@acms-subir/core 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,6 +6,8 @@ import { siteOrigin, absUrl, canonicalPath } from '../lib/seo';
6
6
  import { siteLang } from '../lib/dates';
7
7
  import { CONTENT } from '../lib/content';
8
8
  import { I18N, localeFromPath, localeHomeUrl } from '../lib/i18n';
9
+ import { SEARCH } from '../lib/search-config';
10
+ import { searchStrings } from '../lib/search-i18n';
9
11
  import '../styles/site.css';
10
12
  import '../styles/cards.css';
11
13
 
@@ -62,6 +64,11 @@ const metaDescription = description || settings.tagline || undefined;
62
64
  // locale setting (a date-formatting concern) stays the fallback
63
65
  const reqLocale = I18N.enabled ? localeFromPath(current) : '';
64
66
  const htmlLang = reqLocale || siteLang(settings);
67
+ // search modal chrome: strings follow the document language, the form's
68
+ // no-JS fallback goes to the (locale-prefixed) /search page
69
+ const showSearch = !bare && SEARCH.enabled;
70
+ const searchT = searchStrings(htmlLang.slice(0, 2));
71
+ const searchAction = reqLocale && reqLocale !== I18N.defaultLocale ? `/${reqLocale}/search` : '/search';
65
72
  // hreflang alternates connect the per-language home pages; other routes have
66
73
  // no language twins yet, so alternates are emitted only on home paths
67
74
  const isHomePath = I18N.enabled && I18N.locales.some((l) => current === localeHomeUrl(l));
@@ -188,9 +195,114 @@ const homeJsonLd =
188
195
  ))}
189
196
  </span>
190
197
  )}
198
+ {showSearch && (
199
+ <a class="pc-search-trigger" href={searchAction} data-search-open aria-label={searchT.label}>
200
+ <svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><circle cx="11" cy="11" r="7" /><path d="m20 20-3.6-3.6" /></svg>
201
+ </a>
202
+ )}
191
203
  </nav>
192
204
  </header>
193
205
  )}
