feat(library): implement multilingual static search

This commit is contained in:
DeCentN2Madness committed 2026-10-09 19:26:58 -07:00
1 parent 76a035424d
commit b20c253383
19 files changed
+1404 -19

No files matched your search

+312
View File
@@ -0,0 +1,312 @@
---
import type { LibraryLocale } from '../utils/library-catalog';
interface Props {
mode?: 'embedded' | 'legacy';
locale?: LibraryLocale;
archiveId?: string;
resultsId: string;
}
const { mode = 'embedded', locale = 'en', archiveId, resultsId } = Astro.props;
const messages = {
en: {
label: 'Search the Library', placeholder: 'Search', submit: 'Search', clear: 'Clear search',
loading: 'Loading Library search…', prompt: 'Enter a term to search Library articles.',
noResults: 'No Library articles matched your search.', error: 'Search is unavailable. The Library list remains available.',
results: 'Library search results', result: 'result', resultsWord: 'results', previous: 'Previous results', next: 'Next results',
language: 'Language', allLanguages: 'All languages', js: 'Interactive Library search requires JavaScript.'
},
es: {
label: 'Buscar en la Biblioteca', placeholder: 'Buscar', submit: 'Buscar', clear: 'Borrar búsqueda',
loading: 'Cargando la búsqueda de la Biblioteca…', prompt: 'Ingrese un término para buscar artículos de la Biblioteca.',
noResults: 'No se encontraron artículos de la Biblioteca.', error: 'La búsqueda no está disponible. La lista de la Biblioteca permanece disponible.',
results: 'Resultados de búsqueda de la Biblioteca', result: 'resultado', resultsWord: 'resultados', previous: 'Resultados anteriores', next: 'Resultados siguientes',
language: 'Idioma', allLanguages: 'Todos los idiomas', js: 'La búsqueda interactiva de la Biblioteca requiere JavaScript.'
},
ar: {
label: 'البحث في المكتبة', placeholder: 'بحث', submit: 'بحث', clear: 'مسح البحث',
loading: 'جارٍ تحميل البحث في المكتبة…', prompt: 'أدخل عبارة للبحث في مقالات المكتبة.',
noResults: 'لم يتم العثور على مقالات مطابقة في المكتبة.', error: 'البحث غير متاح. تبقى قائمة المكتبة متاحة.',
results: 'نتائج البحث في المكتبة', result: 'نتيجة', resultsWord: 'نتائج', previous: 'النتائج السابقة', next: 'النتائج التالية',
language: 'اللغة', allLanguages: 'كل اللغات', js: 'يتطلب البحث التفاعلي في المكتبة تفعيل JavaScript.'
}
}[locale];
---
<div
class="library-search"
data-library-search
data-mode={mode}
data-locale={locale}
data-index-base="/assets/search/library"
data-archive-id={archiveId}
data-results-id={resultsId}
data-messages={JSON.stringify(messages)}
hidden
>
<form class="library-search__form" action={mode === 'legacy' ? '/search' : undefined} method="get" role="search">
<label for={`library-search-${mode}-${locale}`}>{messages.label}</label>
<div class="library-search__controls">
<input
id={`library-search-${mode}-${locale}`}
name={mode === 'legacy' ? 'term' : undefined}
type="search"
placeholder={messages.placeholder}
autocomplete="off"
data-search-input
/>
<button type="submit">{messages.submit}</button>
<button type="reset" class="library-search__clear" data-search-clear disabled>{messages.clear}</button>
</div>
{mode === 'legacy' && (
<label class="library-search__language">
<span>{messages.language}</span>
<select name="lang" data-search-language>
<option value="all">{messages.allLanguages}</option>
<option value="en">English</option>
<option value="es">Español</option>
<option value="ar">العربية</option>
</select>
</label>
)}
</form>
<p class="library-search__status" data-search-status aria-live="polite" aria-atomic="true"></p>
</div>
<noscript><p class="library-search__noscript">{messages.js}</p></noscript>
<script>
import { boundedSearchOffset, parseLegacyLibrarySearchState, searchLibrary } from '../utils/library-search';
import type { LibraryLocale, LibrarySearchRecord } from '../utils/library-catalog';
interface SearchMessages {
loading: string; prompt: string; noResults: string; error: string; results: string;
result: string; resultsWord: string; previous: string; next: string;
}
const localeNames: Record<LibraryLocale, string> = { en: 'English', es: 'Español', ar: 'العربية' };
const indexCache = new Map<LibraryLocale, Promise<LibrarySearchRecord[]>>();
function loadIndex(base: string, locale: LibraryLocale): Promise<LibrarySearchRecord[]> {
let request = indexCache.get(locale);
if (!request) {
request = fetch(`${base}/${locale}.json`, { headers: { Accept: 'application/json' } }).then(async (response) => {
if (!response.ok) throw new Error(`Search index HTTP ${response.status}`);
const records = await response.json();
if (!Array.isArray(records) || records.some((record) => record?.locale !== locale || typeof record?.url !== 'string')) {
throw new Error('Invalid Library search index');
}
return records as LibrarySearchRecord[];
}).catch((error) => {
indexCache.delete(locale);
throw error;
});
indexCache.set(locale, request);
}
return request;
}
function createResult(record: LibrarySearchRecord): HTMLElement {
const article = document.createElement('article');
article.className = 'library-search-result';
article.lang = record.locale;
article.dir = record.locale === 'ar' ? 'rtl' : 'ltr';
const heading = document.createElement('h3');
const link = document.createElement('a');
link.href = record.url;
link.textContent = record.title;
heading.append(link);
const description = document.createElement('p');
description.textContent = record.description;
const meta = document.createElement('p');
meta.className = 'library-search-result__meta';
const date = new Intl.DateTimeFormat(record.locale, { dateStyle: 'long', timeZone: 'UTC' })
.format(new Date(`${record.publishedAt}T00:00:00Z`));
meta.textContent = `${record.author.name} · ${date} · ${localeNames[record.locale]}`;
article.append(heading, description, meta);
return article;
}
document.querySelectorAll<HTMLElement>('[data-library-search]').forEach((root) => {
const form = root.querySelector<HTMLFormElement>('form');
const input = root.querySelector<HTMLInputElement>('[data-search-input]');
const clear = root.querySelector<HTMLButtonElement>('[data-search-clear]');
const language = root.querySelector<HTMLSelectElement>('[data-search-language]');
const status = root.querySelector<HTMLElement>('[data-search-status]');
const results = document.getElementById(root.dataset.resultsId ?? '');
const archive = root.dataset.archiveId ? document.getElementById(root.dataset.archiveId) : null;
const mode = root.dataset.mode === 'legacy' ? 'legacy' : 'embedded';
const locale = (root.dataset.locale ?? 'en') as LibraryLocale;
const base = root.dataset.indexBase ?? '/assets/search/library';
const messages = JSON.parse(root.dataset.messages ?? '{}') as SearchMessages;
if (!form || !input || !clear || !status || !results) return;
const setArchiveVisible = (visible: boolean) => {
if (archive) archive.hidden = !visible;
results.hidden = visible;
if (visible) results.replaceChildren();
};
const setStatus = (message: string) => { status.textContent = message; };
const heading = () => {
const element = document.createElement('h2');
element.tabIndex = -1;
element.textContent = messages.results;
return element;
};
const render = (records: LibrarySearchRecord[], query: string, offset = 0, focus = false) => {
const matches = searchLibrary(records, query);
const normalizedOffset = boundedSearchOffset(matches.length, offset);
const visible = mode === 'legacy' ? matches.slice(normalizedOffset, normalizedOffset + 10) : matches;
const fragment = document.createDocumentFragment();
const title = heading();
fragment.append(title);
const list = document.createElement('div');
list.className = 'library-search-results__list';
visible.forEach(({ record }) => list.append(createResult(record)));
fragment.append(list);
if (mode === 'legacy' && matches.length > 10) {
const pager = document.createElement('nav');
pager.className = 'library-search-results__pager';
pager.setAttribute('aria-label', messages.results);
for (const [label, nextOffset] of [[messages.previous, normalizedOffset - 10], [messages.next, normalizedOffset + 10]] as const) {
const button = document.createElement('button');
button.type = 'button';
button.textContent = label;
button.disabled = nextOffset < 0 || nextOffset >= matches.length;
button.addEventListener('click', () => updateLegacyOffset(nextOffset));
pager.append(button);
}
fragment.append(pager);
}
results.replaceChildren(fragment);
results.hidden = false;
if (archive) archive.hidden = true;
clear.disabled = false;
setStatus(matches.length
? `${matches.length} ${matches.length === 1 ? messages.result : messages.resultsWord}`
: messages.noResults);
if (focus) title.focus();
return normalizedOffset;
};
const fail = () => {
setArchiveVisible(true);
clear.disabled = !input.value;
setStatus(messages.error);
};
const embeddedQuery = () => {
const hash = window.location.hash.slice(1);
if (!hash) return '';
const params = new URLSearchParams(hash);
return params.has('search') ? params.get('search') ?? '' : '';
};
let focusAfterNavigation = false;
const runEmbedded = async () => {
const query = embeddedQuery();
input.value = query;
if (!query.trim()) {
setArchiveVisible(true);
clear.disabled = true;
setStatus('');
return;
}
setStatus(messages.loading);
try {
const records = await loadIndex(base, locale);
render(records, query, 0, focusAfterNavigation);
} catch { fail(); }
finally { focusAfterNavigation = false; }
};
const legacyState = () => {
const params = new URLSearchParams(window.location.search);
const state = parseLegacyLibrarySearchState(params);
return { params, query: state.query, selected: state.language, offset: state.offset };
};
const runLegacy = async (focus = false) => {
const state = legacyState();
input.value = state.query;
if (language) language.value = state.selected;
if (!state.query.trim()) {
results.hidden = false;
results.replaceChildren(heading());
clear.disabled = true;
setStatus(messages.prompt);
return;
}
setStatus(messages.loading);
try {
const locales: LibraryLocale[] = state.selected === 'all' ? ['en', 'es', 'ar'] : [state.selected];
const records = (await Promise.all(locales.map((item) => loadIndex(base, item)))).flat();
const actualOffset = render(records, state.query, state.offset, focus);
const sourceOffset = state.params.get('offset');
const canonicalOffset = actualOffset ? String(actualOffset) : null;
if (actualOffset !== state.offset || sourceOffset !== canonicalOffset) {
if (actualOffset) state.params.set('offset', String(actualOffset)); else state.params.delete('offset');
history.replaceState(null, '', `${location.pathname}?${state.params}${location.hash}`);
}
} catch { fail(); }
};
const updateLegacyOffset = (offset: number) => {
const state = legacyState();
if (offset > 0) state.params.set('offset', String(offset)); else state.params.delete('offset');
history.pushState(null, '', `${location.pathname}?${state.params}${location.hash}`);
void runLegacy(true);
};
form.addEventListener('submit', (event) => {
event.preventDefault();
const query = input.value.trim();
if (mode === 'embedded') {
if (!root.dataset.previousHash && location.hash && !location.hash.startsWith('#search=')) root.dataset.previousHash = location.hash;
focusAfterNavigation = true;
const nextHash = query ? `#${new URLSearchParams({ search: query })}` : root.dataset.previousHash ?? '';
if (location.hash === nextHash) void runEmbedded(); else location.hash = nextHash;
} else {
const state = legacyState();
if (query) state.params.set('term', query); else state.params.delete('term');
state.params.delete('q');
state.params.delete('offset');
if (language?.value && language.value !== 'all') state.params.set('lang', language.value); else state.params.delete('lang');
history.pushState(null, '', `${location.pathname}${state.params.size ? `?${state.params}` : ''}`);
void runLegacy(true);
}
});
form.addEventListener('reset', (event) => {
event.preventDefault();
input.value = '';
if (mode === 'embedded') {
const previous = root.dataset.previousHash ?? '';
delete root.dataset.previousHash;
history.pushState(null, '', `${location.pathname}${location.search}${previous}`);
void runEmbedded();
} else {
const params = legacyState().params;
params.delete('term'); params.delete('q'); params.delete('offset'); params.delete('lang');
history.pushState(null, '', `${location.pathname}${params.size ? `?${params}` : ''}`);
if (language) language.value = 'all';
void runLegacy();
}
input.focus();
});
if (language) language.addEventListener('change', () => form.requestSubmit());
window.addEventListener(mode === 'embedded' ? 'hashchange' : 'popstate', () => {
if (mode === 'embedded') void runEmbedded(); else void runLegacy();
});
root.hidden = false;
if (mode === 'embedded') void runEmbedded(); else void runLegacy();
});
</script>
<style>
.library-search label { display: block; font-weight: 700; margin-bottom: .4rem; }
.library-search__controls { display: grid; gap: .5rem; }
.library-search input, .library-search select { border: 1px solid var(--color-border); border-radius: 4px; font: inherit; padding: .7rem; width: 100%; }
.library-search button { background: var(--color-primary); border: 2px solid var(--color-primary); border-radius: 4px; color: white; cursor: pointer; font: inherit; font-weight: 700; padding: .55rem .75rem; }
.library-search button:focus-visible, .library-search input:focus-visible, .library-search select:focus-visible { outline: 3px solid var(--color-accent); outline-offset: 2px; }
.library-search button:disabled { cursor: default; opacity: .55; }
.library-search__clear { background: white !important; color: var(--color-primary) !important; }
.library-search__language { margin-top: 1rem; }
.library-search__status { font-size: .9rem; min-height: 1.5em; }
.library-search__noscript { font-size: .9rem; }
</style>
+6 -4
View File
@@ -1,11 +1,13 @@
---
import { getPublicationManifest } from '../../utils/publication';
import { getLibraryCatalog } from '../../utils/library';
import BlogIndexLayout from '../../layouts/BlogIndexLayout.astro';
import BlogCard from '../BlogCard.astro';
const posts = (await getPublicationManifest()).flatMap(({ eligible, entry }) => eligible && entry.collection === 'blog' && entry.data.lang === 'ar' ? [entry] : []).sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
import LibrarySearch from '../LibrarySearch.astro';
const posts = (await getLibraryCatalog()).byLocale.ar.map(({ entry }) => entry);
const popular = posts.slice(0, 5);
---
<BlogIndexLayout title="مكتبة معهد أريزونا للتوحد" description="مقالات إخبارية حول علاج تحليل السلوك التطبيقي والتعليم الخاص، ودراسات حالة، ووصفات، ومقالات افتتاحية، ودروس تعليمية." lang="ar">
<div class="blog-list">{posts.map((post) => <BlogCard post={post} prefix="/ar" />)}</div>
<aside class="library-sidebar"><h2>المقالات الشائعة</h2><ul>{popular.map((post) => <li><a href={`/ar/library/${post.data.slug}`}>{post.data.title}</a></li>)}</ul></aside>
<div id="library-results-ar" class="library-search-results" hidden></div>
<div id="library-archive-ar" class="blog-list">{posts.map((post) => <BlogCard post={post} prefix="/ar" />)}</div>
<aside class="library-sidebar"><LibrarySearch locale="ar" archiveId="library-archive-ar" resultsId="library-results-ar" /><h2>المقالات الشائعة</h2><ul>{popular.map((post) => <li><a href={`/ar/library/${post.data.slug}`}>{post.data.title}</a></li>)}</ul></aside>
</BlogIndexLayout>
@@ -1,8 +1,9 @@
---
import { getPublicationManifest } from '../../utils/publication';
import { getLibraryCatalog } from '../../utils/library';
import BlogIndexLayout from '../../layouts/BlogIndexLayout.astro';
import BlogCard from '../BlogCard.astro';
const posts = (await getPublicationManifest()).flatMap(({ eligible, entry }) => eligible && entry.collection === 'blog' && entry.data.lang === 'en' ? [entry] : []).sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
import LibrarySearch from '../LibrarySearch.astro';
const posts = (await getLibraryCatalog()).byLocale.en.map(({ entry }) => entry);
const popular = posts.slice(0, 5);
---
<BlogIndexLayout
@@ -13,10 +14,10 @@ const popular = posts.slice(0, 5);
"We also highlight community events, success stories, and expert guidance to foster understanding and empowerment. Whether you're a parent, caregiver, educator, or advocate, our goal is to provide meaningful content that helps children with autism reach their highest potential in a compassionate and supportive environment. Stay connected with us as we continue to share knowledge and celebrate progress!",
]}
>
<div class="blog-list">{posts.map((post) => <BlogCard post={post} />)}</div>
<div id="library-results-en" class="library-search-results" hidden></div>
<div id="library-archive-en" class="blog-list">{posts.map((post) => <BlogCard post={post} />)}</div>
<aside class="library-sidebar">
<label for="library-search">Search</label>
<input id="library-search" type="search" placeholder="Search" />
<LibrarySearch locale="en" archiveId="library-archive-en" resultsId="library-results-en" />
<h2>Popular Posts</h2>
<ul>{popular.map((post) => <li><a href={`/library/${post.data.slug}`}>{post.data.title}</a></li>)}</ul>
</aside>
@@ -1,11 +1,13 @@
---
import { getPublicationManifest } from '../../utils/publication';
import { getLibraryCatalog } from '../../utils/library';
import BlogIndexLayout from '../../layouts/BlogIndexLayout.astro';
import BlogCard from '../BlogCard.astro';
const posts = (await getPublicationManifest()).flatMap(({ eligible, entry }) => eligible && entry.collection === 'blog' && entry.data.lang === 'es' ? [entry] : []).sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
import LibrarySearch from '../LibrarySearch.astro';
const posts = (await getLibraryCatalog()).byLocale.es.map(({ entry }) => entry);
const popular = posts.slice(0, 5);
---
<BlogIndexLayout title="La Biblioteca en AZ Institute for Autism" description="Noticias sobre terapia ABA y educación especial, casos de estudio, recetas, editoriales y tutoriales para y sobre nuestros estudiantes con autismo." lang="es">
<div class="blog-list">{posts.map((post) => <BlogCard post={post} prefix="/es" />)}</div>
<aside class="library-sidebar"><h2>Publicaciones populares</h2><ul>{popular.map((post) => <li><a href={`/es/library/${post.data.slug}`}>{post.data.title}</a></li>)}</ul></aside>
<div id="library-results-es" class="library-search-results" hidden></div>
<div id="library-archive-es" class="blog-list">{posts.map((post) => <BlogCard post={post} prefix="/es" />)}</div>
<aside class="library-sidebar"><LibrarySearch locale="es" archiveId="library-archive-es" resultsId="library-results-es" /><h2>Publicaciones populares</h2><ul>{popular.map((post) => <li><a href={`/es/library/${post.data.slug}`}>{post.data.title}</a></li>)}</ul></aside>
</BlogIndexLayout>
+7 -2
View File
@@ -29,13 +29,18 @@ const { entry } = await getPublication(Astro.url.pathname);
:global(.library-intro h2) { color: white; }
:global(.library-layout) { align-items: start; display: grid; gap: 3rem; grid-template-columns: 1fr 240px; }
:global(.library-sidebar) { position: sticky; top: 1rem; }
:global(.library-sidebar label) { display: block; font-weight: 700; margin-bottom: .4rem; }
:global(.library-sidebar input) { border: 1px solid var(--color-border); border-radius: 4px; font: inherit; padding: .7rem; width: 100%; }
:global(.library-sidebar h2) { font-size: 1.1rem; margin-top: 2rem; }
:global(.library-sidebar ul) { list-style: none; padding: 0; }
:global(.library-sidebar li) { border-bottom: 1px solid var(--color-border); padding-block: .65rem; }
:global(.library-sidebar a) { color: var(--color-primary); font-size: .9rem; }
:global(.blog-list) { display: block; }
:global(.library-search-results[hidden]), :global(.blog-list[hidden]) { display: none; }
:global(.library-search-results h2) { margin-top: 0; }
:global(.library-search-result) { border-bottom: 1px solid var(--color-border); margin-bottom: 2rem; padding-bottom: 1.5rem; }
:global(.library-search-result h3) { font-family: var(--font-body); margin-bottom: .65rem; }
:global(.library-search-result p) { margin-bottom: .65rem; }
:global(.library-search-result__meta) { font-size: .85rem; }
:global(.library-search-results__pager) { display: flex; gap: .75rem; }
@media (max-width: 760px) {
:global(.library-layout) { grid-template-columns: 1fr; }
:global(.library-sidebar) { position: static; }
@@ -0,0 +1,18 @@
import type { APIRoute } from 'astro';
import { getLibraryCatalog } from '../../../../utils/library';
import { libraryLocales, type LibraryLocale } from '../../../../utils/library-catalog';
export function getStaticPaths() {
return libraryLocales.map((locale) => ({ params: { locale }, props: { locale } }));
}
export const GET: APIRoute = async ({ props }) => {
const locale = props.locale as LibraryLocale;
const records = (await getLibraryCatalog()).byLocale[locale].map(({ search }) => search);
return new Response(`${JSON.stringify(records)}\n`, {
headers: {
'Content-Type': 'application/json; charset=utf-8',
'X-Content-Type-Options': 'nosniff'
}
});
};
+41
View File
@@ -0,0 +1,41 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import PageHero from '../components/PageHero.astro';
import LibrarySearch from '../components/LibrarySearch.astro';
---
<BaseLayout
title="Search the Library | Arizona Institute for Autism"
description="Search English, Spanish, and Arabic articles from the Arizona Institute for Autism Library."
lang="en"
>
<PageHero title="Search the Library" />
<section class="section">
<div class="container-narrow search-page">
<p>This search includes published Library articles in English, Spanish, and Arabic. It does not search other website pages.</p>
<p>Embedded Library search keeps terms in the browser URL fragment. Searches on this compatibility page use the URL query string and may appear in ordinary server access logs.</p>
<LibrarySearch mode="legacy" locale="en" resultsId="library-search-results" />
<div id="library-search-results" class="library-search-results" hidden></div>
<noscript>
<nav aria-label="Library languages">
<a href="/library">English Library</a> ·
<a href="/es/library" lang="es">Biblioteca en español</a> ·
<a href="/ar/library" lang="ar" dir="rtl">المكتبة العربية</a>
</nav>
</noscript>
</div>
</section>
</BaseLayout>
<style>
.search-page > p { margin-bottom: 1rem; }
.search-page :global(.library-search) { margin-block: 2rem; }
.search-page :global(.library-search__controls) { grid-template-columns: minmax(0, 1fr) auto auto; }
.search-page :global(.library-search-results[hidden]) { display: none; }
.search-page :global(.library-search-result) { border-bottom: 1px solid var(--color-border); margin-bottom: 2rem; padding-bottom: 1.5rem; }
.search-page :global(.library-search-result__meta) { font-size: .85rem; }
.search-page :global(.library-search-results__pager) { display: flex; gap: .75rem; }
@media (max-width: 640px) {
.search-page :global(.library-search__controls) { grid-template-columns: 1fr; }
}
</style>
+163
View File
@@ -0,0 +1,163 @@
export const libraryLocales = ['en', 'es', 'ar'] as const;
export type LibraryLocale = typeof libraryLocales[number];
export interface LibraryBlogEntry {
id: string;
collection: 'blog';
body?: string;
data: {
lang: string;
slug: string;
title: string;
description?: string;
date: Date | string | number;
author: string;
category?: string;
tags?: string[];
};
}
export interface LibraryAuthorEntry {
id: string;
data: {
lang: string;
slug: string;
name: string;
};
}
export interface LibraryPublication<TEntry extends LibraryBlogEntry = LibraryBlogEntry> {
route: string;
eligible: boolean;
entry: TEntry | { collection: string };
}
export interface LibrarySearchRecord {
url: string;
locale: LibraryLocale;
title: string;
description: string;
headings: string[];
category: string;
tags: string[];
author: {
name: string;
slug: string;
};
publishedAt: string;
}
export interface LibraryCatalogItem<TEntry extends LibraryBlogEntry = LibraryBlogEntry> {
route: string;
entry: TEntry;
search: LibrarySearchRecord;
}
export interface LibraryCatalog<TEntry extends LibraryBlogEntry = LibraryBlogEntry> {
all: LibraryCatalogItem<TEntry>[];
byLocale: Record<LibraryLocale, LibraryCatalogItem<TEntry>[]>;
}
const localeOrder = new Set<string>(libraryLocales);
const compareRoutes = (left: string, right: string) => left.localeCompare(right, 'en');
function plainHeading(value: string): string {
if (/[<{][^>}]*(?:script|style|import|export)[^>}]*[>}]/iu.test(value)) return '';
const text = value
.replace(/\s+#+\s*$/u, '')
.replace(/!\[([^\]]*)\]\([^)]*\)/gu, '$1')
.replace(/\[([^\]]+)\]\([^)]*\)/gu, '$1')
.replace(/`([^`]*)`/gu, '$1')
.replace(/<[^>]+>/gu, ' ')
.replace(/[\*_~]/gu, '')
.replace(/\\([\\`*{}\[\]()#+.!_>-])/gu, '$1')
.replace(/\s+/gu, ' ')
.trim();
return text.length <= 300 ? text : text.slice(0, 300).trimEnd();
}
/** Extract bounded visible Markdown headings without parsing code or MDX implementation. */
export function extractMarkdownHeadings(source = ''): string[] {
const headings: string[] = [];
let fence: string | undefined;
for (const line of source.split(/\r?\n/u)) {
const fenceMatch = line.match(/^\s{0,3}(`{3,}|~{3,})/u);
if (fenceMatch) {
if (!fence) fence = fenceMatch[1][0];
else if (fence === fenceMatch[1][0]) fence = undefined;
continue;
}
if (fence) continue;
const match = line.match(/^\s{0,3}#{1,6}\s+(.+?)\s*$/u);
if (!match) continue;
const heading = plainHeading(match[1]);
if (heading && !headings.includes(heading)) headings.push(heading);
if (headings.length === 50) break;
}
return headings;
}
function normalizedTags(tags: string[] | undefined): string[] {
return [...new Set((tags ?? []).map((tag) => tag.trim()).filter(Boolean))];
}
function publishedAt(value: Date | string | number, id: string): { iso: string; time: number } {
const date = value instanceof Date ? value : new Date(value);
const time = date.valueOf();
if (!Number.isFinite(time)) throw new Error(`Invalid Library publication date: ${id}`);
return { iso: date.toISOString().slice(0, 10), time };
}
export function createLibraryCatalog<TEntry extends LibraryBlogEntry>(
manifest: LibraryPublication<TEntry>[],
authors: LibraryAuthorEntry[]
): LibraryCatalog<TEntry> {
const authorMap = new Map<string, LibraryAuthorEntry>();
for (const author of authors) {
if (!localeOrder.has(author.data.lang) || !author.data.slug || !author.data.name.trim()) {
throw new Error(`Invalid Library author record: ${author.id}`);
}
const key = `${author.data.lang}:${author.data.slug}`;
if (authorMap.has(key)) throw new Error(`Missing or ambiguous article author: ${key} (duplicate Library author records)`);
authorMap.set(key, author);
}
const seenRoutes = new Set<string>();
const dated: Array<LibraryCatalogItem<TEntry> & { publicationTime: number }> = [];
for (const publication of manifest) {
if (!publication.eligible || publication.entry.collection !== 'blog') continue;
const entry = publication.entry as TEntry;
const locale = entry.data.lang as LibraryLocale;
if (!localeOrder.has(locale)) throw new Error(`Unsupported Library locale: ${entry.id}`);
if (seenRoutes.has(publication.route)) throw new Error(`Duplicate Library route: ${publication.route}`);
seenRoutes.add(publication.route);
const author = authorMap.get(`${locale}:${entry.data.author}`);
if (!author) throw new Error(`Missing or ambiguous article author: ${locale}:${entry.data.author} for ${entry.id}`);
const date = publishedAt(entry.data.date, entry.id);
dated.push({
route: publication.route,
entry,
publicationTime: date.time,
search: {
url: publication.route,
locale,
title: entry.data.title,
description: entry.data.description ?? '',
headings: extractMarkdownHeadings(entry.body),
category: entry.data.category?.trim() ?? '',
tags: normalizedTags(entry.data.tags),
author: { name: author.data.name, slug: author.data.slug },
publishedAt: date.iso
}
});
}
dated.sort((left, right) => right.publicationTime - left.publicationTime || compareRoutes(left.route, right.route));
const all = dated.map(({ publicationTime: _publicationTime, ...item }) => item);
const byLocale = Object.fromEntries(libraryLocales.map((locale) => [
locale,
all.filter((item) => item.search.locale === locale)
])) as Record<LibraryLocale, LibraryCatalogItem<TEntry>[]>;
return { all, byLocale };
}
+97
View File
@@ -0,0 +1,97 @@
import type { LibraryLocale, LibrarySearchRecord } from './library-catalog';
export interface LibrarySearchMatch {
record: LibrarySearchRecord;
score: number;
}
export interface LegacyLibrarySearchState {
query: string;
language: LibraryLocale | 'all';
offset: number;
types: string[];
}
const combiningMarks = /\p{M}+/gu;
const punctuation = /[\p{P}\p{S}]+/gu;
/** Build a comparison-only key while preserving the original record for display. */
export function normalizeLibrarySearchText(value: unknown, locale: LibraryLocale): string {
return String(value ?? '')
.normalize('NFKC')
.toLocaleLowerCase(locale)
.normalize('NFD')
.replace(combiningMarks, '')
.replace(/\u0640/gu, '')
.normalize('NFC')
.replace(punctuation, ' ')
.replace(/\s+/gu, ' ')
.trim();
}
function words(value: string): Set<string> {
return new Set(value.split(' ').filter(Boolean));
}
function tokenScore(token: string, fields: Array<{ text: string; weight: number }>): number {
let score = 0;
for (const field of fields) {
const fieldWords = words(field.text);
if (fieldWords.has(token)) score = Math.max(score, field.weight);
// Three-character partial tokens support attached Arabic particles and
// ordinary word prefixes without introducing fuzzy edits or stemming.
else if (token.length >= 3 && [...fieldWords].some((word) => word.includes(token))) {
score = Math.max(score, Math.floor(field.weight * 0.6));
}
}
return score;
}
export function searchLibrary(records: LibrarySearchRecord[], query: unknown): LibrarySearchMatch[] {
const locale = records[0]?.locale ?? 'en';
const normalizedQuery = normalizeLibrarySearchText(query, locale);
if (!normalizedQuery) return [];
const tokens = [...new Set(normalizedQuery.split(' ').filter(Boolean))];
return records.flatMap((record) => {
const title = normalizeLibrarySearchText(record.title, record.locale);
const headings = normalizeLibrarySearchText(record.headings.join(' '), record.locale);
const description = normalizeLibrarySearchText(record.description, record.locale);
const categoryTags = normalizeLibrarySearchText([record.category, ...record.tags].join(' '), record.locale);
const author = normalizeLibrarySearchText(`${record.author.name} ${record.author.slug}`, record.locale);
const fields = [
{ text: title, weight: 100 },
{ text: headings, weight: 60 },
{ text: description, weight: 30 },
{ text: categoryTags, weight: 20 },
{ text: author, weight: 10 }
];
const scores = tokens.map((token) => tokenScore(token, fields));
if (scores.some((score) => score === 0)) return [];
const phrase = title === normalizedQuery ? 1000 : title.includes(normalizedQuery) ? 500 : 0;
return [{ record, score: phrase + scores.reduce((total, score) => total + score, 0) }];
}).sort((left, right) => right.score - left.score
|| Date.parse(right.record.publishedAt) - Date.parse(left.record.publishedAt)
|| left.record.url.localeCompare(right.record.url, 'en'));
}
/** Parse legacy state without treating compatibility content types as search scope. */
export function parseLegacyLibrarySearchState(value: string | URLSearchParams): LegacyLibrarySearchState {
const params = typeof value === 'string' ? new URLSearchParams(value) : value;
const query = params.has('term') ? params.get('term') ?? '' : params.get('q') ?? '';
const requestedLanguage = params.get('lang');
const language = requestedLanguage && ['en', 'es', 'ar'].includes(requestedLanguage)
? requestedLanguage as LibraryLocale
: 'all';
const rawOffset = Number(params.get('offset'));
const offset = Number.isInteger(rawOffset) && rawOffset >= 0 && rawOffset % 10 === 0 ? rawOffset : 0;
return { query, language, offset, types: params.getAll('type') };
}
export function boundedSearchOffset(resultCount: number, requestedOffset: number, pageSize = 10): number {
if (!Number.isInteger(resultCount) || resultCount <= 0 || !Number.isInteger(pageSize) || pageSize <= 0) return 0;
const validOffset = Number.isInteger(requestedOffset) && requestedOffset >= 0 && requestedOffset % pageSize === 0
? requestedOffset
: 0;
return Math.min(validOffset, Math.floor((resultCount - 1) / pageSize) * pageSize);
}
+15
View File
@@ -0,0 +1,15 @@
import { getCollection, type CollectionEntry } from 'astro:content';
import { createLibraryCatalog, type LibraryCatalog } from './library-catalog';
import { getPublicationManifest } from './publication';
let catalog: Promise<LibraryCatalog<CollectionEntry<'blog'>>> | undefined;
export function getLibraryCatalog(): Promise<LibraryCatalog<CollectionEntry<'blog'>>> {
catalog ??= Promise.all([getPublicationManifest(), getCollection('authors')]).then(([manifest, authors]) =>
createLibraryCatalog(
manifest as Parameters<typeof createLibraryCatalog<CollectionEntry<'blog'>>>[0],
authors
)
);
return catalog;
}
+3
View File
@@ -8,6 +8,9 @@ export interface PublicationEntry {
noindex?: boolean;
canonical?: string;
translationKey?: string;
displayH1?: string;
featuredImage?: string;
alt?: string;
};
}
+2
View File
@@ -1,6 +1,7 @@
import { getCollection } from 'astro:content';
import site from '../data/site.json';
import { createPublicationManifest, normalizeRoute } from './publication-policy';
import { createLibrarySearchPublication } from './search-publication';
export const allowIndexing = import.meta.env.PUBLIC_ALLOW_INDEXING === 'true';
@@ -11,6 +12,7 @@ export async function getPublicationManifest() {
export async function getPublication(route: string) {
const normalized = normalizeRoute(route);
if (normalized === '/search') return createLibrarySearchPublication(site.url);
const publication = (await getPublicationManifest()).find((page) => page.route === normalized);
if (!publication?.eligible) throw new Error(`Route is not published: ${normalized}`);
return publication;
+11
View File
@@ -0,0 +1,11 @@
import { publicationFor, type PublicationEntry, type TranslatedPublication } from './publication-policy.ts';
const entry: PublicationEntry = {
id: 'utility/search',
collection: 'pages',
data: { lang: 'en', slug: 'search', draft: false, noindex: true }
};
export function createLibrarySearchPublication(site: string): TranslatedPublication & { entry: PublicationEntry } {
return { ...publicationFor(entry, site), entry, translations: [] };
}