@o-a/cms-agent 0.1.7 → 0.2.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.
Files changed (45) hide show
  1. package/dist/boot.d.ts +2 -0
  2. package/dist/boot.js +3 -1
  3. package/dist/config.d.ts +0 -1
  4. package/dist/config.js +0 -1
  5. package/dist/create-site/cli.js +0 -0
  6. package/dist/create-site/mint-token-cli.js +0 -0
  7. package/dist/media/filename.js +4 -1
  8. package/dist/migrations/index.d.ts +1 -1
  9. package/dist/migrations/index.js +24 -1
  10. package/dist/renderer/render-cache.d.ts +10 -0
  11. package/dist/renderer/render-cache.js +11 -0
  12. package/dist/renderer/render-page.d.ts +2 -0
  13. package/dist/renderer/render-page.js +40 -1
  14. package/dist/routes/admin-redirect.d.ts +5 -0
  15. package/dist/routes/admin-redirect.js +26 -0
  16. package/dist/routes/capabilities.js +2 -2
  17. package/dist/routes/media-public.js +6 -0
  18. package/dist/routes/preview-revision.js +3 -19
  19. package/dist/routes/preview.js +0 -18
  20. package/dist/routes/public.d.ts +2 -0
  21. package/dist/routes/public.js +25 -30
  22. package/dist/routes/search-public.d.ts +6 -0
  23. package/dist/routes/search-public.js +104 -0
  24. package/dist/routes/search.js +4 -0
  25. package/dist/routes/sitemap.js +3 -9
  26. package/dist/schemas/page.schema.json +6 -0
  27. package/dist/search/drivers/node-sqlite-driver.d.ts +5 -1
  28. package/dist/search/drivers/node-sqlite-driver.js +2 -2
  29. package/dist/search/query-content.d.ts +32 -0
  30. package/dist/search/query-content.js +207 -0
  31. package/dist/search/rebuild-index.js +248 -55
  32. package/dist/server-config.d.ts +1 -0
  33. package/dist/server-config.js +28 -1
  34. package/dist/server.js +22 -0
  35. package/dist/services/content-read.js +5 -10
  36. package/dist/services/delete-content.js +2 -13
  37. package/dist/services/manage-redirects.js +5 -15
  38. package/dist/services/migration-runner.js +55 -13
  39. package/dist/services/publish.js +8 -14
  40. package/dist/services/rate-limit-config.d.ts +1 -1
  41. package/dist/services/rate-limit-config.js +6 -4
  42. package/dist/services/validation.d.ts +0 -1
  43. package/dist/services/validation.js +0 -11
  44. package/package.json +2 -2
  45. package/dist/schemas/post.schema.json +0 -25
package/dist/boot.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { Liquid } from 'liquidjs';
2
2
  import type { SiteConfig } from './config.ts';
3
+ import type { RenderCache } from './renderer/render-cache.ts';
3
4
  import type { ThemeTemplates } from './renderer/theme-templates.ts';
4
5
  import type { StartupCheckOptions } from './services/startup-checks.ts';
5
6
  import type { ThemeSchemas } from './services/validation.ts';
@@ -11,5 +12,6 @@ export interface BootedSite {
11
12
  layouts: Record<string, string>;
12
13
  pageTemplates: PageTemplate[];
13
14
  engine: Liquid;
15
+ renderCache: RenderCache;
14
16
  }
15
17
  export declare function bootSite(siteRoot: string, options?: StartupCheckOptions): BootedSite;
package/dist/boot.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { loadSiteConfig } from "./config.js";
2
2
  import { createEngine } from "./renderer/engine.js";
3
+ import { createRenderCache } from "./renderer/render-cache.js";
3
4
  import { loadLayouts, loadSnippets, loadThemeTemplates } from "./renderer/theme-templates.js";
4
5
  import { runStartupChecks } from "./services/startup-checks.js";
5
6
  import { loadThemeSchemas } from "./services/theme-schemas.js";
@@ -29,5 +30,6 @@ export function bootSite(siteRoot, options) {
29
30
  // page.
30
31
  const pageTemplates = loadPageTemplates(config.templatesRoot, themeSchemas);
31
32
  const engine = createEngine(snippets);
32
- return { config, themeSchemas, themeTemplates, layouts, pageTemplates, engine };
33
+ const renderCache = createRenderCache();
34
+ return { config, themeSchemas, themeTemplates, layouts, pageTemplates, engine, renderCache };
33
35
  }