206
+ {showSearch && (
207
+ <dialog class="pc-search" aria-label={searchT.label}>
208
+ {/* a real GET form: with JS disabled (or the fetch failing) the
209
+ query submits to the /search page instead of the live modal */}
210
+ <form class="pc-search-box" action={searchAction} method="get" role="search">
211
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><circle cx="11" cy="11" r="7" /><path d="m20 20-3.6-3.6" /></svg>
212
+ <input class="pc-search-input" type="search" name="q" placeholder={searchT.placeholder} aria-label={searchT.label} autocomplete="off" />
213
+ <button type="button" class="pc-search-kbd" data-search-close>esc</button>
214
+ </form>
215
+ <div class="pc-search-results" hidden></div>
216
+ </dialog>
217
+ )}
218
+ {showSearch && (
219
+ <script is:inline define:vars={{ searchLocale: reqLocale, t: searchT }}>
220
+ (() => {
221
+ const dlg = document.querySelector('dialog.pc-search');
222
+ if (!dlg || !dlg.showModal) return;
223
+ const input = dlg.querySelector('.pc-search-input');
224
+ const box = dlg.querySelector('.pc-search-results');
225
+ let ctl = null;
226
+ let timer = 0;
227
+ let sel = -1;
228
+
229
+ const open = () => { if (!dlg.open) dlg.showModal(); input.focus(); input.select(); };
230
+ document.querySelectorAll('[data-search-open]').forEach((el) =>
231
+ el.addEventListener('click', (e) => { e.preventDefault(); open(); })
232
+ );
233
+ dlg.querySelectorAll('[data-search-close]').forEach((el) => el.addEventListener('click', () => dlg.close()));
234
+ // clicks on ::backdrop (and the dialog's transparent frame) land on
235
+ // the dialog element itself — descendants stop at the panel
236
+ dlg.addEventListener('click', (e) => { if (e.target === dlg) dlg.close(); });
237
+ document.addEventListener('keydown', (e) => {
238
+ if ((e.key === 'k' || e.key === 'K') && (e.metaKey || e.ctrlKey)) {
239
+ e.preventDefault();
240
+ dlg.open ? dlg.close() : open();
241
+ return;
242
+ }
243
+ const a = document.activeElement;
244
+ const typing = a && (/^(INPUT|TEXTAREA|SELECT)$/.test(a.tagName) || a.isContentEditable);
245
+ if (e.key === '/' && !typing && !dlg.open) { e.preventDefault(); open(); }
246
+ });
247
+
248
+ const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
249
+ // snippet arrives pre-escaped from /api/search (only <mark> inside)
250
+ const hit = (url, title, meta, snippet) =>
251
+ `<a class="pc-search-hit" href="${esc(url)}"><span class="pc-search-t">${esc(title)}` +
252
+ (snippet ? `<span class="pc-search-snip">${snippet}</span>` : '') +
253
+ `</span>${meta ? `<span class="pc-search-m">${esc(meta)}</span>` : ''}</a>`;
254
+
255
+ const render = (d) => {
256
+ const groups = [];
257
+ if (d.posts.length) groups.push([t.posts, d.posts.map((p) => hit(p.url, p.title, p.date, p.snippet))]);
258
+ if (d.pages.length) groups.push([t.pages, d.pages.map((p) => hit(p.url, p.title, (p.locale || '').toUpperCase(), p.snippet))]);
259
+ if (d.tags.length) groups.push([t.tags, d.tags.map((g) => hit(g.url, g.name, `${g.count} ${t.posts.toLowerCase()}`))]);
260
+ if (d.authors.length) groups.push([t.authors, d.authors.map((u) => hit(u.url, u.name, ''))]);
261
+ box.innerHTML = groups.length
262
+ ? groups.map(([label, items]) => `<div class="pc-search-group">${esc(label)}</div>${items.join('')}`).join('')
263
+ : `<div class="pc-search-empty">${esc(t.empty)} “${esc(input.value.trim())}”</div>`;
264
+ box.hidden = false;
265
+ sel = -1;
266
+ };
267
+
268
+ input.addEventListener('input', () => {
269
+ clearTimeout(timer);
270
+ const q = input.value.trim();
271
+ if (!q) {
272
+ if (ctl) ctl.abort();
273
+ box.hidden = true;
274
+ box.innerHTML = '';
275
+ return;
276
+ }
277
+ timer = setTimeout(async () => {
278
+ if (ctl) ctl.abort();
279
+ ctl = new AbortController();
280
+ try {
281
+ const res = await fetch(`/api/search?q=${encodeURIComponent(q)}&locale=${encodeURIComponent(searchLocale)}`, { signal: ctl.signal });
282
+ if (res.ok) render(await res.json());
283
+ } catch { /* aborted or offline — the form still submits to /search */ }
284
+ }, 150);
285
+ });
286
+
287
+ const move = (dir) => {
288
+ const hits = [...box.querySelectorAll('.pc-search-hit')];
289
+ if (!hits.length) return;
290
+ sel = (sel + dir + hits.length) % hits.length;
291
+ hits.forEach((h, i) => h.classList.toggle('sel', i === sel));
292
+ hits[sel].scrollIntoView({ block: 'nearest' });
293
+ };
294
+ input.addEventListener('keydown', (e) => {
295
+ if (e.key === 'ArrowDown') { e.preventDefault(); move(1); }
296
+ else if (e.key === 'ArrowUp') { e.preventDefault(); move(-1); }
297
+ else if (e.key === 'Enter') {
298
+ const cur = box.querySelector('.pc-search-hit.sel');
299
+ if (cur) { e.preventDefault(); cur.click(); }
300
+ // no selection: the form submits to the /search page
301
+ }
302
+ });
303
+ })();
304
+ </script>
305
+ )}
194
306
  <main class="site-main">
195
307
  <slot />
196
308
  </main>
