feat: remote search index

This commit is contained in:
Cory Dransfeldt 2024-10-19 13:31:55 -07:00
parent e7389ce4c0
commit 193366e946
No known key found for this signature in database
8 changed files with 186 additions and 170 deletions

26
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "coryd.dev",
"version": "1.3.0",
"version": "1.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "coryd.dev",
"version": "1.3.0",
"version": "1.4.0",
"license": "MIT",
"dependencies": {
"@cdransf/api-text": "^1.5.0",
@ -21,7 +21,7 @@
"@11ty/eleventy": "v3.0.0",
"@11ty/eleventy-plugin-syntaxhighlight": "^5.0.0",
"@cdransf/eleventy-plugin-tabler-icons": "^2.0.3",
"@supabase/supabase-js": "^2.45.5",
"@supabase/supabase-js": "^2.45.6",
"autoprefixer": "^10.4.20",
"cssnano": "^7.0.6",
"dotenv-flow": "^4.1.0",
@ -614,9 +614,9 @@
}
},
"node_modules/@supabase/postgrest-js": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-1.16.2.tgz",
"integrity": "sha512-dA/CIrSO2YDQ6ABNpbvEg9DwBMMbuKfWaFuZAU9c66PenoLSoIoyXk1Yq/wC2XISgEIqaMHmTrDAAsO80kjHqg==",
"version": "1.16.3",
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-1.16.3.tgz",
"integrity": "sha512-HI6dsbW68AKlOPofUjDTaosiDBCtW4XAm0D18pPwxoW3zKOE2Ru13Z69Wuys9fd6iTpfDViNco5sgrtnP0666A==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -647,16 +647,16 @@
}
},
"node_modules/@supabase/supabase-js": {
"version": "2.45.5",
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.45.5.tgz",
"integrity": "sha512-xTPsv33Hcj6C38SXa4nKobwEwkNQuwcCKtcuBsDT6bvphl1VUAO3x2QoLOuuglJzk2Oaf3WcVsvRcxXNE8PG/g==",
"version": "2.45.6",
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.45.6.tgz",
"integrity": "sha512-qVXSSUhhIqdFnF2VUGgeecPvw1cDW6+avcTbRgur4LaGnzrJCbM3Rx7g81/SSZjjeqYOtmHuKWhiHzV/EN8Ktw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@supabase/auth-js": "2.65.1",
"@supabase/functions-js": "2.4.3",
"@supabase/node-fetch": "2.6.15",
"@supabase/postgrest-js": "1.16.2",
"@supabase/postgrest-js": "1.16.3",
"@supabase/realtime-js": "2.10.7",
"@supabase/storage-js": "2.7.1"
}
@ -709,9 +709,9 @@
"peer": true
},
"node_modules/@types/node": {
"version": "22.7.6",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.6.tgz",
"integrity": "sha512-/d7Rnj0/ExXDMcioS78/kf1lMzYk4BZV8MZGTBKzTGZ6/406ukkbYlIsZmMPhcR5KlkunDHQLrtAVmSq7r+mSw==",
"version": "22.7.7",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.7.tgz",
"integrity": "sha512-SRxCrrg9CL/y54aiMCG3edPKdprgMVGDXjA3gB8UmmBW5TcXzRUYAh8EWzTnSJFAd1rgImPELza+A3bJ+qxz8Q==",
"license": "MIT",
"dependencies": {
"undici-types": "~6.19.2"

View file

@ -1,6 +1,6 @@
{
"name": "coryd.dev",
"version": "1.3.0",
"version": "1.4.0",
"description": "The source for my personal site. Built using 11ty (and other tools).",
"type": "module",
"engines": {
@ -39,7 +39,7 @@
"@11ty/eleventy": "v3.0.0",
"@11ty/eleventy-plugin-syntaxhighlight": "^5.0.0",
"@cdransf/eleventy-plugin-tabler-icons": "^2.0.3",
"@supabase/supabase-js": "^2.45.5",
"@supabase/supabase-js": "^2.45.6",
"autoprefixer": "^10.4.20",
"cssnano": "^7.0.6",
"dotenv-flow": "^4.1.0",

View file

@ -85,12 +85,11 @@ window.addEventListener('load', () => {
})
})()
// search logic
;(() => {
if (!MiniSearch) return
const miniSearch = new MiniSearch({
fields: ['title', 'text', 'tags', 'type'],
})
const miniSearch = new MiniSearch({ fields: ['title', 'description', 'tags', 'type'] })
const $form = document.querySelector('.search__form')
const $input = document.querySelector('.search__form--input')
const $fallback = document.querySelector('.search__form--fallback')
@ -105,47 +104,108 @@ window.addEventListener('load', () => {
const PAGE_SIZE = 10
let currentPage = 1
let currentResults = []
const loadSearchIndex = async () => {
try {
const response = await fetch('https://coryd.dev/api/search')
const index = await response.json()
const resultsById = index.reduce((byId, result) => {
byId[result.id] = result
return byId
}, {})
miniSearch.addAll(index)
return resultsById
} catch (error) {
console.error('Error fetching search index:', error)
return {}
}
}
let resultsById = {}
let debounceTimeout
loadSearchIndex().then(loadedResultsById => resultsById = loadedResultsById)
const getSelectedTypes = () => {
return Array.from($typeCheckboxes)
.filter(checkbox => checkbox.checked)
.map(checkbox => checkbox.value)
const parseMarkdown = markdown => {
if (!markdown) return ''
return markdown
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.*?)\*/g, '<em>$1</em>')
.replace(/\[(.*?)\]\((.*?)\)/g, '<a href="$2">$1</a>')
.replace(/\n/g, '<br>')
.replace(/[#*_~`]/g, '')
}
$input.addEventListener('input', () => {
const query = $input.value
const truncateDescription = (markdown, maxLength = 150) => {
const htmlDescription = parseMarkdown(markdown)
const tempDiv = document.createElement('div')
tempDiv.innerHTML = htmlDescription
const plainText = tempDiv.textContent || tempDiv.innerText || ''
return plainText.length > maxLength ? `${plainText.substring(0, maxLength)}...` : plainText
}
const formatArtistTitle = (title, totalPlays) =>
totalPlays > 0 ? `${title} <strong class="highlight-text">${totalPlays} plays</strong>` : title
const renderSearchResults = results => {
if (results.length > 0) {
$results.innerHTML = results.map(({ title, url, description, type, total_plays }) => {
const truncatedDesc = truncateDescription(description)
const formattedTitle = type === 'artist' && total_plays !== undefined
? formatArtistTitle(title, total_plays)
: title
return `
<li class="search__results--result">
<a href="${url}">
<h3>${formattedTitle}</h3>
</a>
<p>${truncatedDesc}</p>
</li>
`
}).join('')
$results.style.display = 'block'
} else {
$results.innerHTML = '<li class="search__results--no-results">No results found.</li>'
$results.style.display = 'block'
}
}
const appendSearchResults = results => {
const newResults = results.map(({ title, url, description, type, total_plays }) => {
const truncatedDesc = truncateDescription(description)
const formattedTitle = type === 'artist' && total_plays !== undefined
? formatArtistTitle(title, total_plays)
: title
return `
<li class="search__results--result">
<a href="${url}">
<h3>${formattedTitle}</h3>
</a>
<p>${truncatedDesc}</p>
</li>
`
}).join('')
$results.insertAdjacentHTML('beforeend', newResults)
}
const loadSearchIndex = async (query = '', types = []) => {
const typeQuery = types.join(',')
const response = await fetch(`https://coryd.dev/api/search-beta?q=${query}&type=${typeQuery}`)
const index = await response.json()
resultsById = index.reduce((byId, result) => {
byId[result.id] = result
return byId
}, {})
miniSearch.removeAll()
miniSearch.addAll(index)
return resultsById
}
loadSearchIndex().then(loadedResultsById => resultsById = loadedResultsById)
const getSelectedTypes = () =>
Array.from($typeCheckboxes)
.filter(checkbox => checkbox.checked)
.map(checkbox => checkbox.value)
$input.addEventListener('input', () => {
const query = $input.value.trim()
clearTimeout(debounceTimeout)
if (query.length === 0) {
if (!query) {
renderSearchResults([])
$loadMoreButton.style.display = 'none'
return
}
debounceTimeout = setTimeout(() => {
const results = (query.length > 1) ? getSearchResults(query) : []
debounceTimeout = setTimeout(async () => {
resultsById = await loadSearchIndex(query, getSelectedTypes())
const results = getSearchResults(query)
currentResults = results
currentPage = 1
@ -154,14 +214,10 @@ window.addEventListener('load', () => {
}, 300)
})
$input.addEventListener('keydown', (event) => {
if (event.key === 'Enter') event.preventDefault()
})
$typeCheckboxes.forEach(checkbox => {
checkbox.addEventListener('change', () => {
const query = $input.value
const results = getSearchResults(query)
checkbox.addEventListener('change', async () => {
resultsById = await loadSearchIndex($input.value.trim(), getSelectedTypes())
const results = getSearchResults($input.value.trim())
currentResults = results
currentPage = 1
@ -178,85 +234,12 @@ window.addEventListener('load', () => {
if (currentPage * PAGE_SIZE >= currentResults.length) $loadMoreButton.style.display = 'none'
})
const getSearchResults = (query) => {
const selectedTypes = getSelectedTypes()
return miniSearch.search(query, { prefix: true, fuzzy: 0.2, boost: { title: 2 } })
const getSearchResults = query =>
miniSearch.search(query, { prefix: true, fuzzy: 0.2, boost: { title: 2 } })
.map(({ id }) => resultsById[id])
.filter(result => selectedTypes.includes(result.type))
}
.filter(result => getSelectedTypes().includes(result.type))
const getResultsForPage = (page) => {
const start = (page - 1) * PAGE_SIZE
const end = page * PAGE_SIZE
return currentResults.slice(start, end)
}
const parseMarkdown = (markdown) => {
if (!markdown) return ''
markdown = markdown.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
markdown = markdown.replace(/\*(.*?)\*/g, '<em>$1</em>')
markdown = markdown.replace(/\[(.*?)\]\((.*?)\)/g, '<a href="$2">$1</a>')
markdown = markdown.replace(/\n/g, '<br>')
markdown = markdown.replace(/[#*_~`]/g, '')
return markdown
}
const truncateDescription = (markdown, maxLength = 150) => {
const htmlDescription = parseMarkdown(markdown)
const tempDiv = document.createElement('div')
tempDiv.innerHTML = htmlDescription
const plainText = tempDiv.textContent || tempDiv.innerText || ''
if (plainText.length > maxLength) return plainText.substring(0, maxLength) + '...'
return plainText
}
const formatArtistTitle = (title, totalPlays) => {
if (totalPlays > 0) return `${title} <strong class="highlight-text">${totalPlays} plays</strong>`
return `${title}`
}
const renderSearchResults = (results) => {
if (results.length > 0) {
$results.innerHTML = results.map(({ title, url, description, type, total_plays }) => {
const truncatedDesc = truncateDescription(description)
let formattedTitle = title
if (type === 'artist' && total_plays !== undefined) formattedTitle = formatArtistTitle(title, total_plays)
return `
<li class="search__results--result">
<a href="${url}">
<h3>${formattedTitle}</h3>
</a>
<p>${truncatedDesc}</p>
</li>
`
}).join('\n')
$results.style.display = 'block'
} else {
$results.innerHTML = '<li class="search__results--no-results">No results found.</li>'
$results.style.display = 'block'
}
}
const appendSearchResults = (results) => {
const newResults = results.map(({ title, url, description, type, total_plays }) => {
const truncatedDesc = truncateDescription(description)
let formattedTitle = title
if (type === 'artist' && total_plays !== undefined) formattedTitle = formatArtistTitle(title, total_plays)
return `
<li class="search__results--result">
<a href="${url}">
<h3>${formattedTitle}</h3>
</a>
<p>${truncatedDesc}</p>
</li>
`
}).join('\n')
$results.insertAdjacentHTML('beforeend', newResults)
}
const getResultsForPage = page =>
currentResults.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
})()
})

View file

@ -1,19 +0,0 @@
import { createClient } from '@supabase/supabase-js'
const SUPABASE_URL = process.env.SUPABASE_URL
const SUPABASE_KEY = process.env.SUPABASE_KEY
const supabase = createClient(SUPABASE_URL, SUPABASE_KEY)
export default async function fetchSearchIndex() {
const { data, error } = await supabase
.from('optimized_search_index')
.select('search_index')
if (error) {
console.error('Error fetching search index data:', error)
return []
}
const [{ search_index } = {}] = data
return search_index || []
}

View file

@ -1,4 +0,0 @@
---
permalink: "/api/search"
---
{{ search | json }}

View file

@ -62,9 +62,7 @@ WITH search_data AS (
SELECT
'artist' AS type,
CONCAT(
COALESCE(ar.emoji, ar.genre_emoji, '🎧'), ' ', ar.name
) AS title,
CONCAT(COALESCE(ar.emoji, ar.genre_emoji, '🎧'), ' ', ar.name) AS title,
CONCAT('https://coryd.dev', ar.url) AS url,
ar.description AS description,
ARRAY[ar.genre_name] AS tags,
@ -110,17 +108,13 @@ search_data_with_id AS (
FROM search_data
)
SELECT
json_agg(
json_build_object(
'id', search_data_with_id.id,
'url', search_data_with_id.url,
'title', search_data_with_id.title,
'description', search_data_with_id.description,
'tags', search_data_with_id.tags,
'genre_name', search_data_with_id.genre_name,
'genre_url', search_data_with_id.genre_url,
'type', search_data_with_id.type,
'total_plays', search_data_with_id.total_plays
)
) AS search_index
id,
url,
title,
description,
tags,
genre_name,
genre_url,
type,
total_plays
FROM search_data_with_id;

50
workers/search/index.js Normal file
View file

@ -0,0 +1,50 @@
import { createClient } from '@supabase/supabase-js'
export default {
async fetch(request, env) {
const allowedOrigin = 'https://coryd.dev'
const origin = request.headers.get('Origin') || ''
const referer = request.headers.get('Referer') || ''
if (!origin.startsWith(allowedOrigin) && !referer.startsWith(allowedOrigin)) return new Response('Forbidden', { status: 403 })
const supabaseUrl = env.SUPABASE_URL || process.env.SUPABASE_URL
const supabaseKey = env.SUPABASE_KEY || process.env.SUPABASE_KEY
const supabase = createClient(supabaseUrl, supabaseKey)
const { searchParams } = new URL(request.url)
const query = searchParams.get('q') || ''
const types = searchParams.get('type')?.split(',') || []
const page = parseInt(searchParams.get('page')) || 1
const pageSize = parseInt(searchParams.get('page_size')) || 10
const offset = (page - 1) * pageSize
try {
let supabaseQuery = supabase
.from('optimized_search_index')
.select('*', { count: 'exact' })
if (types.length > 0) supabaseQuery = supabaseQuery.in('type', types)
if (query) supabaseQuery = supabaseQuery.or(`title.ilike.%${query}%,description.ilike.%${query}%`)
const { data, error, count } = await supabaseQuery.range(offset, offset + pageSize - 1)
if (error) {
console.error('Query error:', error)
return new Response('Error fetching data from Supabase', { status: 500 })
}
return new Response(
JSON.stringify({ results: data, total: count, page, pageSize }),
{
headers: {
'Content-Type': 'application/json'
},
}
)
} catch (error) {
console.error('Unexpected error:', error)
return new Response('Internal Server Error', { status: 500 })
}
}
}

View file

@ -0,0 +1,12 @@
name = "search-worker"
main = "./index.js"
compatibility_date = "2023-01-01"
account_id = "${CF_ACCOUNT_ID}"
workers_dev = true
[env.production]
name = "search-worker-production"
routes = [
{ pattern = "coryd.dev/api/search*", zone_id = "${CF_ZONE_ID}" },
]