@acms-subir/core 0.0.2 → 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.
- package/CHANGELOG.md +47 -0
- package/cli.mjs +67 -9
- package/integration.mjs +85 -3
- package/package.json +5 -4
- package/src/layouts/Site.astro +112 -0
- package/src/lib/plaintext.ts +22 -0
- package/src/lib/repo/migrations.ts +15 -0
- package/src/lib/repo/sql.ts +163 -21
- package/src/lib/repo/types.ts +19 -3
- package/src/lib/search-config.ts +10 -0
- package/src/lib/search-i18n.ts +45 -0
- package/src/pages/admin/index.astro +22 -8
- package/src/pages/api/search.ts +44 -0
- package/src/pages/search.astro +83 -18
- package/src/styles/site.css +85 -0
- package/src/lib/repo/search.ts +0 -14
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# @acms-subir/core
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- d2d4761: Calendar card: embed a Google Calendar (or any ICS feed) as a native, themeable widget instead of an iframe. One card renders one view — month grid, week strip, or upcoming list — server-side at request time from the feed (5-minute cache, recurring events expanded, site timezone honored), with a small vanilla script for month/week navigation and an event-details popover. Paste an embed URL, calendar ID, or .ics link in the editor; add up to six color-coded feeds per card, plus an optional title/subtitle. Styling is driven by scoped `--cal-*` custom properties defaulting to theme tokens and stable `.pc-cal-*` class hooks, so themes and Settings → Code injection can restyle it freely.
|
|
8
|
+
- 769f9de: Code-first content config: `acms({ content })` renames the post collection per project
|
|
9
|
+
|
|
10
|
+
- `content.posts.{singular,plural,base}` changes every user-visible label
|
|
11
|
+
(site nav, admin sidebar, headings, buttons, feeds) and moves the public
|
|
12
|
+
routes off `/blog` — validated at build time, defaults unchanged.
|
|
13
|
+
- Tag archives moved to a standalone `/tag/[slug]` (configurable via
|
|
14
|
+
`content.tags.base`); `<postsBase>/tag/*` always 301s to the new location.
|
|
15
|
+
- When `base` is renamed, `/blog/*` (plus any `content.posts.redirectFrom`
|
|
16
|
+
entries) 301s to the new base — only on requests that would otherwise 404,
|
|
17
|
+
so rules never shadow real content.
|
|
18
|
+
- Internal identifiers (DB rows, backup format, `/admin/posts`) are never
|
|
19
|
+
renamed, so existing backups and sessions survive.
|
|
20
|
+
- Scaffolder: new "What do you call your posts?" prompt (`--content
|
|
21
|
+
articles|releases|notes`) writes the block into `astro.config.mjs`.
|
|
22
|
+
|
|
23
|
+
- 769f9de: Multiple content collections: `acms({ content: { collections } })`
|
|
24
|
+
|
|
25
|
+
- Each key in the `collections` map gets its own public routes at `base`,
|
|
26
|
+
admin section at `/admin/c/<key>`, sidebar entry, dashboard stat, and
|
|
27
|
+
feed/sitemap entries. `posts` is the built-in collection; `content.posts`
|
|
28
|
+
stays as shorthand for `collections.posts`. Labels default from the key.
|
|
29
|
+
- Migration v19 rebuilds the posts table: new `collection` column (existing
|
|
30
|
+
rows land in `posts`) and slug uniqueness becomes per-collection, so the
|
|
31
|
+
same slug can exist in different collections. Pre-v19 backups restore
|
|
32
|
+
cleanly.
|
|
33
|
+
- One shared tag registry; tag archives group entries by collection when
|
|
34
|
+
more than one is configured. `/rss.xml` stays the everything-feed with a
|
|
35
|
+
collection `<category>` per item.
|
|
36
|
+
- The editor gains a collection switcher (multi-collection sites only);
|
|
37
|
+
moving a published entry between collections leaves a 301. Post-list cards
|
|
38
|
+
can target a collection.
|
|
39
|
+
- `/admin/posts` and `/admin/posts/[id]` 308 to their `/admin/c/posts`
|
|
40
|
+
equivalents. Removing a collection key hides its routes but never deletes
|
|
41
|
+
rows — the dashboard shows a restore notice.
|
|
42
|
+
|
|
43
|
+
- 8e1f688: The update story: `acms update` now prints the release notes it crossed and deploy instructions matched to your target — `npm run deploy` for Cloudflare, `docker compose up -d --build` for Docker — plus a `--check` flag that exits 1 when behind, for CI or cron. Notes come from the `CHANGELOG.md` that now ships inside the package, so nothing depends on a git host or an API token. The admin dashboard shows a dismissible banner when a new release is out (checked at most daily, straight from the npm registry, fail-open), and `astro dev` logs a once-a-day nudge (opt out with `ACMS_NO_UPDATE_CHECK=1`). The scaffolder no longer freezes silently during `npm install` — the spinner animates with elapsed time — and its Next-steps box now mentions `npx acms update`.
|
|
44
|
+
|
|
45
|
+
### Patch Changes
|
|
46
|
+
|
|
47
|
+
- 19bf3c7: Enforce the documented "project-local routes win" rule at injection time. Astro registers injected and file-based routes at equal priority and serves the injected one on a collision (with a deprecation warning that this becomes a hard error), so the integration now skips injecting any route whose pattern the consumer already defines in `src/pages/` — checked as `<pattern>.astro`, `<pattern>.ts`, `<pattern>/index.astro`, or `<pattern>/index.ts`. Shell overrides of `/`, collection indexes, feeds, and any other public route now actually take effect.
|
package/cli.mjs
CHANGED
|
@@ -1,26 +1,32 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// `acms` — tiny maintenance CLI installed with @acms-subir/core.
|
|
3
3
|
// acms update check the registry, install the latest core, print what's new
|
|
4
|
+
// acms update --check just report whether an update exists (exit 1 if behind)
|
|
4
5
|
// acms doctor sanity-check the project setup
|
|
5
6
|
// acms add cloudflare retrofit Workers deployment (wrangler.jsonc, deps, D1/R2)
|
|
6
7
|
// acms add docker retrofit self-hosting (Dockerfile, compose.yml)
|
|
7
8
|
import { spawnSync } from 'node:child_process';
|
|
8
9
|
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
9
10
|
import { createRequire } from 'node:module';
|
|
10
|
-
import { join } from 'node:path';
|
|
11
|
+
import { dirname, join } from 'node:path';
|
|
11
12
|
|
|
12
13
|
const PKG = '@acms-subir/core';
|
|
13
14
|
const cmd = process.argv[2];
|
|
14
15
|
|
|
15
16
|
const read = (p) => { try { return readFileSync(p, 'utf8'); } catch { return ''; } };
|
|
16
|
-
const
|
|
17
|
+
const installedPkgJson = () => {
|
|
17
18
|
try {
|
|
18
|
-
|
|
19
|
-
return JSON.parse(readFileSync(require.resolve(`${PKG}/package.json`), 'utf8')).version;
|
|
19
|
+
return createRequire(join(process.cwd(), 'package.json')).resolve(`${PKG}/package.json`);
|
|
20
20
|
} catch { return null; }
|
|
21
21
|
};
|
|
22
|
+
const installedVersion = () => {
|
|
23
|
+
const p = installedPkgJson();
|
|
24
|
+
try { return p ? JSON.parse(readFileSync(p, 'utf8')).version : null; } catch { return null; }
|
|
25
|
+
};
|
|
22
26
|
const latestVersion = async () => {
|
|
23
|
-
const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(PKG)}/latest
|
|
27
|
+
const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(PKG)}/latest`, {
|
|
28
|
+
signal: AbortSignal.timeout(10_000),
|
|
29
|
+
});
|
|
24
30
|
if (!res.ok) throw new Error(`registry responded ${res.status}`);
|
|
25
31
|
return (await res.json()).version;
|
|
26
32
|
};
|
|
@@ -30,7 +36,33 @@ const semverLt = (a, b) => {
|
|
|
30
36
|
return false;
|
|
31
37
|
};
|
|
32
38
|
|
|
39
|
+
// Release notes come from the CHANGELOG that changesets writes into the
|
|
40
|
+
// package itself, read after the install from the freshly unpacked copy. No
|
|
41
|
+
// git-host API is involved on purpose: the repository lives on a private
|
|
42
|
+
// OneDev instance, so anything API-based would need a token on every site
|
|
43
|
+
// that runs this command.
|
|
44
|
+
const changelogFor = (pkgJsonPath) => (pkgJsonPath ? read(join(dirname(pkgJsonPath), 'CHANGELOG.md')) : '');
|
|
45
|
+
// Entries newer than `current` up to `latest`, oldest first. Changesets writes
|
|
46
|
+
// one "## X.Y.Z" heading per release.
|
|
47
|
+
const releaseNotes = (md, current, latest) => {
|
|
48
|
+
// split on every "## " heading, not just the version ones, so a hand-written
|
|
49
|
+
// "## Unreleased" section cannot get swallowed into the entry above it
|
|
50
|
+
const heads = [...md.matchAll(/^## +(.*)$/gm)];
|
|
51
|
+
return heads
|
|
52
|
+
.map((h, i) => ({
|
|
53
|
+
version: h[1].trim().match(/^\d+\.\d+\.\d+$/)?.[0],
|
|
54
|
+
body: md.slice(h.index + h[0].length, heads[i + 1]?.index ?? md.length).trim(),
|
|
55
|
+
}))
|
|
56
|
+
.filter((r) => r.version && semverLt(current, r.version) && !semverLt(latest, r.version))
|
|
57
|
+
.sort((a, b) => (semverLt(a.version, b.version) ? -1 : 1));
|
|
58
|
+
};
|
|
59
|
+
const deployTargets = () => ({
|
|
60
|
+
cloudflare: ['wrangler.jsonc', 'wrangler.json', 'wrangler.toml'].some((f) => existsSync(f)),
|
|
61
|
+
docker: ['compose.yml', 'compose.yaml', 'docker-compose.yml', 'Dockerfile'].some((f) => existsSync(f)),
|
|
62
|
+
});
|
|
63
|
+
|
|
33
64
|
if (cmd === 'update') {
|
|
65
|
+
const checkOnly = process.argv.includes('--check');
|
|
34
66
|
const current = installedVersion();
|
|
35
67
|
if (!current) { console.error(`✗ ${PKG} is not installed here — run this from your site's folder.`); process.exit(1); }
|
|
36
68
|
let latest;
|
|
@@ -39,13 +71,38 @@ if (cmd === 'update') {
|
|
|
39
71
|
console.log(`✓ Already up to date (${current}).`);
|
|
40
72
|
process.exit(0);
|
|
41
73
|
}
|
|
74
|
+
if (checkOnly) {
|
|
75
|
+
console.log(`Update available: ${current} → ${latest}. Run: npx acms update`);
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
42
78
|
console.log(`Updating ${PKG} ${current} → ${latest}…`);
|
|
43
79
|
const res = spawnSync('npm', ['install', `${PKG}@latest`, '--no-audit', '--no-fund'], { stdio: 'inherit' });
|
|
44
80
|
if (res.status !== 0) process.exit(res.status ?? 1);
|
|
45
|
-
console.log(
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
81
|
+
console.log(`\n✓ Updated to ${latest}.`);
|
|
82
|
+
const notes = releaseNotes(changelogFor(installedPkgJson()), current, latest);
|
|
83
|
+
if (notes.length) {
|
|
84
|
+
console.log(`\nWhat's new:`);
|
|
85
|
+
for (const r of notes) {
|
|
86
|
+
console.log(`\n ${PKG} ${r.version}`);
|
|
87
|
+
if (r.body) console.log(r.body.split('\n').map((l) => ` ${l}`).join('\n'));
|
|
88
|
+
}
|
|
89
|
+
} else {
|
|
90
|
+
console.log(`\nWhat's new: https://www.npmjs.com/package/${PKG}/v/${latest}`);
|
|
91
|
+
}
|
|
92
|
+
const t = deployTargets();
|
|
93
|
+
const steps = [
|
|
94
|
+
...(t.cloudflare ? [' npm run deploy # Cloudflare Workers'] : []),
|
|
95
|
+
...(t.docker ? [' git push, then on your server: docker compose up -d --build'] : []),
|
|
96
|
+
];
|
|
97
|
+
console.log([
|
|
98
|
+
'',
|
|
99
|
+
'Deploy when ready:',
|
|
100
|
+
...(steps.length ? steps : [' rebuild and redeploy your host']),
|
|
101
|
+
' (the database migrates itself on the first request — no manual steps)',
|
|
102
|
+
].join('\n'));
|
|
103
|
+
// the registry fetch leaves a keep-alive socket in undici's pool, which can
|
|
104
|
+
// hold the event loop open for seconds after there is nothing left to do
|
|
105
|
+
process.exit(0);
|
|
49
106
|
} else if (cmd === 'add') {
|
|
50
107
|
// Retrofit a deployment target onto an existing project. Both targets share
|
|
51
108
|
// one codebase — astro.config picks the adapter via DEPLOY_TARGET — so
|
|
@@ -251,6 +308,7 @@ data
|
|
|
251
308
|
|
|
252
309
|
Usage:
|
|
253
310
|
acms update Update the CMS core to the latest release
|
|
311
|
+
acms update --check Report whether an update exists (exit 1 if behind)
|
|
254
312
|
acms doctor Check this project's setup
|
|
255
313
|
acms add cloudflare Add Workers deployment to this project (--provision creates D1 + R2)
|
|
256
314
|
acms add docker Add Docker self-hosting to this project`);
|
package/integration.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { readFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
2
3
|
import { fileURLToPath } from 'node:url';
|
|
3
4
|
|
|
4
5
|
// This file is loaded by Node while Astro evaluates the consumer's
|
|
@@ -27,7 +28,6 @@ const ROUTES = [
|
|
|
27
28
|
['/[...slug]', '[...slug].astro'],
|
|
28
29
|
['/author/[slug]', 'author/[slug].astro'],
|
|
29
30
|
['/links', 'links.astro'],
|
|
30
|
-
['/search', 'search.astro'],
|
|
31
31
|
['/go/[id]', 'go/[id].ts'],
|
|
32
32
|
['/media/[...key]', 'media/[...key].ts'],
|
|
33
33
|
['/embed/[pageId]/[blockId]', 'embed/[pageId]/[blockId].ts'],
|
|
@@ -180,6 +180,55 @@ function resolveI18n(i18n, contentBases) {
|
|
|
180
180
|
return { locales, defaultLocale };
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
+
// Defaults + validation for acms({ search }). Search ships on; disabling it
|
|
184
|
+
// removes the /search page(s) and /api/search from the route table, and the
|
|
185
|
+
// layout skips the header trigger + modal (lib/search-config.ts).
|
|
186
|
+
function resolveSearch(search) {
|
|
187
|
+
if (search === undefined) return { enabled: true };
|
|
188
|
+
const unknown = Object.keys(search).filter((k) => k !== 'enabled');
|
|
189
|
+
if (unknown.length > 0) {
|
|
190
|
+
throw new Error(
|
|
191
|
+
`acms: unknown search key${unknown.length > 1 ? 's' : ''} ${unknown.map((k) => `"${k}"`).join(', ')} — ` +
|
|
192
|
+
'the only valid key is search.enabled'
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
const enabled = search.enabled ?? true;
|
|
196
|
+
if (typeof enabled !== 'boolean') {
|
|
197
|
+
throw new Error('acms: search.enabled must be true or false');
|
|
198
|
+
}
|
|
199
|
+
return { enabled };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const semverLt = (a, b) => {
|
|
203
|
+
const pa = a.split('.').map(Number), pb = b.split('.').map(Number);
|
|
204
|
+
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; }
|
|
205
|
+
return false;
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
// Dev-only update nudge: one log line when a newer core is on npm, checked at
|
|
209
|
+
// most once per 24h (stamp file in Astro's cache dir). Fire-and-forget — it
|
|
210
|
+
// never delays startup and swallows every failure. ACMS_NO_UPDATE_CHECK=1
|
|
211
|
+
// disables it entirely.
|
|
212
|
+
async function devUpdateNudge(cacheDir, logger) {
|
|
213
|
+
const stampFile = join(cacheDir, 'acms-update-check.json');
|
|
214
|
+
try {
|
|
215
|
+
const stamp = JSON.parse(readFileSync(stampFile, 'utf8'));
|
|
216
|
+
if (Date.now() - stamp.at < 86_400_000) return;
|
|
217
|
+
} catch {}
|
|
218
|
+
try {
|
|
219
|
+
const res = await fetch('https://registry.npmjs.org/@acms-subir%2Fcore/latest', {
|
|
220
|
+
signal: AbortSignal.timeout(3000),
|
|
221
|
+
});
|
|
222
|
+
if (!res.ok) return;
|
|
223
|
+
const latest = (await res.json()).version ?? '';
|
|
224
|
+
mkdirSync(dirname(stampFile), { recursive: true });
|
|
225
|
+
writeFileSync(stampFile, JSON.stringify({ at: Date.now(), latest }));
|
|
226
|
+
if (latest && semverLt(CORE_VERSION, latest)) {
|
|
227
|
+
logger.info(`v${latest} is available (you're on ${CORE_VERSION}) — npx acms update`);
|
|
228
|
+
}
|
|
229
|
+
} catch {}
|
|
230
|
+
}
|
|
231
|
+
|
|
183
232
|
const CONFIG_ID = 'virtual:acms-config';
|
|
184
233
|
const THEMES_ID = 'virtual:acms-user-themes';
|
|
185
234
|
|
|
@@ -231,6 +280,10 @@ function acmsVitePlugin(userConfig) {
|
|
|
231
280
|
* Home page content per language is authored in /admin as a normal page
|
|
232
281
|
* marked "home" with that language selected. Omit for monolingual sites —
|
|
233
282
|
* nothing changes.
|
|
283
|
+
* @param {{enabled?: boolean}} [options.search]
|
|
284
|
+
* Public site search: the /search page, the /api/search JSON endpoint, and
|
|
285
|
+
* the header search modal. On by default — set `enabled: false` to remove
|
|
286
|
+
* all three.
|
|
234
287
|
* @returns {import('astro').AstroIntegration}
|
|
235
288
|
*/
|
|
236
289
|
export default function acms(options = {}) {
|
|
@@ -240,12 +293,14 @@ export default function acms(options = {}) {
|
|
|
240
293
|
[content.tags.base, 'content.tags.base'],
|
|
241
294
|
]);
|
|
242
295
|
const i18n = resolveI18n(options.i18n, contentBases);
|
|
296
|
+
const search = resolveSearch(options.search);
|
|
243
297
|
const userConfig = {
|
|
244
298
|
coreVersion: CORE_VERSION,
|
|
245
299
|
brand: options.brand ?? {},
|
|
246
300
|
seed: options.seed ?? {},
|
|
247
301
|
content,
|
|
248
302
|
i18n,
|
|
303
|
+
search,
|
|
249
304
|
};
|
|
250
305
|
const routes = [...ROUTES];
|
|
251
306
|
for (const c of Object.values(content.collections)) {
|
|
@@ -257,13 +312,35 @@ export default function acms(options = {}) {
|
|
|
257
312
|
for (const l of i18n.locales) {
|
|
258
313
|
if (l !== i18n.defaultLocale) routes.push([`/${l}`, 'index.astro']);
|
|
259
314
|
}
|
|
315
|
+
if (search.enabled) {
|
|
316
|
+
routes.push(['/search', 'search.astro'], ['/api/search', 'api/search.ts']);
|
|
317
|
+
// a locale-prefixed search page per non-default locale, mirroring the
|
|
318
|
+
// per-locale home routes; search.astro derives its locale from the path
|
|
319
|
+
for (const l of i18n.locales) {
|
|
320
|
+
if (l !== i18n.defaultLocale) routes.push([`/${l}/search`, 'search.astro']);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
let cacheDir = '';
|
|
260
324
|
return {
|
|
261
325
|
name: 'acms',
|
|
262
326
|
hooks: {
|
|
263
|
-
'astro:config:setup': ({ command, injectRoute, addMiddleware, updateConfig }) => {
|
|
327
|
+
'astro:config:setup': ({ config, command, injectRoute, addMiddleware, updateConfig }) => {
|
|
328
|
+
if (config.cacheDir) cacheDir = fileURLToPath(config.cacheDir);
|
|
264
329
|
updateConfig({ vite: { plugins: [acmsVitePlugin(userConfig)] } });
|
|
265
330
|
|
|
331
|
+
// "Project-local routes win" is enforced here: Astro registers
|
|
332
|
+
// injected and file-based routes at equal priority (and the injected
|
|
333
|
+
// one is served on a collision), so a route is only injected when the
|
|
334
|
+
// consumer has no file at the same pattern in src/pages.
|
|
335
|
+
const pagesDir = fileURLToPath(new URL('./pages/', config.srcDir));
|
|
336
|
+
const overridden = (pattern) => {
|
|
337
|
+
const rel = pattern === '/' ? 'index' : pattern.slice(1);
|
|
338
|
+
return ['.astro', '.ts', '/index.astro', '/index.ts'].some((suffix) =>
|
|
339
|
+
existsSync(`${pagesDir}${rel}${suffix}`)
|
|
340
|
+
);
|
|
341
|
+
};
|
|
266
342
|
for (const [pattern, file] of routes) {
|
|
343
|
+
if (overridden(pattern)) continue;
|
|
267
344
|
injectRoute({ pattern, entrypoint: entry(`./src/pages/${file}`) });
|
|
268
345
|
}
|
|
269
346
|
// Auth gate, trailing-slash canonicalization, redirect fallback,
|
|
@@ -281,6 +358,11 @@ export default function acms(options = {}) {
|
|
|
281
358
|
});
|
|
282
359
|
}
|
|
283
360
|
},
|
|
361
|
+
// fires only for `astro dev` — builds and previews never phone anywhere
|
|
362
|
+
'astro:server:start': ({ logger }) => {
|
|
363
|
+
if (!cacheDir || process.env.ACMS_NO_UPDATE_CHECK) return;
|
|
364
|
+
void devUpdateNudge(cacheDir, logger);
|
|
365
|
+
},
|
|
284
366
|
},
|
|
285
367
|
};
|
|
286
368
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@acms-subir/core",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"homepage": "https://acms.subir.dev",
|
|
11
11
|
"repository": {
|
|
12
12
|
"type": "git",
|
|
13
|
-
"url": "git+https://
|
|
13
|
+
"url": "git+https://onedev.subir.dev/acms-subir.git",
|
|
14
14
|
"directory": "packages/core"
|
|
15
15
|
},
|
|
16
16
|
"keywords": [
|
|
@@ -40,7 +40,8 @@
|
|
|
40
40
|
"files": [
|
|
41
41
|
"integration.mjs",
|
|
42
42
|
"cli.mjs",
|
|
43
|
-
"src"
|
|
43
|
+
"src",
|
|
44
|
+
"CHANGELOG.md"
|
|
44
45
|
],
|
|
45
46
|
"dependencies": {
|
|
46
47
|
"@dnd-kit/core": "^6.3.1",
|
|
@@ -64,7 +65,7 @@
|
|
|
64
65
|
"peerDependencies": {
|
|
65
66
|
"@astrojs/cloudflare": "^12.6.13",
|
|
66
67
|
"@astrojs/node": "^9.2.0",
|
|
67
|
-
"@astrojs/react": "^
|
|
68
|
+
"@astrojs/react": "^4.4.2",
|
|
68
69
|
"astro": "^5.7.0",
|
|
69
70
|
"react": "^19.0.0",
|
|
70
71
|
"react-dom": "^19.0.0"
|
package/src/layouts/Site.astro
CHANGED
|
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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(/ /gi, ' ')
|
|
16
|
+
.replace(/&/gi, '&')
|
|
17
|
+
.replace(/</gi, '<')
|
|
18
|
+
.replace(/>/gi, '>')
|
|
19
|
+
.replace(/"/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;
|
package/src/lib/repo/sql.ts
CHANGED
|
@@ -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 {
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
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
|
-
|
|
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.
|
|
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.
|
|
327
|
-
"SELECT
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
563
|
-
|
|
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 ----
|
|
@@ -630,6 +771,7 @@ export class SqlRepository implements ContentRepository {
|
|
|
630
771
|
];
|
|
631
772
|
await this.driver.batch(stmts);
|
|
632
773
|
await this.updateSettings(content.settings);
|
|
774
|
+
await this.reindexSearch();
|
|
633
775
|
}
|
|
634
776
|
|
|
635
777
|
// ---- redirects ----
|
package/src/lib/repo/types.ts
CHANGED
|
@@ -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 —
|
|
105
|
-
// excerpt > body)
|
|
106
|
-
|
|
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
|
|
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://
|
|
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
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro';
|
|
2
|
+
import { getRepo } from '../../lib/repo';
|
|
3
|
+
import { authorSlug, publicTags } from '../../lib/schema';
|
|
4
|
+
import { collectionUrl, tagUrl } from '../../lib/content';
|
|
5
|
+
import { localeHomeUrl, normalizePageLocale } from '../../lib/i18n';
|
|
6
|
+
import { formatDate } from '../../lib/dates';
|
|
7
|
+
|
|
8
|
+
// Public search API behind the site's search modal. Published-only by
|
|
9
|
+
// construction — the same contract as rss/sitemap — so it needs no auth and
|
|
10
|
+
// the middleware already leaves /api alone. `locale` boosts the visitor's
|
|
11
|
+
// language to the top of page results; it never filters. Snippets are safe
|
|
12
|
+
// HTML (entity-escaped, <mark> around matches) ready for innerHTML.
|
|
13
|
+
|
|
14
|
+
const LIMITS = { posts: 10, pages: 5, tags: 5, authors: 5 };
|
|
15
|
+
|
|
16
|
+
export const GET: APIRoute = async ({ locals, url }) => {
|
|
17
|
+
const headers = { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'public, max-age=60' };
|
|
18
|
+
const q = (url.searchParams.get('q') ?? '').trim().slice(0, 100);
|
|
19
|
+
if (!q) return new Response(JSON.stringify({ posts: [], pages: [], tags: [], authors: [] }), { headers });
|
|
20
|
+
|
|
21
|
+
const repo = await getRepo(locals);
|
|
22
|
+
const locale = normalizePageLocale(url.searchParams.get('locale') ?? '');
|
|
23
|
+
const [results, settings] = await Promise.all([repo.searchPublished(q, { locale }), repo.getSettings()]);
|
|
24
|
+
|
|
25
|
+
const body = {
|
|
26
|
+
posts: results.posts.slice(0, LIMITS.posts).map(({ doc: p, snippet }) => ({
|
|
27
|
+
title: p.title,
|
|
28
|
+
url: collectionUrl(p.collection, p.slug),
|
|
29
|
+
excerpt: p.excerpt,
|
|
30
|
+
snippet,
|
|
31
|
+
date: p.publishedAt ? formatDate(p.publishedAt, settings) : '',
|
|
32
|
+
tags: publicTags(p.tags),
|
|
33
|
+
})),
|
|
34
|
+
pages: results.pages.slice(0, LIMITS.pages).map(({ doc: p, snippet }) => ({
|
|
35
|
+
title: p.title,
|
|
36
|
+
url: p.isHome ? localeHomeUrl(p.locale) : `/${p.slug}`,
|
|
37
|
+
snippet,
|
|
38
|
+
locale: p.locale,
|
|
39
|
+
})),
|
|
40
|
+
tags: results.tags.slice(0, LIMITS.tags).map(({ tag, count }) => ({ name: tag.name, url: tagUrl(tag.slug), count })),
|
|
41
|
+
authors: results.authors.slice(0, LIMITS.authors).map((u) => ({ name: u.name, url: `/author/${authorSlug(u)}`, avatar: u.avatar })),
|
|
42
|
+
};
|
|
43
|
+
return new Response(JSON.stringify(body), { headers });
|
|
44
|
+
};
|
package/src/pages/search.astro
CHANGED
|
@@ -1,41 +1,54 @@
|
|
|
1
1
|
---
|
|
2
2
|
import Site from '../layouts/Site.astro';
|
|
3
3
|
import { getRepo } from '../lib/repo';
|
|
4
|
-
import { formatDate } from '../lib/dates';
|
|
5
|
-
import {
|
|
4
|
+
import { formatDate, siteLang } from '../lib/dates';
|
|
5
|
+
import { collectionUrl, tagUrl } from '../lib/content';
|
|
6
|
+
import { authorSlug } from '../lib/schema';
|
|
7
|
+
import { I18N, localeFromPath, localeHomeUrl, normalizePageLocale } from '../lib/i18n';
|
|
8
|
+
import { searchStrings } from '../lib/search-i18n';
|
|
9
|
+
|
|
10
|
+
// The no-JS search surface (the modal's form falls back here). Serves both
|
|
11
|
+
// /search and the locale-prefixed /<locale>/search routes — the locale comes
|
|
12
|
+
// from the path, same trick as the per-locale home routes, and only boosts
|
|
13
|
+
// page ranking; every locale's content stays visible.
|
|
6
14
|
|
|
7
15
|
const repo = await getRepo(Astro.locals);
|
|
8
16
|
const q = (Astro.url.searchParams.get('q') ?? '').trim();
|
|
9
|
-
const results = q ? await repo.searchPublished(q) : { posts: [], pages: [] };
|
|
10
17
|
const settings = await repo.getSettings();
|
|
11
|
-
const
|
|
18
|
+
const reqLocale = I18N.enabled ? localeFromPath(Astro.url.pathname) : '';
|
|
19
|
+
const locale = normalizePageLocale(reqLocale);
|
|
20
|
+
const t = searchStrings((reqLocale || siteLang(settings)).slice(0, 2));
|
|
21
|
+
const results = q
|
|
22
|
+
? await repo.searchPublished(q, { locale })
|
|
23
|
+
: { posts: [], pages: [], tags: [], authors: [] };
|
|
24
|
+
const total = results.posts.length + results.pages.length + results.tags.length + results.authors.length;
|
|
12
25
|
---
|
|
13
26
|
|
|
14
|
-
<Site title={q ?
|
|
27
|
+
<Site title={q ? `${t.label}: ${q}` : t.label} description={`${t.label} · ${settings.siteTitle}`} noindex={Boolean(q)}>
|
|
15
28
|
<header class="search-hero">
|
|
16
|
-
<h1>
|
|
17
|
-
<form class="search-form" action=
|
|
29
|
+
<h1>{t.label}</h1>
|
|
30
|
+
<form class="search-form" action={Astro.url.pathname} method="get" role="search">
|
|
18
31
|
<input
|
|
19
32
|
type="search"
|
|
20
33
|
name="q"
|
|
21
34
|
value={q}
|
|
22
|
-
placeholder={
|
|
23
|
-
aria-label=
|
|
35
|
+
placeholder={t.placeholder}
|
|
36
|
+
aria-label={t.label}
|
|
24
37
|
autofocus={!q}
|
|
25
38
|
/>
|
|
26
|
-
<button type="submit">
|
|
39
|
+
<button type="submit">{t.button}</button>
|
|
27
40
|
</form>
|
|
28
41
|
</header>
|
|
29
42
|
|
|
30
43
|
{q && (
|
|
31
44
|
<p class="search-count">
|
|
32
|
-
{total === 0 ?
|
|
45
|
+
{total === 0 ? `${t.empty} “${q}”.` : `${total} ${total === 1 ? t.result : t.results} · “${q}”`}
|
|
33
46
|
</p>
|
|
34
47
|
)}
|
|
35
48
|
|
|
36
49
|
{results.posts.length > 0 && (
|
|
37
50
|
<ul class="post-list">
|
|
38
|
-
{results.posts.map((p) => (
|
|
51
|
+
{results.posts.map(({ doc: p, snippet }) => (
|
|
39
52
|
<li class="post-item">
|
|
40
53
|
{p.cover && (
|
|
41
54
|
<a class="post-thumb" href={collectionUrl(p.collection, p.slug)}><img src={p.cover} alt={p.coverAlt} loading="lazy" /></a>
|
|
@@ -45,7 +58,11 @@ const total = results.posts.length + results.pages.length;
|
|
|
45
58
|
<div class="post-meta">
|
|
46
59
|
{p.publishedAt && <time datetime={new Date(p.publishedAt).toISOString()}>{formatDate(p.publishedAt, settings)}</time>}
|
|
47
60
|
</div>
|
|
48
|
-
{
|
|
61
|
+
{snippet ? (
|
|
62
|
+
<p class="post-excerpt" set:html={snippet} />
|
|
63
|
+
) : (
|
|
64
|
+
p.excerpt && <p class="post-excerpt">{p.excerpt}</p>
|
|
65
|
+
)}
|
|
49
66
|
</div>
|
|
50
67
|
</li>
|
|
51
68
|
))}
|
|
@@ -53,12 +70,46 @@ const total = results.posts.length + results.pages.length;
|
|
|
53
70
|
)}
|
|
54
71
|
|
|
55
72
|
{results.pages.length > 0 && (
|
|
56
|
-
<section class="search-
|
|
57
|
-
<h2>
|
|
73
|
+
<section class="search-section">
|
|
74
|
+
<h2>{t.pages}</h2>
|
|
58
75
|
<ul class="post-list">
|
|
59
|
-
{results.pages.map((p) => (
|
|
76
|
+
{results.pages.map(({ doc: p, snippet }) => (
|
|
60
77
|
<li class="post-item">
|
|
61
|
-
<div
|
|
78
|
+
<div>
|
|
79
|
+
<a href={p.isHome ? localeHomeUrl(p.locale) : `/${p.slug}`}>{p.title}</a>
|
|
80
|
+
{I18N.enabled && p.locale !== locale && (
|
|
81
|
+
<span class="search-loc">{(p.locale || I18N.defaultLocale).toUpperCase()}</span>
|
|
82
|
+
)}
|
|
83
|
+
{snippet && <p class="post-excerpt" set:html={snippet} />}
|
|
84
|
+
</div>
|
|
85
|
+
</li>
|
|
86
|
+
))}
|
|
87
|
+
</ul>
|
|
88
|
+
</section>
|
|
89
|
+
)}
|
|
90
|
+
|
|
91
|
+
{results.tags.length > 0 && (
|
|
92
|
+
<section class="search-section">
|
|
93
|
+
<h2>{t.tags}</h2>
|
|
94
|
+
<div class="search-tags">
|
|
95
|
+
{results.tags.map(({ tag, count }) => (
|
|
96
|
+
<a class="tag" href={tagUrl(tag.slug)}>{tag.name} <span class="search-tag-count">{count}</span></a>
|
|
97
|
+
))}
|
|
98
|
+
</div>
|
|
99
|
+
</section>
|
|
100
|
+
)}
|
|
101
|
+
|
|
102
|
+
{results.authors.length > 0 && (
|
|
103
|
+
<section class="search-section">
|
|
104
|
+
<h2>{t.authors}</h2>
|
|
105
|
+
<ul class="post-list">
|
|
106
|
+
{results.authors.map((u) => (
|
|
107
|
+
<li class="post-item">
|
|
108
|
+
{u.avatar && <img class="search-avatar" src={u.avatar} alt="" loading="lazy" />}
|
|
109
|
+
<div>
|
|
110
|
+
<a href={`/author/${authorSlug(u)}`}>{u.name}</a>
|
|
111
|
+
{u.bio && <p class="post-excerpt">{u.bio}</p>}
|
|
112
|
+
</div>
|
|
62
113
|
</li>
|
|
63
114
|
))}
|
|
64
115
|
</ul>
|
|
@@ -79,5 +130,19 @@ const total = results.posts.length + results.pages.length;
|
|
|
79
130
|
background: var(--accent); color: #fff; cursor: pointer;
|
|
80
131
|
}
|
|
81
132
|
.search-count { color: var(--muted); }
|
|
82
|
-
.search-
|
|
133
|
+
.search-section h2 { font-size: 1.1rem; margin-top: 2rem; }
|
|
134
|
+
/* snippets arrive pre-escaped with <mark> around matches (set:html), so
|
|
135
|
+
the highlight style must reach into the injected fragment */
|
|
136
|
+
.post-excerpt :global(mark) {
|
|
137
|
+
background: color-mix(in srgb, var(--accent) 22%, transparent);
|
|
138
|
+
color: inherit; border-radius: 3px; padding: 0 1px;
|
|
139
|
+
}
|
|
140
|
+
.search-loc {
|
|
141
|
+
font-size: 11px; font-weight: 700; letter-spacing: 0.06em; color: var(--muted);
|
|
142
|
+
border: 1px solid var(--line); border-radius: 5px; padding: 1px 5px; margin-left: 8px;
|
|
143
|
+
vertical-align: 2px;
|
|
144
|
+
}
|
|
145
|
+
.search-tags { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
|
|
146
|
+
.search-tag-count { color: var(--muted); }
|
|
147
|
+
.search-avatar { width: 44px; height: 44px; border-radius: 50%; object-fit: cover; flex: none; }
|
|
83
148
|
</style>
|
package/src/styles/site.css
CHANGED
|
@@ -425,3 +425,88 @@ a.tag:hover { background: var(--accent); color: #fff; }
|
|
|
425
425
|
.pc-cal-row .d { flex-basis: 84px; }
|
|
426
426
|
}
|
|
427
427
|
}
|
|
428
|
+
|
|
429
|
+
/* Site search (.pc-search) — the header trigger + overlay modal rendered by
|
|
430
|
+
Site.astro. The server renders the dialog around a real GET form to the
|
|
431
|
+
/search page, so search works with JS disabled; the inline script upgrades
|
|
432
|
+
it to live /api/search results. Scoped custom properties default to global
|
|
433
|
+
tokens — theme CSS and Settings → Code injection are unlayered, so either
|
|
434
|
+
overrides any of this without !important. */
|
|
435
|
+
@layer base {
|
|
436
|
+
.site-nav a.pc-search-trigger {
|
|
437
|
+
display: inline-flex; align-items: center; align-self: center;
|
|
438
|
+
color: var(--muted); padding: 2px;
|
|
439
|
+
}
|
|
440
|
+
.site-nav a.pc-search-trigger:hover { color: var(--ink); }
|
|
441
|
+
.pc-search-trigger svg { display: block; }
|
|
442
|
+
|
|
443
|
+
dialog.pc-search {
|
|
444
|
+
--search-bg: var(--panel);
|
|
445
|
+
--search-ink: var(--ink);
|
|
446
|
+
--search-muted: var(--muted);
|
|
447
|
+
--search-line: var(--line);
|
|
448
|
+
--search-accent: var(--accent);
|
|
449
|
+
--search-radius: 12px;
|
|
450
|
+
--search-veil: rgb(0 0 0 / 0.35);
|
|
451
|
+
--search-width: 640px;
|
|
452
|
+
--search-top: 9vh;
|
|
453
|
+
width: min(var(--search-width), 92vw);
|
|
454
|
+
margin: var(--search-top) auto auto;
|
|
455
|
+
border: 0; padding: 0; background: transparent; overflow: visible;
|
|
456
|
+
}
|
|
457
|
+
dialog.pc-search::backdrop { background: var(--search-veil); backdrop-filter: blur(3px); }
|
|
458
|
+
|
|
459
|
+
.pc-search-box {
|
|
460
|
+
display: flex; align-items: center; gap: 12px;
|
|
461
|
+
background: var(--search-bg); color: var(--search-ink);
|
|
462
|
+
border-radius: var(--search-radius);
|
|
463
|
+
box-shadow: 0 14px 40px rgb(0 0 0 / 0.18), 0 2px 6px rgb(0 0 0 / 0.08);
|
|
464
|
+
padding: 15px 18px;
|
|
465
|
+
}
|
|
466
|
+
.pc-search-box svg { flex: none; color: var(--search-muted); }
|
|
467
|
+
.pc-search-input {
|
|
468
|
+
flex: 1; min-width: 0; border: 0; outline: 0; background: transparent;
|
|
469
|
+
font: inherit; font-size: 17px; color: var(--search-ink);
|
|
470
|
+
}
|
|
471
|
+
.pc-search-input::placeholder { color: var(--search-muted); }
|
|
472
|
+
.pc-search-input::-webkit-search-cancel-button { display: none; }
|
|
473
|
+
.pc-search-kbd {
|
|
474
|
+
flex: none; border: 1px solid var(--search-line); border-radius: 6px;
|
|
475
|
+
background: transparent; color: var(--search-muted); cursor: pointer;
|
|
476
|
+
font-size: 11px; font-weight: 700; letter-spacing: 0.04em; padding: 2px 7px;
|
|
477
|
+
}
|
|
478
|
+
.pc-search-kbd:hover { color: var(--search-ink); }
|
|
479
|
+
|
|
480
|
+
.pc-search-results {
|
|
481
|
+
margin-top: 10px; padding: 2px 0 8px;
|
|
482
|
+
background: var(--search-bg); border-radius: var(--search-radius);
|
|
483
|
+
box-shadow: 0 14px 40px rgb(0 0 0 / 0.16);
|
|
484
|
+
max-height: min(56vh, 460px); overflow-y: auto;
|
|
485
|
+
}
|
|
486
|
+
.pc-search-group {
|
|
487
|
+
font-size: 10.5px; font-weight: 700; letter-spacing: 0.13em; text-transform: uppercase;
|
|
488
|
+
color: var(--search-muted); padding: 14px 18px 4px;
|
|
489
|
+
}
|
|
490
|
+
a.pc-search-hit {
|
|
491
|
+
display: flex; align-items: baseline; gap: 10px;
|
|
492
|
+
padding: 8px 18px; text-decoration: none; color: var(--search-ink); font-size: 15.5px;
|
|
493
|
+
}
|
|
494
|
+
a.pc-search-hit:hover, a.pc-search-hit.sel {
|
|
495
|
+
background: color-mix(in srgb, var(--search-ink) 6%, transparent);
|
|
496
|
+
}
|
|
497
|
+
.pc-search-t { min-width: 0; }
|
|
498
|
+
.pc-search-snip {
|
|
499
|
+
display: block; font-size: 13px; color: var(--search-muted);
|
|
500
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
501
|
+
}
|
|
502
|
+
.pc-search-snip mark {
|
|
503
|
+
background: color-mix(in srgb, var(--search-accent) 22%, transparent);
|
|
504
|
+
color: inherit; border-radius: 3px; padding: 0 1px;
|
|
505
|
+
}
|
|
506
|
+
.pc-search-m { margin-left: auto; flex: none; font-size: 12.5px; color: var(--search-muted); }
|
|
507
|
+
.pc-search-empty { padding: 18px; color: var(--search-muted); font-size: 14.5px; }
|
|
508
|
+
|
|
509
|
+
@media (max-width: 640px) {
|
|
510
|
+
dialog.pc-search { --search-top: 4vh; width: 94vw; }
|
|
511
|
+
}
|
|
512
|
+
}
|
package/src/lib/repo/search.ts
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
import type { Post } from '../schema';
|
|
2
|
-
|
|
3
|
-
// Shared ranking for the LIKE-based search: title hits beat excerpt hits beat
|
|
4
|
-
// body hits; recency breaks ties. Swap the adapters' SQL for FTS later and
|
|
5
|
-
// this ranking can go — the repo method signature stays the same.
|
|
6
|
-
export function rankByQuery(posts: Post[], query: string): Post[] {
|
|
7
|
-
const q = query.toLowerCase();
|
|
8
|
-
const score = (p: Post) =>
|
|
9
|
-
(p.title.toLowerCase().includes(q) ? 4 : 0) +
|
|
10
|
-
(p.excerpt.toLowerCase().includes(q) ? 2 : 0) +
|
|
11
|
-
(p.tags.some((t) => t.toLowerCase().includes(q)) ? 2 : 0) +
|
|
12
|
-
1;
|
|
13
|
-
return [...posts].sort((a, b) => score(b) - score(a) || (b.publishedAt ?? 0) - (a.publishedAt ?? 0));
|
|
14
|
-
}
|