@@ -0,0 +1,22 @@
1
+ // Searchable plain text from rendered body_html: the FTS index stores this
2
+ // instead of a posts column, derived once per write. Dynamic-block
3
+ // placeholders (<div data-dyn>) carry no text, so tag-stripping drops them;
4
+ // script/style bodies are removed explicitly. Entity decoding covers what
5
+ // the editor emits — the named basics plus numeric escapes.
6
+ const codePoint = (n: number) => (n > 0 && n <= 0x10ffff ? String.fromCodePoint(n) : ' ');
7
+
8
+ export function htmlToPlaintext(html: string): string {
9
+ if (!html) return '';
10
+ return html
11
+ .replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi, ' ')
12
+ .replace(/<[^>]*>/g, ' ')
13
+ .replace(/&#x([0-9a-f]+);/gi, (_, h: string) => codePoint(parseInt(h, 16)))
14
+ .replace(/&#(\d+);/g, (_, n: string) => codePoint(Number(n)))
15
+ .replace(/&nbsp;/gi, ' ')
16
+ .replace(/&amp;/gi, '&')
17
+ .replace(/&lt;/gi, '<')
18
+ .replace(/&gt;/gi, '>')
19
+ .replace(/&quot;/gi, '"')
20
+ .replace(/\s+/g, ' ')
21
+ .trim();
22
+ }
@@ -226,6 +226,21 @@ export const MIGRATIONS: Migration[] = [
226
226
  version: 20,
227
227
  statements: [`ALTER TABLE pages ADD COLUMN locale TEXT NOT NULL DEFAULT ''`],
228
228
  },
229
+ {
230
+ // Public search: one FTS5 row per published post/page (title, excerpt,
231
+ // stripped body text, public tag names). Rows are synced by the repo on
232
+ // every write — no SQL triggers — and backfilled in code right after
233
+ // this version applies, since plaintext can't be derived in SQL.
234
+ // remove_diacritics makes "cafe" match "café" (matters for es content).
235
+ version: 21,
236
+ statements: [
237
+ `CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5(
238
+ kind UNINDEXED, ref_id UNINDEXED, locale UNINDEXED,
239
+ title, excerpt, plaintext, tags,
240
+ tokenize = 'unicode61 remove_diacritics 2'
241
+ )`,
242
+ ],
243
+ },
229
244
  ];
230
245
 
231
246
  export const LATEST_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
@@ -1,7 +1,7 @@
1
- import type { ContentRepository, Stats } from './types';
2
- import { Page, Post, Link, Media, NavItem, Redirect, Settings, Tag, User, normalizeRedirectPath, slugify, type WidgetInstance } from '../schema';
1
+ import type { ContentRepository, SearchResults, Stats } from './types';
2
+ import { Page, Post, Link, Media, NavItem, Redirect, Settings, Tag, User, isInternalTag, normalizeRedirectPath, publicTags, slugify, type WidgetInstance } from '../schema';
3
3
  import { MIGRATIONS, MIGRATIONS_TABLE } from './migrations';
4
- import { rankByQuery } from './search';
4
+ import { htmlToPlaintext } from '../plaintext';
5
5
  import { SEED } from '../brand';
6
6
  import { CONTENT, postsIndexUrl } from '../content';
7
7
 
@@ -75,6 +75,28 @@ function rowToLink(r: Row): Link {
75
75
  });
76
76
  }
77
77
 
78
+ // FTS5 MATCH expression from user input: each whitespace token becomes a
79
+ // quoted phrase (operators like AND / OR / - stay inert), the last one gets
80
+ // a * for search-as-you-type prefix matching.
81
+ function ftsQuery(query: string): string {
82
+ const tokens = query.split(/\s+/).filter(Boolean).slice(0, 8);
83
+ return tokens.map((t, i) => `"${t.replace(/"/g, '""')}"${i === tokens.length - 1 ? '*' : ''}`).join(' ');
84
+ }
85
+
86
+ // snippet() brackets matches with private-use-area chars so the raw text can
87
+ // be entity-escaped before the markers become <mark> — the result is safe to
88
+ // render with set:html / innerHTML.
89
+ const MARK_OPEN = '\uE000';
90
+ const MARK_CLOSE = '\uE001';
91
+ const SNIPPET = `snippet(search_index, 5, '${MARK_OPEN}', '${MARK_CLOSE}', '…', 12)`;
92
+ // column weights: kind, ref_id, locale (unindexed), title, excerpt, plaintext, tags
93
+ const RANK = 'bm25(search_index, 0, 0, 0, 8.0, 4.0, 1.0, 2.0)';
94
+ function safeSnippet(raw: unknown): string {
95
+ return String(raw ?? '')
96
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
97
+ .replaceAll(MARK_OPEN, '<mark>').replaceAll(MARK_CLOSE, '</mark>');
98
+ }
99
+
78
100
  export class SqlRepository implements ContentRepository {
79
101
  private driver!: SqlDriver;
80
102
  private ready: Promise<void>;
@@ -106,12 +128,16 @@ export class SqlRepository implements ContentRepository {
106
128
  applied = 1;
107
129
  }
108
130
  }
