@o-a/cms-agent 0.1.6 → 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.
- 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/mint-token-cli.js +0 -0
- 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 +3 -9
- 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 +32 -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/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/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
|
-
|
|
33
|
+
const renderCache = createRenderCache();
|
|
34
|
+
return { config, themeSchemas, themeTemplates, layouts, pageTemplates, engine, renderCache };
|
|
33
35
|
}
|
package/dist/config.d.ts
CHANGED
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'),
|
package/dist/create-site/cli.js
CHANGED
|
File without changes
|
|
File without changes
|
package/dist/media/filename.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/dist/migrations/index.js
CHANGED
|
@@ -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 =
|
|
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;
|
|
@@ -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,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 {
|
|
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:
|
|
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
|
|
24
|
-
//
|
|
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
|
|
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);
|
package/dist/routes/preview.js
CHANGED
|
@@ -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);
|
package/dist/routes/public.d.ts
CHANGED
|
@@ -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>;
|
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
|
}
|
|
@@ -34,15 +33,10 @@ function buildSitemapUrls(config) {
|
|
|
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;
|