@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.
Files changed (50) hide show
  1. package/README.md +7 -16
  2. package/dist/boot.d.ts +2 -0
  3. package/dist/boot.js +3 -1
  4. package/dist/config.d.ts +0 -1
  5. package/dist/config.js +0 -1
  6. package/dist/create-site/cli.js +0 -0
  7. package/dist/create-site/generate-site.js +1 -1
  8. package/dist/create-site/mint-token-cli.js +0 -0
  9. package/dist/create-site/template/AGENTS.md +191 -0
  10. package/dist/create-site/template/vhost/Dockerfile +1 -1
  11. package/dist/media/filename.js +4 -1
  12. package/dist/migrations/index.d.ts +1 -1
  13. package/dist/migrations/index.js +24 -1
  14. package/dist/renderer/render-cache.d.ts +10 -0
  15. package/dist/renderer/render-cache.js +11 -0
  16. package/dist/renderer/render-page.d.ts +2 -0
  17. package/dist/renderer/render-page.js +40 -1
  18. package/dist/routes/admin-redirect.d.ts +5 -0
  19. package/dist/routes/admin-redirect.js +26 -0
  20. package/dist/routes/capabilities.js +2 -2
  21. package/dist/routes/media-public.js +6 -0
  22. package/dist/routes/preview-revision.js +3 -19
  23. package/dist/routes/preview.js +0 -18
  24. package/dist/routes/public.d.ts +2 -0
  25. package/dist/routes/public.js +25 -30
  26. package/dist/routes/search-public.d.ts +6 -0
  27. package/dist/routes/search-public.js +104 -0
  28. package/dist/routes/search.js +4 -0
  29. package/dist/routes/sitemap.js +4 -10
  30. package/dist/schemas/page.schema.json +6 -0
  31. package/dist/search/drivers/node-sqlite-driver.d.ts +5 -1
  32. package/dist/search/drivers/node-sqlite-driver.js +2 -2
  33. package/dist/search/query-content.d.ts +32 -0
  34. package/dist/search/query-content.js +207 -0
  35. package/dist/search/rebuild-index.js +248 -55
  36. package/dist/server-config.d.ts +1 -0
  37. package/dist/server-config.js +28 -1
  38. package/dist/server.js +22 -0
  39. package/dist/services/content-read.js +5 -10
  40. package/dist/services/delete-content.js +2 -13
  41. package/dist/services/manage-redirects.js +5 -15
  42. package/dist/services/migration-runner.js +55 -13
  43. package/dist/services/publish.js +8 -14
  44. package/dist/services/rate-limit-config.d.ts +1 -1
  45. package/dist/services/rate-limit-config.js +6 -4
  46. package/dist/services/theme-schemas.js +2 -2
  47. package/dist/services/validation.d.ts +0 -1
  48. package/dist/services/validation.js +0 -11
  49. package/package.json +2 -2
  50. package/dist/schemas/post.schema.json +0 -25
@@ -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 html = await renderPage(config, themeTemplates, layouts, engine, toRenderPath(resolved.relativePath), 'public');
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,6 @@
1
+ import type { FastifyPluginAsync } from 'fastify';
2
+ import type { SiteConfig } from '../config.ts';
3
+ export interface SearchPublicRouteOptions {
4
+ config: SiteConfig;
5
+ }
6
+ export declare const searchPublicRoutes: FastifyPluginAsync<SearchPublicRouteOptions>;
@@ -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
+ };
@@ -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) -
@@ -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, subdir, relativePath) {
9
+ function isPublished(contentRoot, relativePath) {
11
10
  try {
12
- const { bytes } = readContentFile(contentRoot, join(subdir, relativePath));
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-guide.md), not real content.
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, 'pages', relativePath)) {
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 declare function openNodeSqliteDriver(path: string): SearchDriver;
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
+ }