109
- for (const m of MIGRATIONS.filter((m) => m.version > applied)) {
131
+ const pending = MIGRATIONS.filter((m) => m.version > applied);
132
+ for (const m of pending) {
110
133
  await this.driver.batch([
111
134
  ...m.statements.map((sql) => ({ sql })),
112
135
  { sql: 'INSERT INTO migrations (version, applied_at) VALUES (?, ?)', args: [m.version, Date.now()] },
113
136
  ]);
114
137
  }
138
+ // v21 introduced the search index; plaintext can't be derived in SQL, so
139
+ // the backfill runs here once, right after that version lands.
140
+ if (pending.some((m) => m.version === 21)) await this.reindexSearch();
115
141
  }
116
142
 
117
143
  private async seed() {
@@ -136,6 +162,65 @@ export class SqlRepository implements ContentRepository {
136
162
  { sql: 'INSERT INTO links (id, label, url, ord) VALUES (?, ?, ?, 0)', args: [uuid(), 'GitHub', 'https://github.com'] },
137
163
  { sql: 'INSERT INTO links (id, label, url, ord) VALUES (?, ?, ?, 1)', args: [uuid(), 'Astro docs', 'https://docs.astro.build'] },
138
164
  ]);
165
+ // seed rows are inserted with raw SQL, after migrate()'s backfill ran
166
+ await this.reindexSearch();
167
+ }
168
+
169
+ // ---- search index ----
170
+ // One FTS5 row per published post/page. Sync lives here at the repo layer
171
+ // so every write path — saves, scheduled publishes, restores, seeds —
172
+ // flows through the same statements.
173
+ private postIndexStatements(r: Row): SqlStatement[] {
174
+ const stmts: SqlStatement[] = [
175
+ { sql: "DELETE FROM search_index WHERE kind = 'post' AND ref_id = ?", args: [r.id] },
176
+ ];
177
+ if (r.status === 'published') {
178
+ stmts.push({
179
+ sql: "INSERT INTO search_index (kind, ref_id, locale, title, excerpt, plaintext, tags) VALUES ('post', ?, '', ?, ?, ?, ?)",
180
+ args: [
181
+ r.id, r.title, r.excerpt ?? '',
182
+ htmlToPlaintext(String(r.body_html ?? '')) || String(r.body ?? ''),
183
+ publicTags(JSON.parse(String(r.tags ?? '[]'))).join(' '),
184
+ ],
185
+ });
186
+ }
187
+ return stmts;
188
+ }
189
+ private pageIndexStatements(r: Row): SqlStatement[] {
190
+ const stmts: SqlStatement[] = [
191
+ { sql: "DELETE FROM search_index WHERE kind = 'page' AND ref_id = ?", args: [r.id] },
192
+ ];
193
+ if (r.status === 'published') {
194
+ stmts.push({
195
+ sql: "INSERT INTO search_index (kind, ref_id, locale, title, excerpt, plaintext, tags) VALUES ('page', ?, ?, ?, ?, ?, '')",
196
+ args: [r.id, r.locale ?? '', r.title, r.seo_desc ?? '', htmlToPlaintext(String(r.body_html ?? ''))],
197
+ });
198
+ }
199
+ return stmts;
200
+ }
201
+ private async syncPostIndex(id: string) {
202
+ const r = await this.first('SELECT id, status, title, excerpt, tags, body, body_html FROM posts WHERE id = ?', [id]);
203
+ await this.driver.batch(
204
+ r ? this.postIndexStatements(r) : [{ sql: "DELETE FROM search_index WHERE kind = 'post' AND ref_id = ?", args: [id] }]
205
+ );
206
+ }
207
+ private async syncPageIndex(id: string) {
208
+ const r = await this.first('SELECT id, status, locale, title, seo_desc, body_html FROM pages WHERE id = ?', [id]);
209
+ await this.driver.batch(
210
+ r ? this.pageIndexStatements(r) : [{ sql: "DELETE FROM search_index WHERE kind = 'page' AND ref_id = ?", args: [id] }]
211
+ );
212
+ }
213
+ // full rebuild: the v21 backfill, restores, seeds
214
+ private async reindexSearch() {
215
+ const [posts, pages] = await Promise.all([
216
+ this.driver.all("SELECT id, status, title, excerpt, tags, body, body_html FROM posts WHERE status = 'published'"),
217
+ this.driver.all("SELECT id, status, locale, title, seo_desc, body_html FROM pages WHERE status = 'published'"),
218
+ ]);
219
+ await this.driver.batch([
220
+ { sql: 'DELETE FROM search_index' },
221
+ ...posts.flatMap((r) => this.postIndexStatements(r)),
222
+ ...pages.flatMap((r) => this.pageIndexStatements(r)),
223
+ ]);
139
224
  }
140
225
 
