@o-a/cms-agent 0.1.7 → 0.2.1
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/README.md +7 -16
- package/dist/boot.d.ts +2 -0
- package/dist/boot.js +3 -1
- package/dist/config.d.ts +0 -1
- package/dist/config.js +0 -1
- package/dist/create-site/cli.js +0 -0
- package/dist/create-site/generate-site.js +1 -1
- package/dist/create-site/mint-token-cli.js +0 -0
- package/dist/create-site/template/AGENTS.md +191 -0
- package/dist/create-site/template/vhost/Dockerfile +1 -1
- package/dist/media/filename.js +4 -1
- package/dist/migrations/index.d.ts +1 -1
- package/dist/migrations/index.js +24 -1
- package/dist/renderer/render-cache.d.ts +10 -0
- package/dist/renderer/render-cache.js +11 -0
- package/dist/renderer/render-page.d.ts +2 -0
- package/dist/renderer/render-page.js +40 -1
- package/dist/routes/admin-redirect.d.ts +5 -0
- package/dist/routes/admin-redirect.js +26 -0
- package/dist/routes/capabilities.js +2 -2
- package/dist/routes/media-public.js +6 -0
- package/dist/routes/preview-revision.js +3 -19
- package/dist/routes/preview.js +0 -18
- package/dist/routes/public.d.ts +2 -0
- package/dist/routes/public.js +25 -30
- package/dist/routes/search-public.d.ts +6 -0
- package/dist/routes/search-public.js +104 -0
- package/dist/routes/search.js +4 -0
- package/dist/routes/sitemap.js +4 -10
- package/dist/schemas/page.schema.json +6 -0
- package/dist/search/drivers/node-sqlite-driver.d.ts +5 -1
- package/dist/search/drivers/node-sqlite-driver.js +2 -2
- package/dist/search/query-content.d.ts +32 -0
- package/dist/search/query-content.js +207 -0
- package/dist/search/rebuild-index.js +248 -55
- package/dist/server-config.d.ts +1 -0
- package/dist/server-config.js +28 -1
- package/dist/server.js +22 -0
- package/dist/services/content-read.js +5 -10
- package/dist/services/delete-content.js +2 -13
- package/dist/services/manage-redirects.js +5 -15
- package/dist/services/migration-runner.js +55 -13
- package/dist/services/publish.js +8 -14
- package/dist/services/rate-limit-config.d.ts +1 -1
- package/dist/services/rate-limit-config.js +6 -4
- package/dist/services/theme-schemas.js +2 -2
- package/dist/services/validation.d.ts +0 -1
- package/dist/services/validation.js +0 -11
- package/package.json +2 -2
- package/dist/schemas/post.schema.json +0 -25
package/dist/routes/public.js
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
|
-
import { PageRenderError, renderPage } from "../renderer/render-page.js";
|
|
2
|
+
import { getMenusMtimeMs, getPageMtimeMs, PageRenderError, renderPage } from "../renderer/render-page.js";
|
|
3
3
|
import { PathSafetyError } from "../services/path-safety.js";
|
|
4
|
-
import { isBlogUrl } from "../services/post-urls.js";
|
|
5
|
-
import { resolveBlogUrl } from "../services/resolve-blog-url.js";
|
|
6
4
|
import { resolveUrl } from "../services/resolve-url.js";
|
|
7
5
|
import { findStaticFile, sendStaticFile } from "../services/static-file.js";
|
|
8
6
|
// resolveUrl's relativePath is relative to pagesRoot (e.g. "about.json"),
|
|
@@ -13,12 +11,6 @@ import { findStaticFile, sendStaticFile } from "../services/static-file.js";
|
|
|
13
11
|
function toRenderPath(pagesRelativePath) {
|
|
14
12
|
return join('pages', pagesRelativePath);
|
|
15
13
|
}
|
|
16
|
-
// Same seam as toRenderPath above, for posts: resolveBlogUrl's
|
|
17
|
-
// relativePath is relative to postsRoot, renderPage's is relative to
|
|
18
|
-
// contentRoot/draftsRoot directly.
|
|
19
|
-
function toPostsRenderPath(postsRelativePath) {
|
|
20
|
-
return join('posts', postsRelativePath);
|
|
21
|
-
}
|
|
22
14
|
// A themed 404: content/pages/404.json, if it exists and is published,
|
|
23
15
|
// is rendered through the ordinary public renderPage pipeline (layout +
|
|
24
16
|
// sections + blocks, same as any other page) - no special-casing in
|
|
@@ -37,7 +29,7 @@ async function sendNotFound(reply, config, themeTemplates, layouts, engine, url)
|
|
|
37
29
|
reply.code(404).send({ statusCode: 404, error: 'Not Found', message: `No page at "${url}"` });
|
|
38
30
|
}
|
|
39
31
|
}
|
|
40
|
-
async function handlePublicRequest(request, reply, config, themeTemplates, layouts, engine) {
|
|
32
|
+
async function handlePublicRequest(request, reply, config, themeTemplates, layouts, engine, renderCache) {
|
|
41
33
|
// The public catch-all is registered without a /v1 prefix alongside
|
|
42
34
|
// v1Routes (which has its own exact/prefixed routes). Fastify's
|
|
43
35
|
// router already prefers exact matches over this wildcard regardless
|
|
@@ -63,24 +55,6 @@ async function handlePublicRequest(request, reply, config, themeTemplates, layou
|
|
|
63
55
|
}
|
|
64
56
|
const url = `/${request.params['*']}`;
|
|
65
57
|
try {
|
|
66
|
-
// /blog is a permanently reserved namespace (confirmed design
|
|
67
|
-
// decision): checked first, and never falls through to page
|
|
68
|
-
// resolution even on a miss - a page manually placed at
|
|
69
|
-
// content/pages/blog/x.json is deliberately unreachable.
|
|
70
|
-
if (isBlogUrl(url)) {
|
|
71
|
-
const resolved = resolveBlogUrl(config, url);
|
|
72
|
-
if (resolved.kind === 'not-found') {
|
|
73
|
-
await sendNotFound(reply, config, themeTemplates, layouts, engine, url);
|
|
74
|
-
return;
|
|
75
|
-
}
|
|
76
|
-
if (resolved.kind === 'redirect') {
|
|
77
|
-
reply.code(301).header('location', resolved.to).send();
|
|
78
|
-
return;
|
|
79
|
-
}
|
|
80
|
-
const html = await renderPage(config, themeTemplates, layouts, engine, toPostsRenderPath(resolved.relativePath), 'public');
|
|
81
|
-
reply.type('text/html; charset=utf-8').send(html);
|
|
82
|
-
return;
|
|
83
|
-
}
|
|
84
58
|
const resolved = resolveUrl(config, url);
|
|
85
59
|
if (resolved.kind === 'not-found') {
|
|
86
60
|
await sendNotFound(reply, config, themeTemplates, layouts, engine, url);
|
|
@@ -90,7 +64,28 @@ async function handlePublicRequest(request, reply, config, themeTemplates, layou
|
|
|
90
64
|
reply.code(301).header('location', resolved.to).send();
|
|
91
65
|
return;
|
|
92
66
|
}
|
|
93
|
-
const
|
|
67
|
+
const renderPath = toRenderPath(resolved.relativePath);
|
|
68
|
+
// Validated against real filesystem state, not invalidated by
|
|
69
|
+
// hooking every write path (publish/unpublish/delete/move/batch) -
|
|
70
|
+
// see render-cache.ts's own comment for why. pageMtimeMs is null
|
|
71
|
+
// only if the file vanished between resolveUrl confirming it
|
|
72
|
+
// exists and this check (vanishingly unlikely) - falls through to
|
|
73
|
+
// an ordinary uncached render rather than treating that as
|
|
74
|
+
// fatal.
|
|
75
|
+
const pageMtimeMs = getPageMtimeMs(config, renderPath);
|
|
76
|
+
if (pageMtimeMs !== null) {
|
|
77
|
+
const menusMtimeMs = getMenusMtimeMs(config);
|
|
78
|
+
const cached = renderCache.get(renderPath);
|
|
79
|
+
if (cached && cached.pageMtimeMs === pageMtimeMs && cached.menusMtimeMs === menusMtimeMs) {
|
|
80
|
+
reply.type('text/html; charset=utf-8').send(cached.html);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const html = await renderPage(config, themeTemplates, layouts, engine, renderPath, 'public');
|
|
84
|
+
renderCache.set(renderPath, { html, pageMtimeMs, menusMtimeMs });
|
|
85
|
+
reply.type('text/html; charset=utf-8').send(html);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const html = await renderPage(config, themeTemplates, layouts, engine, renderPath, 'public');
|
|
94
89
|
reply.type('text/html; charset=utf-8').send(html);
|
|
95
90
|
}
|
|
96
91
|
catch (error) {
|
|
@@ -113,5 +108,5 @@ async function handlePublicRequest(request, reply, config, themeTemplates, layou
|
|
|
113
108
|
}
|
|
114
109
|
}
|
|
115
110
|
export const publicRoutes = async (fastify, opts) => {
|
|
116
|
-
fastify.get('/*', async (request, reply) => handlePublicRequest(request, reply, opts.config, opts.themeTemplates, opts.layouts, opts.engine));
|
|
111
|
+
fastify.get('/*', async (request, reply) => handlePublicRequest(request, reply, opts.config, opts.themeTemplates, opts.layouts, opts.engine, opts.renderCache));
|
|
117
112
|
};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { queryContent } from "../search/query-content.js";
|
|
2
|
+
import { NO_AUTH_ROUTE_RATE_LIMIT } from "../services/rate-limit-config.js";
|
|
3
|
+
const FIELD_OPS = ['eq', 'gt', 'gte', 'lt', 'lte'];
|
|
4
|
+
const DEFAULT_LIMIT = 20;
|
|
5
|
+
const MAX_LIMIT = 100;
|
|
6
|
+
function badRequest(reply, message) {
|
|
7
|
+
reply.code(400).send({ statusCode: 400, error: 'Bad Request', message });
|
|
8
|
+
}
|
|
9
|
+
// field:value (op implied "eq") or field:op:value. Split on the FIRST
|
|
10
|
+
// colon, then check whether the next segment up to a second colon is
|
|
11
|
+
// one of the known op words - a value that itself contains a colon
|
|
12
|
+
// (unlikely for the fields this targets, but not impossible) still
|
|
13
|
+
// parses correctly either way, since only a genuine, recognised op
|
|
14
|
+
// token is ever treated as one.
|
|
15
|
+
function parseFilter(raw) {
|
|
16
|
+
const firstColon = raw.indexOf(':');
|
|
17
|
+
if (firstColon <= 0) {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
const field = raw.slice(0, firstColon);
|
|
21
|
+
const rest = raw.slice(firstColon + 1);
|
|
22
|
+
const secondColon = rest.indexOf(':');
|
|
23
|
+
if (secondColon !== -1) {
|
|
24
|
+
const maybeOp = rest.slice(0, secondColon);
|
|
25
|
+
if (FIELD_OPS.includes(maybeOp)) {
|
|
26
|
+
const value = rest.slice(secondColon + 1);
|
|
27
|
+
return value === '' ? undefined : { field, op: maybeOp, value };
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return rest === '' ? undefined : { field, op: 'eq', value: rest };
|
|
31
|
+
}
|
|
32
|
+
function parseSort(raw) {
|
|
33
|
+
return raw.startsWith('-') ? { field: raw.slice(1), direction: 'desc' } : { field: raw, direction: 'asc' };
|
|
34
|
+
}
|
|
35
|
+
function parseLimit(raw) {
|
|
36
|
+
if (raw === undefined) {
|
|
37
|
+
return DEFAULT_LIMIT;
|
|
38
|
+
}
|
|
39
|
+
const parsed = Number.parseInt(raw, 10);
|
|
40
|
+
if (!Number.isFinite(parsed) || parsed < 1) {
|
|
41
|
+
return DEFAULT_LIMIT;
|
|
42
|
+
}
|
|
43
|
+
return Math.min(parsed, MAX_LIMIT);
|
|
44
|
+
}
|
|
45
|
+
function parseOffset(raw) {
|
|
46
|
+
if (raw === undefined) {
|
|
47
|
+
return 0;
|
|
48
|
+
}
|
|
49
|
+
const parsed = Number.parseInt(raw, 10);
|
|
50
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
|
|
51
|
+
}
|
|
52
|
+
async function handleSearch(request, reply, config) {
|
|
53
|
+
const { q, pageType, sort } = request.query;
|
|
54
|
+
const rawFilters = request.query.filter;
|
|
55
|
+
const filterStrings = rawFilters === undefined ? [] : Array.isArray(rawFilters) ? rawFilters : [rawFilters];
|
|
56
|
+
const filters = [];
|
|
57
|
+
for (const raw of filterStrings) {
|
|
58
|
+
const parsed = parseFilter(raw);
|
|
59
|
+
if (!parsed) {
|
|
60
|
+
badRequest(reply, `invalid filter "${raw}" - expected field:value or field:op:value`);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (parsed.op !== 'eq' && !Number.isFinite(Number(parsed.value))) {
|
|
64
|
+
badRequest(reply, `value must be numeric for op "${parsed.op}" (filter "${raw}")`);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
filters.push(parsed);
|
|
68
|
+
}
|
|
69
|
+
const response = queryContent(config.searchIndexPath, {
|
|
70
|
+
q,
|
|
71
|
+
pageType,
|
|
72
|
+
filters,
|
|
73
|
+
sort: sort ? parseSort(sort) : undefined,
|
|
74
|
+
limit: parseLimit(request.query.limit),
|
|
75
|
+
offset: parseOffset(request.query.offset),
|
|
76
|
+
});
|
|
77
|
+
reply.send(response);
|
|
78
|
+
}
|
|
79
|
+
// The one public query surface - full-text (q), structured filters,
|
|
80
|
+
// sort, and pagination all in one endpoint (query-content.ts's own
|
|
81
|
+
// queryContent), rather than three narrow ones. Deliberately no
|
|
82
|
+
// requireScope, unlike every other route in this codebase: it's
|
|
83
|
+
// read-only and can only ever surface already-published data
|
|
84
|
+
// (rebuild-index.ts never indexes drafts or unpublished content), so
|
|
85
|
+
// there's nothing here a site visitor couldn't already see by
|
|
86
|
+
// browsing the live site directly - the whole point of this route is
|
|
87
|
+
// that a theme's own front-end JS can call it directly, which a
|
|
88
|
+
// token requirement would rule out entirely (a bearer token embedded
|
|
89
|
+
// in public client-side JS is not a secret - anyone's dev tools can
|
|
90
|
+
// read it straight back out, and this agent's tokens all carry real
|
|
91
|
+
// write scopes, not just search). NO_AUTH_ROUTE_RATE_LIMIT, the same
|
|
92
|
+
// defense-in-depth GET /v1/capabilities already has.
|
|
93
|
+
//
|
|
94
|
+
// GET /search.json, not GET /v1/search: this is a stable, public,
|
|
95
|
+
// front-end-facing contract, not part of the versioned admin/
|
|
96
|
+
// integration surface under /v1 - registered without a prefix in
|
|
97
|
+
// server.ts, alongside mediaPublicRoutes/assetsRoutes/sitemapRoutes.
|
|
98
|
+
// A single reserved path, not a whole prefix (unlike /media/* or
|
|
99
|
+
// /assets/*): a site's own content page can still live at the bare
|
|
100
|
+
// /search URL (see granite-starter/theme/sections/search-demo.liquid,
|
|
101
|
+
// which does exactly that) - only this one exact path is claimed.
|
|
102
|
+
export const searchPublicRoutes = async (fastify, opts) => {
|
|
103
|
+
fastify.get('/search.json', { config: NO_AUTH_ROUTE_RATE_LIMIT }, async (request, reply) => handleSearch(request, reply, opts.config));
|
|
104
|
+
};
|
package/dist/routes/search.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { rebuildIndex } from "../search/rebuild-index.js";
|
|
2
2
|
import { WRITE_ROUTE_RATE_LIMIT } from "../services/rate-limit-config.js";
|
|
3
3
|
import { requireScope } from "../services/token-auth.js";
|
|
4
|
+
// The read side (GET /search.json) lives in routes/search-public.ts,
|
|
5
|
+
// registered separately, unprefixed, in server.ts - this file only
|
|
6
|
+
// ever holds the authenticated write side, matching media.ts (write,
|
|
7
|
+
// under /v1) vs media-public.ts (public read, unprefixed).
|
|
4
8
|
export const searchRoutes = async (fastify, opts) => {
|
|
5
9
|
fastify.post('/search/rebuild', { preHandler: requireScope(opts.tokens, 'content'), config: WRITE_ROUTE_RATE_LIMIT }, async (_request, reply) => {
|
|
6
10
|
// rebuildIndex is already self-enqueue()d (search/rebuild-index.ts) -
|
package/dist/routes/sitemap.js
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
2
|
import { readContentFile } from "../services/content-read.js";
|
|
3
3
|
import { listFilesRecursively } from "../services/fs-walk.js";
|
|
4
|
-
import { postPathToUrl } from "../services/post-urls.js";
|
|
5
4
|
import { pagePathToUrl } from "../services/urls.js";
|
|
6
5
|
// Reads through readContentFile/listFilesRecursively (both already
|
|
7
6
|
// gated behind sanitisePath/agent-configured roots - see their own
|
|
8
7
|
// files) rather than touching fs directly, so this route needs no
|
|
9
8
|
// allowlist entry of its own (docs/phase-1-checklist.md Group B).
|
|
10
|
-
function isPublished(contentRoot,
|
|
9
|
+
function isPublished(contentRoot, relativePath) {
|
|
11
10
|
try {
|
|
12
|
-
const { bytes } = readContentFile(contentRoot, join(
|
|
11
|
+
const { bytes } = readContentFile(contentRoot, join('pages', relativePath));
|
|
13
12
|
const parsed = JSON.parse(bytes.toString('utf-8'));
|
|
14
13
|
return parsed.published === true;
|
|
15
14
|
}
|
|
@@ -30,19 +29,14 @@ function buildSitemapUrls(config) {
|
|
|
30
29
|
for (const relativePath of listFilesRecursively(config.pagesRoot, config.pagesRoot, '.json')) {
|
|
31
30
|
// The 404 page must never be listed as a real crawlable URL,
|
|
32
31
|
// regardless of its own published flag - it's a fallback
|
|
33
|
-
// convention (docs/content-authoring
|
|
32
|
+
// convention (docs/guide-content-authoring.md), not real content.
|
|
34
33
|
if (relativePath === '404.json') {
|
|
35
34
|
continue;
|
|
36
35
|
}
|
|
37
|
-
if (isPublished(config.contentRoot,
|
|
36
|
+
if (isPublished(config.contentRoot, relativePath)) {
|
|
38
37
|
urls.push(pagePathToUrl(relativePath));
|
|
39
38
|
}
|
|
40
39
|
}
|
|
41
|
-
for (const relativePath of listFilesRecursively(config.postsRoot, config.postsRoot, '.json')) {
|
|
42
|
-
if (isPublished(config.contentRoot, 'posts', relativePath)) {
|
|
43
|
-
urls.push(postPathToUrl(relativePath));
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
40
|
return urls;
|
|
47
41
|
}
|
|
48
42
|
function escapeXml(value) {
|
|
@@ -12,6 +12,12 @@
|
|
|
12
12
|
"type": { "type": "string", "minLength": 1 },
|
|
13
13
|
"layout": { "type": "string", "minLength": 1 },
|
|
14
14
|
"published": { "type": "boolean" },
|
|
15
|
+
"author": { "type": "string", "minLength": 1 },
|
|
16
|
+
"publishDate": { "type": "string", "minLength": 1 },
|
|
17
|
+
"tags": {
|
|
18
|
+
"type": "array",
|
|
19
|
+
"items": { "type": "string", "minLength": 1 }
|
|
20
|
+
},
|
|
15
21
|
"sections": {
|
|
16
22
|
"type": "array",
|
|
17
23
|
"items": { "$ref": "instance.schema.json" }
|
|
@@ -1,3 +1,7 @@
|
|
|
1
1
|
import type { SearchDriver } from './driver.ts';
|
|
2
2
|
export declare const DRIVER_NAME = "node:sqlite";
|
|
3
|
-
export
|
|
3
|
+
export interface OpenDriverOptions {
|
|
4
|
+
readOnly?: boolean;
|
|
5
|
+
timeout?: number;
|
|
6
|
+
}
|
|
7
|
+
export declare function openNodeSqliteDriver(path: string, options?: OpenDriverOptions): SearchDriver;
|
|
@@ -9,8 +9,8 @@ export const DRIVER_NAME = 'node:sqlite';
|
|
|
9
9
|
// test/static/static-analysis.test.ts). DatabaseSync's own
|
|
10
10
|
// prepare()/exec()/close() already structurally match SearchDriver,
|
|
11
11
|
// so this is a thin adapter, not a reimplementation.
|
|
12
|
-
export function openNodeSqliteDriver(path) {
|
|
13
|
-
const db = new DatabaseSync(path);
|
|
12
|
+
export function openNodeSqliteDriver(path, options = {}) {
|
|
13
|
+
const db = new DatabaseSync(path, options);
|
|
14
14
|
return {
|
|
15
15
|
exec: (sql) => db.exec(sql),
|
|
16
16
|
prepare: (sql) => db.prepare(sql),
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export type FieldOp = 'eq' | 'gt' | 'gte' | 'lt' | 'lte';
|
|
2
|
+
export interface FieldFilter {
|
|
3
|
+
field: string;
|
|
4
|
+
op: FieldOp;
|
|
5
|
+
value: string;
|
|
6
|
+
}
|
|
7
|
+
export interface SortParam {
|
|
8
|
+
field: string;
|
|
9
|
+
direction: 'asc' | 'desc';
|
|
10
|
+
}
|
|
11
|
+
export interface SearchParams {
|
|
12
|
+
q?: string;
|
|
13
|
+
pageType?: string;
|
|
14
|
+
filters: FieldFilter[];
|
|
15
|
+
sort?: SortParam;
|
|
16
|
+
limit: number;
|
|
17
|
+
offset: number;
|
|
18
|
+
}
|
|
19
|
+
export type FieldValue = string | number | boolean;
|
|
20
|
+
export interface SearchResultItem {
|
|
21
|
+
url: string;
|
|
22
|
+
title: string;
|
|
23
|
+
pageType: string;
|
|
24
|
+
fields: Record<string, FieldValue | FieldValue[]>;
|
|
25
|
+
}
|
|
26
|
+
export interface SearchResponse {
|
|
27
|
+
results: SearchResultItem[];
|
|
28
|
+
limit: number;
|
|
29
|
+
offset: number;
|
|
30
|
+
hasMore: boolean;
|
|
31
|
+
}
|
|
32
|
+
export declare function queryContent(searchIndexPath: string, params: SearchParams): SearchResponse;
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { openNodeSqliteDriver } from "./drivers/node-sqlite-driver.js";
|
|
3
|
+
function toFiniteNumber(raw) {
|
|
4
|
+
const parsed = Number(raw);
|
|
5
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
6
|
+
}
|
|
7
|
+
const COMPARATORS = {
|
|
8
|
+
gt: '>',
|
|
9
|
+
gte: '>=',
|
|
10
|
+
lt: '<',
|
|
11
|
+
lte: '<=',
|
|
12
|
+
};
|
|
13
|
+
// Never passes raw user input straight into FTS5 MATCH - a public
|
|
14
|
+
// search box gets real, messy input (unbalanced quotes, a lone
|
|
15
|
+
// trailing "-", a word FTS5 treats as an operator like "AND" or "NOT")
|
|
16
|
+
// which throws a syntax error against a bare MATCH ? (the previous
|
|
17
|
+
// query-index.ts never guarded this at all). Each whitespace-separated
|
|
18
|
+
// token becomes its own quoted, prefix-matched literal - "hello world"
|
|
19
|
+
// becomes "hello"* AND "world"*, embedded quotes doubled per FTS5's own
|
|
20
|
+
// escaping rule - so the constructed expression can never be
|
|
21
|
+
// misinterpreted as an operator, regardless of what the user typed.
|
|
22
|
+
function buildMatchExpression(q) {
|
|
23
|
+
const tokens = q
|
|
24
|
+
.split(/\s+/)
|
|
25
|
+
.map((token) => token.trim())
|
|
26
|
+
.filter((token) => token.length > 0);
|
|
27
|
+
if (tokens.length === 0) {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
return tokens.map((token) => `"${token.replace(/"/g, '""')}"*`).join(' AND ');
|
|
31
|
+
}
|
|
32
|
+
// One INNER JOIN per filter - a page must satisfy every filter to
|
|
33
|
+
// appear at all, so a page missing that field entirely (or with a
|
|
34
|
+
// non-matching value) is correctly excluded, not just left with a
|
|
35
|
+
// null comparison. Same typed-column branch logic query-fields.ts
|
|
36
|
+
// (now retired) already proved out for a single filter.
|
|
37
|
+
function buildFilterJoin(alias, filter) {
|
|
38
|
+
const args = [filter.field];
|
|
39
|
+
if (filter.op === 'eq') {
|
|
40
|
+
const branches = [`${alias}.value_text = ?`];
|
|
41
|
+
args.push(filter.value);
|
|
42
|
+
const numeric = toFiniteNumber(filter.value);
|
|
43
|
+
if (numeric !== undefined) {
|
|
44
|
+
branches.push(`${alias}.value_number = ?`);
|
|
45
|
+
args.push(numeric);
|
|
46
|
+
}
|
|
47
|
+
if (filter.value === 'true' || filter.value === 'false') {
|
|
48
|
+
branches.push(`${alias}.value_bool = ?`);
|
|
49
|
+
args.push(filter.value === 'true' ? 1 : 0);
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
sql: `JOIN page_fields ${alias} ON ${alias}.url = f.url AND ${alias}.field_key = ? AND (${branches.join(' OR ')})`,
|
|
53
|
+
args,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
const numeric = toFiniteNumber(filter.value);
|
|
57
|
+
if (numeric === undefined) {
|
|
58
|
+
// The route validates a numeric op against a numeric value before
|
|
59
|
+
// ever calling in - reaching here regardless just means "nothing
|
|
60
|
+
// could possibly match", not an error this layer needs to raise.
|
|
61
|
+
return { sql: `JOIN page_fields ${alias} ON ${alias}.url = f.url AND ${alias}.field_key = ? AND 1 = 0`, args };
|
|
62
|
+
}
|
|
63
|
+
args.push(numeric);
|
|
64
|
+
return {
|
|
65
|
+
sql: `JOIN page_fields ${alias} ON ${alias}.url = f.url AND ${alias}.field_key = ? AND ${alias}.value_number ${COMPARATORS[filter.op]} ?`,
|
|
66
|
+
args,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
// LEFT, not INNER - unlike a filter, sorting by a field a given page
|
|
70
|
+
// doesn't have shouldn't drop that page from the results, just leave
|
|
71
|
+
// it ordered with a null value (SQLite sorts NULL first in ASC order).
|
|
72
|
+
// COALESCE across both typed columns since this layer has no schema in
|
|
73
|
+
// hand at query time to know in advance which one a given field
|
|
74
|
+
// actually uses.
|
|
75
|
+
function buildSortJoin(field) {
|
|
76
|
+
return {
|
|
77
|
+
sql: 'LEFT JOIN page_fields sort_field ON sort_field.url = f.url AND sort_field.field_key = ?',
|
|
78
|
+
args: [field],
|
|
79
|
+
column: 'COALESCE(sort_field.value_number, sort_field.value_text)',
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
function fieldRowValue(row) {
|
|
83
|
+
if (row.value_text !== null) {
|
|
84
|
+
return row.value_text;
|
|
85
|
+
}
|
|
86
|
+
if (row.value_number !== null) {
|
|
87
|
+
return row.value_number;
|
|
88
|
+
}
|
|
89
|
+
if (row.value_bool !== null) {
|
|
90
|
+
return row.value_bool === 1;
|
|
91
|
+
}
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
// The one query surface a front-end talks to directly - covers a
|
|
95
|
+
// blog listing (pageType + sort by publishDate + pagination), a
|
|
96
|
+
// product grid (several ANDed filters + sort by a numeric field), and
|
|
97
|
+
// general site search (q), rather than three narrow endpoints each
|
|
98
|
+
// covering one of those. Two queries total, never N+1: one for the
|
|
99
|
+
// (already paginated) matching urls, one gathering every indexed
|
|
100
|
+
// field for just that page of urls to build each result's own
|
|
101
|
+
// "fields" map - a product grid needs price to render, not just to
|
|
102
|
+
// have matched.
|
|
103
|
+
export function queryContent(searchIndexPath, params) {
|
|
104
|
+
// readOnly: true (below) throws if the file doesn't exist rather
|
|
105
|
+
// than silently auto-creating an empty one the way a normal open
|
|
106
|
+
// would - checked here instead, so "no rebuild has ever run yet" (a
|
|
107
|
+
// real, expected state for a brand new site) reads as an empty
|
|
108
|
+
// result set, not a 500.
|
|
109
|
+
if (!existsSync(searchIndexPath)) {
|
|
110
|
+
return { results: [], limit: params.limit, offset: params.offset, hasMore: false };
|
|
111
|
+
}
|
|
112
|
+
const driver = openNodeSqliteDriver(searchIndexPath, { readOnly: true, timeout: 2000 });
|
|
113
|
+
try {
|
|
114
|
+
const joins = [];
|
|
115
|
+
const joinArgs = [];
|
|
116
|
+
params.filters.forEach((filter, index) => {
|
|
117
|
+
const built = buildFilterJoin(`pf${index}`, filter);
|
|
118
|
+
joins.push(built.sql);
|
|
119
|
+
joinArgs.push(...built.args);
|
|
120
|
+
});
|
|
121
|
+
const where = [];
|
|
122
|
+
const whereArgs = [];
|
|
123
|
+
const matchExpression = params.q ? buildMatchExpression(params.q) : undefined;
|
|
124
|
+
if (matchExpression) {
|
|
125
|
+
// FTS5's own "tbl MATCH expr" special syntax only recognises the
|
|
126
|
+
// real table name here, not an alias (confirmed live - "f MATCH
|
|
127
|
+
// ?" throws "no such column: f" even though ordinary column
|
|
128
|
+
// references through the same alias work fine everywhere else in
|
|
129
|
+
// this query).
|
|
130
|
+
where.push('pages_fts MATCH ?');
|
|
131
|
+
whereArgs.push(matchExpression);
|
|
132
|
+
}
|
|
133
|
+
if (params.pageType !== undefined) {
|
|
134
|
+
where.push('f.page_type = ?');
|
|
135
|
+
whereArgs.push(params.pageType);
|
|
136
|
+
}
|
|
137
|
+
let orderJoin = '';
|
|
138
|
+
const orderJoinArgs = [];
|
|
139
|
+
let orderBy = 'f.url ASC';
|
|
140
|
+
if (params.sort) {
|
|
141
|
+
const built = buildSortJoin(params.sort.field);
|
|
142
|
+
orderJoin = built.sql;
|
|
143
|
+
orderJoinArgs.push(...built.args);
|
|
144
|
+
orderBy = `${built.column} ${params.sort.direction === 'desc' ? 'DESC' : 'ASC'}`;
|
|
145
|
+
}
|
|
146
|
+
else if (matchExpression) {
|
|
147
|
+
// FTS5's own bm25-derived rank: more negative is more relevant,
|
|
148
|
+
// so plain ascending order is "best match first".
|
|
149
|
+
orderBy = 'rank';
|
|
150
|
+
}
|
|
151
|
+
const sql = [
|
|
152
|
+
'SELECT DISTINCT f.url, f.title, f.page_type',
|
|
153
|
+
'FROM pages_fts f',
|
|
154
|
+
...joins,
|
|
155
|
+
orderJoin,
|
|
156
|
+
where.length > 0 ? `WHERE ${where.join(' AND ')}` : '',
|
|
157
|
+
`ORDER BY ${orderBy}`,
|
|
158
|
+
'LIMIT ? OFFSET ?',
|
|
159
|
+
]
|
|
160
|
+
.filter((part) => part !== '')
|
|
161
|
+
.join(' ');
|
|
162
|
+
// Request one extra row to know whether there's a next page,
|
|
163
|
+
// rather than a separate COUNT(*) query - a real, doubled cost on
|
|
164
|
+
// every single paginated request neither stated use case (infinite
|
|
165
|
+
// scroll, a grid's own "next" button) actually needs an exact
|
|
166
|
+
// total for.
|
|
167
|
+
const args = [...joinArgs, ...orderJoinArgs, ...whereArgs, params.limit + 1, params.offset];
|
|
168
|
+
const mainRows = driver.prepare(sql).all(...args);
|
|
169
|
+
const hasMore = mainRows.length > params.limit;
|
|
170
|
+
const pageRows = mainRows.slice(0, params.limit);
|
|
171
|
+
const fieldsByUrl = new Map();
|
|
172
|
+
if (pageRows.length > 0) {
|
|
173
|
+
const placeholders = pageRows.map(() => '?').join(', ');
|
|
174
|
+
const fieldRows = driver
|
|
175
|
+
.prepare(`SELECT url, field_key, value_text, value_number, value_bool FROM page_fields WHERE url IN (${placeholders})`)
|
|
176
|
+
.all(...pageRows.map((row) => row.url));
|
|
177
|
+
for (const row of fieldRows) {
|
|
178
|
+
const value = fieldRowValue(row);
|
|
179
|
+
if (value === undefined) {
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
const entry = fieldsByUrl.get(row.url) ?? {};
|
|
183
|
+
const existing = entry[row.field_key];
|
|
184
|
+
if (existing === undefined) {
|
|
185
|
+
entry[row.field_key] = value;
|
|
186
|
+
}
|
|
187
|
+
else if (Array.isArray(existing)) {
|
|
188
|
+
existing.push(value);
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
entry[row.field_key] = [existing, value];
|
|
192
|
+
}
|
|
193
|
+
fieldsByUrl.set(row.url, entry);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const results = pageRows.map((row) => ({
|
|
197
|
+
url: row.url,
|
|
198
|
+
title: row.title,
|
|
199
|
+
pageType: row.page_type,
|
|
200
|
+
fields: fieldsByUrl.get(row.url) ?? {},
|
|
201
|
+
}));
|
|
202
|
+
return { results, limit: params.limit, offset: params.offset, hasMore };
|
|
203
|
+
}
|
|
204
|
+
finally {
|
|
205
|
+
driver.close();
|
|
206
|
+
}
|
|
207
|
+
}
|