package/dist/config.d.ts CHANGED
@@ -8,7 +8,6 @@ export interface SiteConfig {
8
8
  rootMirrorRoot: string;
9
9
  templatesRoot: string;
10
10
  pagesRoot: string;
11
- postsRoot: string;
12
11
  menusRoot: string;
13
12
  redirectsPath: string;
14
13
  vhostRoot: string;
package/dist/config.js CHANGED
@@ -33,7 +33,6 @@ export function loadSiteConfig(siteRoot) {
33
33
  rootMirrorRoot: join(themeRoot, 'root'),
34
34
  templatesRoot: join(themeRoot, 'templates'),
35
35
  pagesRoot: join(contentRoot, 'pages'),
36
- postsRoot: join(contentRoot, 'posts'),
37
36
  menusRoot: join(contentRoot, 'menus'),
38
37
  // Also nested inside contentRoot, for the same reason.
39
38
  redirectsPath: join(contentRoot, 'redirects.json'),
File without changes
File without changes
@@ -27,5 +27,8 @@ export function buildMediaFilename(originalFilename, bytes) {
27
27
  const cleaned = base.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '');
28
28
  const slug = cleaned.length > 0 ? cleaned : 'file';
29
29
  const extension = extname(originalFilename).toLowerCase();
30
- return `${hash}-${slug}${extension}`;
30
+ // Slug first, hash last (requested directly) - the readable part of
31
+ // the filename sorts and scans first in a directory listing/URL,
32
+ // with the hash as a suffix rather than something to read past.
33
+ return `${slug}-${hash}${extension}`;
31
34
  }
@@ -1,3 +1,3 @@
1
1
  import type { MigrationMap } from '../services/migration-runner.ts';
2
- export declare const CURRENT_SCHEMA_VERSION = 5;
2
+ export declare const CURRENT_SCHEMA_VERSION = 6;
3
3
  export declare const migrations: MigrationMap;
@@ -1,7 +1,7 @@
1
1
  // The current content schema version. Bumping this and adding a new
2
2
  // migrations[N] entry is the only way a content shape may change
3
3
  // (constraint 4) - never a manual edit convention.
4
- export const CURRENT_SCHEMA_VERSION = 5;
4
+ export const CURRENT_SCHEMA_VERSION = 6;
5
5
  // A trivial identity migration, proving the mechanism (per the build
6
6
  // plan's Phase 1 scope): no shape change, only the version bump. Safe
7
7
  // against page.schema.json's schemaVersion: { minimum: 1 } (not an
@@ -41,9 +41,32 @@ function migrateV4ToV5(content) {
41
41
  const title = typeof content.title === 'string' ? content.title : '';
42
42
  return { ...content, schemaVersion: 5, name: title };
43
43
  }
44
+ // Folds the distinct "post" content type into "page" - author,
45
+ // publishDate, and tags become optional fields any page may carry
46
+ // (page.schema.json), and the post-only schema (which required them,
47
+ // but had no "name" field at all) is retired. Ordinary pages already
48
+ // at v5 already have "name"; this is only ever a genuine backfill for
49
+ // a legacy content/posts/*.json file, which never had one. Mirrors the
50
+ // admin's own backfillPageName (derivePageLabel.ts) precisely: fills
51
+ // name from title only when name is genuinely absent, never overwrites
52
+ // an existing one (a page's name may deliberately differ from its
53
+ // title - migrateV4ToV5's own unconditional set was safe only because
54
+ // no content reaching it could already have a name).
55
+ // migration-runner.ts's runMigrationsJob is what physically relocates
56
+ // a legacy content/posts/<slug>.json file to content/pages/blog/<slug>.json
57
+ // alongside this shape change - this function only ever transforms
58
+ // content, never a path.
59
+ function migrateV5ToV6(content) {
60
+ if ('name' in content) {
61
+ return { ...content, schemaVersion: 6 };
62
+ }
63
+ const title = typeof content.title === 'string' ? content.title : '';
64
+ return { ...content, schemaVersion: 6, name: title };
65
+ }
44
66
  export const migrations = {
45
67
  1: migrateV1ToV2,
46
68
  2: migrateV2ToV3,
47
69
  3: migrateV3ToV4,
48
70
  4: migrateV4ToV5,
71
+ 5: migrateV5ToV6,
49
72
  };
@@ -0,0 +1,10 @@
1
+ export interface RenderCacheEntry {
2
+ html: string;
3
+ pageMtimeMs: number;
4
+ menusMtimeMs: number;
5
+ }
6
+ export interface RenderCache {
7
+ get(renderPath: string): RenderCacheEntry | undefined;
8
+ set(renderPath: string, entry: RenderCacheEntry): void;
9
+ }
10
+ export declare function createRenderCache(): RenderCache;
@@ -0,0 +1,11 @@
1
+ export function createRenderCache() {
2
+ const map = new Map();
3
+ return {
4
+ get(renderPath) {
5
+ return map.get(renderPath);
6
+ },
7
+ set(renderPath, entry) {
8
+ map.set(renderPath, entry);
9
+ },
10
+ };
11
+ }
@@ -26,3 +26,5 @@ export type RenderMode = 'public' | 'preview';
26
26
  export declare function parsePageContent(raw: string): PageContent;
27
27
  export declare function renderLoadedPage(page: PageContent, config: SiteConfig, themeTemplates: ThemeTemplates, layouts: Record<string, string>, engine: Liquid): Promise<string>;
28
28
  export declare function renderPage(config: SiteConfig, themeTemplates: ThemeTemplates, layouts: Record<string, string>, engine: Liquid, relativePath: string, mode: RenderMode): Promise<string>;
29
+ export declare function getPageMtimeMs(config: SiteConfig, relativePath: string): number | null;
30
+ export declare function getMenusMtimeMs(config: SiteConfig): number;
@@ -1,4 +1,5 @@
1
- import { readFileSync } from 'node:fs';
1
+ import { readFileSync, statSync } from 'node:fs';
2
+ import { listFilesRecursively } from "../services/fs-walk.js";
2
3
  import { loadMenus } from "../services/menus.js";
3
4
  import { sanitisePath } from "../services/path-safety.js";
4
5
  export class PageRenderError extends Error {
@@ -141,3 +142,41 @@ export async function renderPage(config, themeTemplates, layouts, engine, relati
141
142
  const page = loadPageForRender(config, relativePath, mode);
142
143
  return renderLoadedPage(page, config, themeTemplates, layouts, engine);
143
144
  }
145
+ // For public.ts's render cache: the page's own current mtime, so a
146
+ // cached render can be validated against real filesystem state rather
147
+ // than needing every write path (publish/unpublish/delete/move/batch)
148
+ // to remember to invalidate something. null means "not cacheable this
149
+ // time" (the file vanished between resolveUrl confirming it exists and
150
+ // this call - vanishingly unlikely, not a hard failure) rather than
151
+ // throwing, since a cache-freshness check is never the place to
152
+ // surface a real error - the ordinary render path below still will.
153
+ export function getPageMtimeMs(config, relativePath) {
154
+ try {
155
+ return statSync(sanitisePath(config.contentRoot, relativePath)).mtimeMs;
156
+ }
157
+ catch {
158
+ return null;
159
+ }
160
+ }
161
+ // A menu edit affects every page's rendered nav, not just one page, so
162
+ // the cache's freshness check needs one value covering all menus
163
+ // together rather than per-page tracking. The max mtime across every
164
+ // menu file serves that - any single menu changing bumps it. 0 (never
165
+ // stale relative to anything) when there are no menus at all, matching
166
+ // listFilesRecursively's own "missing directory returns []" behaviour.
167
+ export function getMenusMtimeMs(config) {
168
+ let max = 0;
169
+ for (const relativePath of listFilesRecursively(config.menusRoot, config.menusRoot, '.json')) {
170
+ try {
171
+ const { mtimeMs } = statSync(sanitisePath(config.menusRoot, relativePath));
172
+ if (mtimeMs > max) {
173
+ max = mtimeMs;
174
+ }
175
+ }
176
+ catch {
177
+ // Vanished between the directory walk and this stat (e.g. a
178
+ // concurrent delete) - doesn't contribute, not fatal.
179
+ }
180
+ }
181
+ return max;
182
+ }
@@ -0,0 +1,5 @@
1
+ import type { FastifyPluginAsync } from 'fastify';
2
+ export interface AdminRedirectRouteOptions {
3
+ adminBaseUrl: string;
4
+ }
5
+ export declare const adminRedirectRoutes: FastifyPluginAsync<AdminRedirectRouteOptions>;
@@ -0,0 +1,26 @@
1
+ import { NO_AUTH_ROUTE_RATE_LIMIT } from "../services/rate-limit-config.js";
2
+ // Only ever registered by server.ts when serverConfig.adminBaseUrl is
3
+ // actually set - that's what makes this genuinely opt-in, not a
4
+ // reserved namespace like /media or /assets. A site that hasn't
5
+ // configured this can still use "admin" as an ordinary page path,
6
+ // same reasoning as GET /search.json over reserving all of /search.
7
+ //
8
+ // Redirects to adminBaseUrl with the requesting host appended as
9
+ // ?site=, never a deep link with a site ID baked in - keeps this
10
+ // agent completely decoupled from any particular admin app's own URL
11
+ // scheme, so a self-hoster running a different admin frontend just
12
+ // points adminBaseUrl at it. request.host (not request.hostname,
13
+ // which strips the port - same reasoning sitemap.ts already
14
+ // documents) so the admin side can match it directly against
15
+ // new URL(registeredSiteUrl).host.
16
+ //
17
+ // Deliberately unauthenticated: this is a discovery aid an operator
18
+ // (or anyone) can hit before they have a token, same class of
19
+ // zero-credential route as GET /v1/capabilities and GET /search.json.
20
+ export const adminRedirectRoutes = async (fastify, opts) => {
21
+ fastify.get('/admin', { config: NO_AUTH_ROUTE_RATE_LIMIT }, async (request, reply) => {
22
+ const target = new URL(opts.adminBaseUrl);
23
+ target.searchParams.set('site', request.host);
24
+ reply.code(302).header('location', target.toString()).send();
25
+ });
26
+ };
@@ -1,7 +1,7 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { CURRENT_SCHEMA_VERSION } from "../migrations/index.js";
4
- import { CAPABILITIES_RATE_LIMIT } from "../services/rate-limit-config.js";
4
+ import { NO_AUTH_ROUTE_RATE_LIMIT } from "../services/rate-limit-config.js";
5
5
  import { DRIVER_NAME } from "../search/drivers/node-sqlite-driver.js";
6
6
  // The agent's own bundled package.json, not site data - the same
7
7
  // import.meta.dirname-relative pattern already established in
@@ -12,7 +12,7 @@ function readAgentVersion() {
12
12
  return packageJson.version ?? '0.0.0';
13
13
  }
14
14
  export const capabilitiesRoutes = async (fastify, opts) => {
15
- fastify.get('/capabilities', { config: CAPABILITIES_RATE_LIMIT }, async () => ({
15
+ fastify.get('/capabilities', { config: NO_AUTH_ROUTE_RATE_LIMIT }, async () => ({
16
16
  agentVersion: readAgentVersion(),
17
17
  contentSchemaVersion: CURRENT_SCHEMA_VERSION,
18
18
  sqliteDriver: DRIVER_NAME,
@@ -25,9 +25,15 @@ async function handleMediaRequest(request, reply, config) {
25
25
  // <base href> fix), making genuinely cross-origin browser requests
26
26
  // for what looks like a same-origin path. Consistent with this
27
27
  // route already being deliberately unauthenticated and public.
28
+ //
29
+ // Cache-Control: immutable, a full year - safe unconditionally
30
+ // because filenames are content-addressed (a hash of the file's own
31
+ // bytes, see media/filename.ts): a given URL's content can never
32
+ // change, so there is no invalidation case to ever design for here.
28
33
  reply
29
34
  .header('X-Content-Type-Options', 'nosniff')
30
35
  .header('Access-Control-Allow-Origin', '*')
36
+ .header('Cache-Control', 'public, max-age=31536000, immutable')
31
37
  .type(mimeTypeFor(relativePath))
32
38
  .send(bytes);
33
39
  }
@@ -3,7 +3,6 @@ import { PageRenderError, parsePageContent, renderLoadedPage } from "../renderer
3
3
  import { GitShowError, readFileAtRevision } from "../services/git-history.js";
4
4
  import { isValidGitRef } from "../services/git.js";
5
5
  import { PathSafetyError } from "../services/path-safety.js";
6
- import { isBlogUrl, urlToPostPath } from "../services/post-urls.js";
7
6
  import { requireScope } from "../services/token-auth.js";
8
7
  import { urlToPagePath } from "../services/urls.js";
9
8
  // A dedicated top-level route rather than nesting under /preview/*
@@ -20,14 +19,11 @@ import { urlToPagePath } from "../services/urls.js";
20
19
  // readFileAtRevision resolves paths relative to config.siteRoot, not
21
20
  // config.contentRoot (confirmed by git-history.ts/git.test.ts's own
22
21
  // "content/pages/about.json" usage) - so unlike preview.ts's
23
- // toRenderPath/toPostsRenderPath (contentRoot-relative), the path
24
- // built here is prefixed with "content/".
22
+ // toRenderPath (contentRoot-relative), the path built here is prefixed
23
+ // with "content/".
25
24
  function toRevisionPath(pagesRelativePath) {
26
25
  return join('content', 'pages', pagesRelativePath);
27
26
  }
28
- function toPostsRevisionPath(postsRelativePath) {
29
- return join('content', 'posts', postsRelativePath);
30
- }
31
27
  async function handlePreviewRevisionRequest(request, reply, config, themeTemplates, layouts, engine) {
32
28
  const { ref } = request.params;
33
29
  const url = `/${request.params['*']}`;
@@ -36,19 +32,7 @@ async function handlePreviewRevisionRequest(request, reply, config, themeTemplat
36
32
  return;
37
33
  }
38
34
  try {
39
- const isPost = isBlogUrl(url);
40
- let repoRelativePath;
41
- if (isPost) {
42
- const relativePath = urlToPostPath(url);
43
- if (relativePath === null) {
44
- reply.code(404).send({ statusCode: 404, error: 'Not Found', message: `No page at "${url}"` });
45
- return;
46
- }
47
- repoRelativePath = toPostsRevisionPath(relativePath);
48
- }
49
- else {
50
- repoRelativePath = toRevisionPath(urlToPagePath(url));
51
- }
35
+ const repoRelativePath = toRevisionPath(urlToPagePath(url));
52
36
  const raw = readFileAtRevision(config, ref, repoRelativePath);
53
37
  const page = parsePageContent(raw.toString('utf-8'));
54
38
  const html = await renderLoadedPage(page, config, themeTemplates, layouts, engine);
@@ -1,7 +1,6 @@
1
1
  import { join } from 'node:path';
2
2
  import { PageRenderError, renderPage } from "../renderer/render-page.js";
3
3
  import { PathSafetyError } from "../services/path-safety.js";
4
- import { isBlogUrl, urlToPostPath } from "../services/post-urls.js";
5
4
  import { requireScope } from "../services/token-auth.js";
6
5
  import { urlToPagePath } from "../services/urls.js";
7
6
  // Same pagesRoot/contentRoot seam as public.ts - see that file's
@@ -11,26 +10,9 @@ import { urlToPagePath } from "../services/urls.js";
11
10
  function toRenderPath(pagesRelativePath) {
12
11
  return join('pages', pagesRelativePath);
13
12
  }
14
- // Same seam as toRenderPath above, for posts.
15
- function toPostsRenderPath(postsRelativePath) {
16
- return join('posts', postsRelativePath);
17
- }
18
13
  async function handlePreviewRequest(request, reply, config, themeTemplates, layouts, engine) {
19
14
  const url = `/${request.params['*']}`;
20
15
  try {
21
- // /blog is a permanently reserved namespace, same as public.ts -
22
- // no redirects.json fallback here either way (preview never
23
- // consults it, per this file's own established comment above).
24
- if (isBlogUrl(url)) {
25
- const relativePath = urlToPostPath(url);
26
- if (relativePath === null) {
27
- reply.code(404).send({ statusCode: 404, error: 'Not Found', message: `No page at "${url}"` });
28
- return;
29
- }
30
- const html = await renderPage(config, themeTemplates, layouts, engine, toPostsRenderPath(relativePath), 'preview');
31
- reply.type('text/html; charset=utf-8').send(html);
32
- return;
33
- }
34
16
  const relativePath = urlToPagePath(url);
35
17
  const html = await renderPage(config, themeTemplates, layouts, engine, toRenderPath(relativePath), 'preview');
36
18
  reply.type('text/html; charset=utf-8').send(html);
@@ -1,11 +1,13 @@
1
1
  import type { FastifyPluginAsync } from 'fastify';
2
2
  import type { Liquid } from 'liquidjs';
3
3
  import type { SiteConfig } from '../config.ts';
4
+ import type { RenderCache } from '../renderer/render-cache.ts';
4
5
  import type { ThemeTemplates } from '../renderer/theme-templates.ts';
5
6
  export interface PublicRouteOptions {
6
7
  config: SiteConfig;
7
8
  themeTemplates: ThemeTemplates;
8
9
  layouts: Record<string, string>;
9
10
  engine: Liquid;
11
+ renderCache: RenderCache;
10
12
  }
11
13
  export declare const publicRoutes: FastifyPluginAsync<PublicRouteOptions>;
@@ -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
  }
@@ -34,15 +33,10 @@ function buildSitemapUrls(config) {
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;