141
226
  // ---- pages ----
@@ -199,10 +284,14 @@ export class SqlRepository implements ContentRepository {
199
284
  }
200
285
  args.push(id);
201
286
  await this.driver.run(`UPDATE pages SET ${sets.join(', ')} WHERE id = ?`, args);
287
+ await this.syncPageIndex(id);
202
288
  }
203
289
  async deletePage(id: string) {
204
290
  await this.ready;
205
- await this.driver.run('DELETE FROM pages WHERE id = ?', [id]);
291
+ await this.driver.batch([
292
+ { sql: 'DELETE FROM pages WHERE id = ?', args: [id] },
293
+ { sql: "DELETE FROM search_index WHERE kind = 'page' AND ref_id = ?", args: [id] },
294
+ ]);
206
295
  }
207
296
 
208
297
  // ---- posts ----
@@ -313,27 +402,29 @@ export class SqlRepository implements ContentRepository {
313
402
  })),
314
403
  ]);
315
404
  }
405
+ await this.syncPostIndex(id);
316
406
  }
317
407
  async deletePost(id: string) {
318
408
  await this.ready;
319
409
  await this.driver.batch([
320
410
  { sql: 'DELETE FROM posts WHERE id = ?', args: [id] },
321
411
  { sql: 'DELETE FROM post_authors WHERE post_id = ?', args: [id] },
412
+ { sql: "DELETE FROM search_index WHERE kind = 'post' AND ref_id = ?", args: [id] },
322
413
  ]);
323
414
  }
324
415
  async publishDuePosts(now: number) {
325
416
  await this.ready;
326
- const due = await this.first(
327
- "SELECT COUNT(*) AS n FROM posts WHERE status = 'scheduled' AND scheduled_at IS NOT NULL AND scheduled_at <= ?",
417
+ const due = await this.driver.all(
418
+ "SELECT id FROM posts WHERE status = 'scheduled' AND scheduled_at IS NOT NULL AND scheduled_at <= ?",
328
419
  [now]
329
420
  );
330
- const n = Number(due?.n ?? 0);
331
- if (!n) return 0;
421
+ if (!due.length) return 0;
332
422
  await this.driver.run(
333
423
  "UPDATE posts SET status = 'published', published_at = COALESCE(published_at, scheduled_at), scheduled_at = NULL, updated_at = ? WHERE status = 'scheduled' AND scheduled_at IS NOT NULL AND scheduled_at <= ?",
334
424
  [now, now]
335
425
  );
336
- return n;
426
+ for (const r of due) await this.syncPostIndex(String(r.id));
427
+ return due.length;
337
428
  }
338
429
 
339
430
  // ---- media ----
@@ -556,25 +647,75 @@ export class SqlRepository implements ContentRepository {
556
647
  }
557
648
 
558
649
  // ---- search ----
559
- async searchPublished(query: string) {
560
- await this.ready;
650
+ async searchPublished(query: string, opts?: { locale?: string }): Promise<SearchResults> {
651
+ await this.ready;
652
+ const trimmed = query.trim();
653
+ if (!trimmed) return { posts: [], pages: [], tags: [], authors: [] };
654
+ const locale = opts?.locale ?? '';
655
+ let postRows: Row[];
656
+ let pageRows: Row[];
657
+ try {
658
+ [postRows, pageRows] = await Promise.all([
659
+ this.driver.all(
660
+ `SELECT p.*, ${SNIPPET} AS snip FROM search_index JOIN posts p ON p.id = search_index.ref_id
661
+ WHERE search_index MATCH ? AND search_index.kind = 'post' AND p.status = 'published'
662
+ ORDER BY ${RANK} LIMIT 50`,
663
+ [ftsQuery(trimmed)]
664
+ ),
665
+ // locale is a boost, never a filter: the visitor's language sorts
666
+ // first, every locale stays visible (en/es cover different topics)
667
+ this.driver.all(
668
+ `SELECT g.*, ${SNIPPET} AS snip FROM search_index JOIN pages g ON g.id = search_index.ref_id
669
+ WHERE search_index MATCH ? AND search_index.kind = 'page' AND g.status = 'published'
670
+ ORDER BY (search_index.locale = ?) DESC, ${RANK} LIMIT 50`,
671
+ [ftsQuery(trimmed), locale]
672
+ ),
673
+ ]);
674
+ } catch {
675
+ // FTS5 unavailable or a query form it rejects — degrade to the old
676
+ // LIKE scan (recency-ordered, snippets fall back to excerpt/seo_desc)
677
+ [postRows, pageRows] = await this.likeSearch(trimmed, locale);
678
+ }
679
+ const like = `%${trimmed.replace(/[%_]/g, ' ').trim()}%`;
680
+ const [authors, tagRows, userRows] = await Promise.all([
681
+ this.authorMap(),
682
+ this.driver.all('SELECT * FROM tags WHERE name LIKE ? OR slug LIKE ? ORDER BY name COLLATE NOCASE ASC LIMIT 20', [like, like]),
683
+ this.driver.all('SELECT * FROM users WHERE name LIKE ? OR bio LIKE ? ORDER BY name COLLATE NOCASE ASC LIMIT 20', [like, like]),
684
+ ]);
685
+ const tags = tagRows.map(rowToTag).filter((t) => !isInternalTag(t.name));
686
+ let tagCounts = new Map<string, number>();
687
+ if (tags.length) {
688
+ const published = await this.driver.all("SELECT tags FROM posts WHERE status = 'published'");
689
+ for (const r of published) {
690
+ for (const name of JSON.parse(String(r.tags ?? '[]')) as string[]) {
691
+ const key = name.toLowerCase();
692
+ tagCounts.set(key, (tagCounts.get(key) ?? 0) + 1);
693
+ }
694
+ }
695
+ }
696
+ return {
697
+ posts: postRows.map((r) => ({
698
+ doc: rowToPost(r, authors.get(String(r.id))),
699
+ snippet: safeSnippet(r.snip ?? r.excerpt ?? ''),
700
+ })),
701
+ pages: pageRows.map((r) => ({ doc: rowToPage(r), snippet: safeSnippet(r.snip ?? r.seo_desc ?? '') })),
702
+ tags: tags.map((tag) => ({ tag, count: tagCounts.get(tag.name.toLowerCase()) ?? 0 })),
703
+ authors: userRows.map(rowToUser),
704
+ };
705
+ }
706
+ private async likeSearch(query: string, locale: string): Promise<[Row[], Row[]]> {
561
707
  const q = `%${query.replace(/[%_]/g, ' ').trim()}%`;
562
- if (q === '%%') return { posts: [], pages: [] };
563
- const [posts, pages, authors] = await Promise.all([
708
+ if (q === '%%') return [[], []];
709
+ return Promise.all([
564
710
  this.driver.all(
565
711
  "SELECT * FROM posts WHERE status = 'published' AND (title LIKE ? OR excerpt LIKE ? OR body_html LIKE ? OR body LIKE ?) ORDER BY published_at DESC LIMIT 50",
566
712
  [q, q, q, q]
567
713
  ),
568
714
  this.driver.all(
569
- "SELECT * FROM pages WHERE status = 'published' AND (title LIKE ? OR body_html LIKE ?) ORDER BY updated_at DESC LIMIT 50",
570
- [q, q]
715
+ "SELECT * FROM pages WHERE status = 'published' AND (title LIKE ? OR body_html LIKE ?) ORDER BY (locale = ?) DESC, updated_at DESC LIMIT 50",
716
+ [q, q, locale]
571
717
  ),
572
- this.authorMap(),
573
718
  ]);
574
- return {
575
- posts: rankByQuery(posts.map((r) => rowToPost(r, authors.get(String(r.id)))), query),
576
- pages: pages.map(rowToPage),
577
- };
578
719
  }
579
720
 
580
721
  // ---- restore ----
@@ -584,9 +725,11 @@ export class SqlRepository implements ContentRepository {
584
725
  ...['posts', 'pages', 'media', 'links', 'tags', 'nav_items', 'users', 'post_authors', 'redirects'].map((t) => ({
585
726
  sql: `DELETE FROM ${t}`,
586
727
  })),
728
+ // p.locale defaults to '' via the zod schema, so pre-v20 backups
729
+ // restore cleanly as default-locale pages
587
730
  ...content.pages.map((p) => ({
588
- sql: 'INSERT INTO pages (id, slug, title, status, is_home, blocks, body_json, body_html, seo_title, seo_desc, template, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
589
- args: [p.id, p.slug, p.title, p.status, p.isHome ? 1 : 0, JSON.stringify(p.blocks), p.bodyJson, p.bodyHtml, p.seoTitle, p.seoDesc, p.template, p.createdAt, p.updatedAt],
731
+ sql: 'INSERT INTO pages (id, slug, title, status, is_home, locale, blocks, body_json, body_html, seo_title, seo_desc, template, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
732
+ args: [p.id, p.slug, p.title, p.status, p.isHome ? 1 : 0, p.locale, JSON.stringify(p.blocks), p.bodyJson, p.bodyHtml, p.seoTitle, p.seoDesc, p.template, p.createdAt, p.updatedAt],
590
733
  })),
591
734
  // p.collection defaults to 'posts' via the zod schema, so pre-v19
592
735
  // backups restore cleanly into the built-in collection
@@ -628,6 +771,7 @@ export class SqlRepository implements ContentRepository {
628
771
  ];
629
772
  await this.driver.batch(stmts);
630
773
  await this.updateSettings(content.settings);
774
+ await this.reindexSearch();
631
775
  }
632
776
 
633
777
  // ---- redirects ----
@@ -37,7 +37,9 @@ export class SqliteRepository extends SqlRepository {
37
37
  const { mkdirSync } = await import(/* @vite-ignore */ 'node:fs');
38
38
  mkdirSync('./data', { recursive: true });
39
39
  }
40
- return new LibsqlDriver(createClient({ url }));
40
+ // remote libSQL (Turso, sqld) needs a token; local files don't
41
+ const authToken = env('SQLITE_AUTH_TOKEN', '');
42
+ return new LibsqlDriver(createClient(authToken ? { url, authToken } : { url }));
41
43
  });
42
44
  }
43
45
  }
@@ -7,6 +7,19 @@ export interface Stats {
7
7
  linkClicks: number;
8
8
  }
9
9
 
10
+ // snippet is safe HTML: entity-escaped text with <mark> around matched terms
11
+ export interface SearchHit<T> {
12
+ doc: T;
13
+ snippet: string;
14
+ }
15
+
16
+ export interface SearchResults {
17
+ posts: SearchHit<Post>[];
18
+ pages: SearchHit<Page>[];
19
+ tags: { tag: Tag; count: number }[];
20
+ authors: User[];
21
+ }
22
+
10
23
  // The single contract both backends implement. The app only ever imports
11
24
  // this — never a concrete adapter.
12
25
  export interface ContentRepository {
@@ -101,9 +114,12 @@ export interface ContentRepository {
101
114
  reorderNavItems(ids: string[]): Promise<void>;
102
115
  deleteNavItem(id: string): Promise<void>;
103
116
 
104
- // public search — ranked LIKE over published posts and pages (title >
105
- // excerpt > body). Behind this method so FTS can replace it transparently.
106
- searchPublished(query: string): Promise<{ posts: Post[]; pages: Page[] }>;
117
+ // public search — FTS5 over published posts and pages (bm25: title >
118
+ // excerpt > tags > body) plus name matches from the tag registry (internal
119
+ // #tags excluded) and author profiles. opts.locale is a ranking boost,
120
+ // never a filter: pages in the visitor's locale sort first, every locale
121
+ // stays visible (en/es cover different topics, not translations).
122
+ searchPublished(query: string, opts?: { locale?: string }): Promise<SearchResults>;
107
123
 
108
124
  // backup restore — id-preserving replace-all from a backup's content.json.
109
125
  // Users restore WITHOUT password hashes (backups never contain them).
@@ -0,0 +1,10 @@
1
+ // Search feature toggle, configured per project via the integration:
2
+ // acms({ search: { enabled: false } })
3
+ // integration.mjs validates and fills defaults before serializing; when
4
+ // disabled it also skips injecting /search and /api/search, so this flag
5
+ // only decides whether the layout renders the trigger + modal.
6
+ import { userConfig } from 'virtual:acms-config';
7
+
8
+ export const SEARCH = {
9
+ enabled: userConfig.search?.enabled ?? true,
10
+ };
@@ -0,0 +1,45 @@
1
+ // UI strings for the search modal and /search page. There is no site-wide
2
+ // translation catalog yet — search is the first public surface with
3
+ // per-locale chrome, so the dict lives here until one exists. Locales fall
4
+ // back to English.
5
+ export interface SearchStrings {
6
+ label: string;
7
+ placeholder: string;
8
+ posts: string;
9
+ pages: string;
10
+ tags: string;
11
+ authors: string;
12
+ empty: string;
13
+ result: string;
14
+ results: string;
15
+ button: string;
16
+ }
17
+
18
+ const STRINGS: Record<string, SearchStrings> = {
19
+ en: {
20
+ label: 'Search',
21
+ placeholder: 'Search posts, pages, tags and authors',
22
+ posts: 'Posts',
23
+ pages: 'Pages',
24
+ tags: 'Tags',
25
+ authors: 'Authors',
26
+ empty: 'Nothing found for',
27
+ result: 'result',
28
+ results: 'results',
29
+ button: 'Search',
30
+ },
31
+ es: {
32
+ label: 'Buscar',
33
+ placeholder: 'Busca artículos, páginas, etiquetas y autores',
34
+ posts: 'Artículos',
35
+ pages: 'Páginas',
36
+ tags: 'Etiquetas',
37
+ authors: 'Autores',
38
+ empty: 'No hay resultados para',
39
+ result: 'resultado',
40
+ results: 'resultados',
41
+ button: 'Buscar',
42
+ },
43
+ };
44
+
45
+ export const searchStrings = (locale: string): SearchStrings => STRINGS[locale] ?? STRINGS.en;
@@ -57,18 +57,21 @@ function ago(ts: number) {
57
57
  <div id="acms-update" hidden data-current={coreVersion} class="card card-pad" style="display:none;align-items:center;gap:10px;margin-bottom:14px;border-left:3px solid var(--accent)">
58
58
  <div>
59
59
  <b>ACMS <span id="acms-update-latest"></span> is available</b>
60
- <span class="hint" style="margin-left:6px">you're on {coreVersion} — run <code>npx acms update</code>, then <code>npm run deploy</code>.</span>
60
+ <span class="hint" style="margin-left:6px">you're on {coreVersion} — run <code>npx acms update</code>; it prints the deploy steps for your setup.</span>
61
61
  </div>
62
- <a class="btn sm ghost" style="margin-left:auto" href="https://github.com/zurielbm/acms-subir/releases" target="_blank" rel="noopener">What's new</a>
62
+ <a id="acms-update-notes" class="btn sm ghost" style="margin-left:auto" href="https://www.npmjs.com/package/@acms-subir/core" target="_blank" rel="noopener">What's new</a>
63
+ <button id="acms-update-dismiss" class="btn sm ghost" type="button" aria-label="Dismiss until the next release" title="Dismiss until the next release">×</button>
63
64
  </div>
64
65
  <script>
65
66
  // Ghost-style update nudge: npm registry check, cached a day, silent on
66
- // any failure (offline, unpublished package, CORS).
67
+ // any failure (offline, unpublished package, CORS). Dismissal is stored
68
+ // per-version, so the banner returns on the next release.
67
69
  (async () => {
68
70
  const el = document.getElementById('acms-update');
69
71
  if (!el) return;
70
72
  const current = el.dataset.current ?? '';
71
73
  const KEY = 'acms-update-check';
74
+ const DISMISSED = 'acms-update-dismissed';
72
75
  let latest = '';
73
76
  try {
74
77
  const cached = JSON.parse(localStorage.getItem(KEY) ?? 'null');
@@ -86,12 +89,23 @@ function ago(ts: number) {
86
89
  for (let i = 0; i < 3; i++) { if ((pa[i] ?? 0) < (pb[i] ?? 0)) return true; if ((pa[i] ?? 0) > (pb[i] ?? 0)) return false; }
87
90
  return false;
88
91
  };
89
- if (latest && lt(current, latest)) {
90
- const slot = document.getElementById('acms-update-latest');
91
- if (slot) slot.textContent = latest;
92
- el.hidden = false;
93
- el.style.display = 'flex';
92
+ if (!latest || !lt(current, latest)) return;
93
+ try { if (localStorage.getItem(DISMISSED) === latest) return; } catch {}
94
+ const slot = document.getElementById('acms-update-latest');
95
+ if (slot) slot.textContent = latest;
96
+ // deep-link to the version's npm page — the repository is self-hosted and
97
+ // private, so npm is the only public place this release exists
98
+ const notes = document.getElementById('acms-update-notes');
99
+ if (notes instanceof HTMLAnchorElement) {
100
+ notes.href = `https://www.npmjs.com/package/@acms-subir/core/v/${encodeURIComponent(latest)}`;
94
101
  }
102
+ document.getElementById('acms-update-dismiss')?.addEventListener('click', () => {
103
+ try { localStorage.setItem(DISMISSED, latest); } catch {}
104
+ el.hidden = true;
105
+ el.style.display = 'none';
106
+ });
107
+ el.hidden = false;
108
+ el.style.display = 'flex';
95
109
  })();
96
110
  </script>
97
111