@minamocms/framework 0.0.0-stage → 0.10.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +240 -0
- package/LICENSE +105 -0
- package/README.md +133 -2
- package/dist/boot.d.ts +19 -0
- package/dist/boot.js +22 -0
- package/dist/config.d.ts +18 -0
- package/dist/config.js +44 -0
- package/dist/create-site/cli.d.ts +2 -0
- package/dist/create-site/cli.js +31 -0
- package/dist/create-site/generate-site.d.ts +12 -0
- package/dist/create-site/generate-site.js +168 -0
- package/dist/create-site/mint-token-cli.d.ts +2 -0
- package/dist/create-site/mint-token-cli.js +26 -0
- package/dist/create-site/mint-token.d.ts +7 -0
- package/dist/create-site/mint-token.js +49 -0
- package/dist/create-site/template/AGENTS.md +473 -0
- package/dist/create-site/template/content/menus/footerCompany.json +9 -0
- package/dist/create-site/template/content/menus/footerProduct.json +9 -0
- package/dist/create-site/template/content/menus/footerResources.json +9 -0
- package/dist/create-site/template/content/menus/main.json +9 -0
- package/dist/create-site/template/content/pages/404.json +23 -0
- package/dist/create-site/template/content/pages/about/careers.json +22 -0
- package/dist/create-site/template/content/pages/about/team.json +22 -0
- package/dist/create-site/template/content/pages/about.json +22 -0
- package/dist/create-site/template/content/pages/docs/deployment.json +22 -0
- package/dist/create-site/template/content/pages/docs/getting-started/quickstart.json +22 -0
- package/dist/create-site/template/content/pages/docs/getting-started.json +24 -0
- package/dist/create-site/template/content/pages/docs.json +22 -0
- package/dist/create-site/template/content/pages/index.json +182 -0
- package/dist/create-site/template/content/redirects.json +4 -0
- package/dist/create-site/template/dockerignore +6 -0
- package/dist/create-site/template/gitignore +11 -0
- package/dist/create-site/template/theme/assets/site.js +49 -0
- package/dist/create-site/template/theme/assets/style.css +1223 -0
- package/dist/create-site/template/theme/blocks/button.liquid +21 -0
- package/dist/create-site/template/theme/blocks/comparison-row.liquid +19 -0
- package/dist/create-site/template/theme/blocks/faq-item.liquid +22 -0
- package/dist/create-site/template/theme/blocks/feature-card.liquid +23 -0
- package/dist/create-site/template/theme/blocks/logo-mark.liquid +13 -0
- package/dist/create-site/template/theme/blocks/pricing-tier.liquid +42 -0
- package/dist/create-site/template/theme/blocks/step-card.liquid +19 -0
- package/dist/create-site/template/theme/blocks/testimonial-card.liquid +25 -0
- package/dist/create-site/template/theme/config/site_settings.json +11 -0
- package/dist/create-site/template/theme/layouts/theme.liquid +102 -0
- package/dist/create-site/template/theme/sections/comparison.liquid +41 -0
- package/dist/create-site/template/theme/sections/cta-banner.liquid +36 -0
- package/dist/create-site/template/theme/sections/faq.liquid +27 -0
- package/dist/create-site/template/theme/sections/feature-grid.liquid +27 -0
- package/dist/create-site/template/theme/sections/hero.liquid +48 -0
- package/dist/create-site/template/theme/sections/how-it-works.liquid +27 -0
- package/dist/create-site/template/theme/sections/pricing.liquid +27 -0
- package/dist/create-site/template/theme/sections/social-proof.liquid +21 -0
- package/dist/create-site/template/theme/sections/testimonials.liquid +27 -0
- package/dist/create-site/template/theme/snippets/icon.liquid +24 -0
- package/dist/create-site/template/theme/snippets/logo.liquid +7 -0
- package/dist/create-site/template/theme/snippets/responsive-media.liquid +107 -0
- package/dist/create-site/template/theme/snippets/site-name.liquid +1 -0
- package/dist/create-site/template/vhost/Dockerfile +33 -0
- package/dist/create-site/template/vhost/docker-entrypoint.sh +58 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +4 -0
- package/dist/media/drivers/driver.d.ts +11 -0
- package/dist/media/drivers/driver.js +1 -0
- package/dist/media/drivers/local-fs-driver.d.ts +3 -0
- package/dist/media/drivers/local-fs-driver.js +94 -0
- package/dist/media/filename.d.ts +2 -0
- package/dist/media/filename.js +54 -0
- package/dist/media/manage-media.d.ts +19 -0
- package/dist/media/manage-media.js +45 -0
- package/dist/media/seed-media-cli.d.ts +2 -0
- package/dist/media/seed-media-cli.js +22 -0
- package/dist/media/seed-media.d.ts +11 -0
- package/dist/media/seed-media.js +76 -0
- package/dist/migrations/index.d.ts +3 -0
- package/dist/migrations/index.js +83 -0
- package/dist/pull-site/cli.d.ts +2 -0
- package/dist/pull-site/cli.js +78 -0
- package/dist/pull-site/prompt-token.d.ts +16 -0
- package/dist/pull-site/prompt-token.js +69 -0
- package/dist/pull-site/prompts.d.ts +22 -0
- package/dist/pull-site/prompts.js +127 -0
- package/dist/pull-site/pull-site.d.ts +23 -0
- package/dist/pull-site/pull-site.js +211 -0
- package/dist/renderer/engine.d.ts +2 -0
- package/dist/renderer/engine.js +48 -0
- package/dist/renderer/render-cache.d.ts +11 -0
- package/dist/renderer/render-cache.js +11 -0
- package/dist/renderer/render-page.d.ts +34 -0
- package/dist/renderer/render-page.js +209 -0
- package/dist/renderer/theme-templates.d.ts +7 -0
- package/dist/renderer/theme-templates.js +82 -0
- package/dist/routes/admin-redirect.d.ts +5 -0
- package/dist/routes/admin-redirect.js +26 -0
- package/dist/routes/assets.d.ts +6 -0
- package/dist/routes/assets.js +30 -0
- package/dist/routes/batch.d.ts +10 -0
- package/dist/routes/batch.js +148 -0
- package/dist/routes/capabilities.d.ts +6 -0
- package/dist/routes/capabilities.js +27 -0
- package/dist/routes/content.d.ts +8 -0
- package/dist/routes/content.js +169 -0
- package/dist/routes/deleted-pages.d.ts +8 -0
- package/dist/routes/deleted-pages.js +9 -0
- package/dist/routes/drafts.d.ts +10 -0
- package/dist/routes/drafts.js +73 -0
- package/dist/routes/git.d.ts +8 -0
- package/dist/routes/git.js +158 -0
- package/dist/routes/index.d.ts +12 -0
- package/dist/routes/index.js +86 -0
- package/dist/routes/links.d.ts +8 -0
- package/dist/routes/links.js +18 -0
- package/dist/routes/media-public.d.ts +11 -0
- package/dist/routes/media-public.js +125 -0
- package/dist/routes/media.d.ts +9 -0
- package/dist/routes/media.js +80 -0
- package/dist/routes/menus.d.ts +8 -0
- package/dist/routes/menus.js +148 -0
- package/dist/routes/preview-revision.d.ts +10 -0
- package/dist/routes/preview-revision.js +96 -0
- package/dist/routes/preview.d.ts +10 -0
- package/dist/routes/preview.js +56 -0
- package/dist/routes/public.d.ts +10 -0
- package/dist/routes/public.js +123 -0
- package/dist/routes/publish.d.ts +10 -0
- package/dist/routes/publish.js +139 -0
- package/dist/routes/redirects.d.ts +8 -0
- package/dist/routes/redirects.js +132 -0
- package/dist/routes/search-public.d.ts +6 -0
- package/dist/routes/search-public.js +104 -0
- package/dist/routes/search.d.ts +8 -0
- package/dist/routes/search.js +15 -0
- package/dist/routes/settings.d.ts +10 -0
- package/dist/routes/settings.js +68 -0
- package/dist/routes/sitemap.d.ts +7 -0
- package/dist/routes/sitemap.js +79 -0
- package/dist/routes/theme.d.ts +10 -0
- package/dist/routes/theme.js +116 -0
- package/dist/schemas/instance.schema.json +17 -0
- package/dist/schemas/menu.schema.json +24 -0
- package/dist/schemas/page.schema.json +26 -0
- package/dist/schemas/redirects.schema.json +24 -0
- package/dist/search/drivers/driver.d.ts +9 -0
- package/dist/search/drivers/driver.js +1 -0
- package/dist/search/drivers/node-sqlite-driver.d.ts +7 -0
- package/dist/search/drivers/node-sqlite-driver.js +19 -0
- package/dist/search/query-content.d.ts +32 -0
- package/dist/search/query-content.js +207 -0
- package/dist/search/query-index.d.ts +5 -0
- package/dist/search/query-index.js +21 -0
- package/dist/search/rebuild-index.d.ts +3 -0
- package/dist/search/rebuild-index.js +320 -0
- package/dist/server-config.d.ts +23 -0
- package/dist/server-config.js +237 -0
- package/dist/server.d.ts +13 -0
- package/dist/server.js +235 -0
- package/dist/services/batch.d.ts +35 -0
- package/dist/services/batch.js +145 -0
- package/dist/services/checkpoint.d.ts +4 -0
- package/dist/services/checkpoint.js +45 -0
- package/dist/services/content-read.d.ts +27 -0
- package/dist/services/content-read.js +151 -0
- package/dist/services/delete-content.d.ts +12 -0
- package/dist/services/delete-content.js +166 -0
- package/dist/services/deleted-pages.d.ts +11 -0
- package/dist/services/deleted-pages.js +83 -0
- package/dist/services/dev-tunnel.d.ts +5 -0
- package/dist/services/dev-tunnel.js +5 -0
- package/dist/services/drafts.d.ts +16 -0
- package/dist/services/drafts.js +164 -0
- package/dist/services/etag.d.ts +2 -0
- package/dist/services/etag.js +30 -0
- package/dist/services/fs-walk.d.ts +1 -0
- package/dist/services/fs-walk.js +26 -0
- package/dist/services/git-commit.d.ts +3 -0
- package/dist/services/git-commit.js +12 -0
- package/dist/services/git-history.d.ts +28 -0
- package/dist/services/git-history.js +131 -0
- package/dist/services/git-revert.d.ts +12 -0
- package/dist/services/git-revert.js +140 -0
- package/dist/services/git.d.ts +16 -0
- package/dist/services/git.js +127 -0
- package/dist/services/interval-job.d.ts +4 -0
- package/dist/services/interval-job.js +9 -0
- package/dist/services/ip-allowlist.d.ts +7 -0
- package/dist/services/ip-allowlist.js +31 -0
- package/dist/services/links.d.ts +22 -0
- package/dist/services/links.js +169 -0
- package/dist/services/manage-menus.d.ts +15 -0
- package/dist/services/manage-menus.js +138 -0
- package/dist/services/manage-redirects.d.ts +25 -0
- package/dist/services/manage-redirects.js +156 -0
- package/dist/services/menu-references.d.ts +3 -0
- package/dist/services/menu-references.js +38 -0
- package/dist/services/menus.d.ts +9 -0
- package/dist/services/menus.js +43 -0
- package/dist/services/migration-runner.d.ts +16 -0
- package/dist/services/migration-runner.js +169 -0
- package/dist/services/mime-types.d.ts +3 -0
- package/dist/services/mime-types.js +43 -0
- package/dist/services/move.d.ts +16 -0
- package/dist/services/move.js +236 -0
- package/dist/services/path-safety.d.ts +6 -0
- package/dist/services/path-safety.js +112 -0
- package/dist/services/pid-file.d.ts +13 -0
- package/dist/services/pid-file.js +54 -0
- package/dist/services/post-urls.d.ts +3 -0
- package/dist/services/post-urls.js +27 -0
- package/dist/services/prepared-operation.d.ts +4 -0
- package/dist/services/prepared-operation.js +1 -0
- package/dist/services/publish.d.ts +15 -0
- package/dist/services/publish.js +313 -0
- package/dist/services/rate-limit-config.d.ts +9 -0
- package/dist/services/rate-limit-config.js +22 -0
- package/dist/services/redirects.d.ts +27 -0
- package/dist/services/redirects.js +175 -0
- package/dist/services/reindex-on-write.d.ts +3 -0
- package/dist/services/reindex-on-write.js +30 -0
- package/dist/services/resolve-blog-url.d.ts +11 -0
- package/dist/services/resolve-blog-url.js +31 -0
- package/dist/services/resolve-url.d.ts +11 -0
- package/dist/services/resolve-url.js +20 -0
- package/dist/services/site-settings.d.ts +28 -0
- package/dist/services/site-settings.js +116 -0
- package/dist/services/startup-checks.d.ts +10 -0
- package/dist/services/startup-checks.js +81 -0
- package/dist/services/static-file.d.ts +7 -0
- package/dist/services/static-file.js +37 -0
- package/dist/services/theme-component-file.d.ts +6 -0
- package/dist/services/theme-component-file.js +39 -0
- package/dist/services/theme-files.d.ts +33 -0
- package/dist/services/theme-files.js +148 -0
- package/dist/services/theme-page-templates.d.ts +7 -0
- package/dist/services/theme-page-templates.js +53 -0
- package/dist/services/theme-schemas.d.ts +2 -0
- package/dist/services/theme-schemas.js +137 -0
- package/dist/services/token-auth.d.ts +10 -0
- package/dist/services/token-auth.js +64 -0
- package/dist/services/urls.d.ts +2 -0
- package/dist/services/urls.js +20 -0
- package/dist/services/validation.d.ts +26 -0
- package/dist/services/validation.js +195 -0
- package/dist/services/write-queue.d.ts +1 -0
- package/dist/services/write-queue.js +14 -0
- package/dist/site-check/cli.d.ts +2 -0
- package/dist/site-check/cli.js +48 -0
- package/dist/site-check/run-check.d.ts +12 -0
- package/dist/site-check/run-check.js +188 -0
- package/dist/site-sync/cli-helpers.d.ts +23 -0
- package/dist/site-sync/cli-helpers.js +75 -0
- package/dist/site-sync/deploy.d.ts +36 -0
- package/dist/site-sync/deploy.js +124 -0
- package/dist/site-sync/live-content.d.ts +30 -0
- package/dist/site-sync/live-content.js +68 -0
- package/dist/site-sync/prompts.d.ts +30 -0
- package/dist/site-sync/prompts.js +237 -0
- package/dist/site-sync/pull-cli.d.ts +2 -0
- package/dist/site-sync/pull-cli.js +75 -0
- package/dist/site-sync/pull-site.d.ts +28 -0
- package/dist/site-sync/pull-site.js +171 -0
- package/dist/site-sync/push-cli.d.ts +2 -0
- package/dist/site-sync/push-cli.js +268 -0
- package/dist/site-sync/push-site.d.ts +96 -0
- package/dist/site-sync/push-site.js +376 -0
- package/dist/site-sync/remote-site.d.ts +38 -0
- package/dist/site-sync/remote-site.js +122 -0
- package/dist/site-sync/sync-record.d.ts +26 -0
- package/dist/site-sync/sync-record.js +76 -0
- package/dist/site-sync/theme-sync.d.ts +34 -0
- package/dist/site-sync/theme-sync.js +128 -0
- package/dist/stop-site/cli.d.ts +2 -0
- package/dist/stop-site/cli.js +28 -0
- package/dist/stop-site/stop-site.d.ts +26 -0
- package/dist/stop-site/stop-site.js +56 -0
- package/dist/theme-state.d.ts +20 -0
- package/dist/theme-state.js +33 -0
- package/dist/upgrade/changelog.d.ts +8 -0
- package/dist/upgrade/changelog.js +46 -0
- package/dist/upgrade/finish.d.ts +9 -0
- package/dist/upgrade/finish.js +30 -0
- package/dist/upgrade/owned-files.d.ts +11 -0
- package/dist/upgrade/owned-files.js +42 -0
- package/dist/upgrade/package-names.d.ts +4 -0
- package/dist/upgrade/package-names.js +26 -0
- package/dist/upgrade/upgrade-cli.d.ts +2 -0
- package/dist/upgrade/upgrade-cli.js +170 -0
- package/package.json +64 -4
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { readContentFile } from "../services/content-read.js";
|
|
3
|
+
import { listFilesRecursively } from "../services/fs-walk.js";
|
|
4
|
+
import { pagePathToUrl } from "../services/urls.js";
|
|
5
|
+
// Reads through readContentFile/listFilesRecursively (both already
|
|
6
|
+
// gated behind sanitisePath/agent-configured roots - see their own
|
|
7
|
+
// files) rather than touching fs directly, so this route needs no
|
|
8
|
+
// allowlist entry of its own (docs/phase-1-checklist.md Group B).
|
|
9
|
+
function isPublished(contentRoot, relativePath) {
|
|
10
|
+
try {
|
|
11
|
+
const { bytes } = readContentFile(contentRoot, join('pages', relativePath));
|
|
12
|
+
const parsed = JSON.parse(bytes.toString('utf-8'));
|
|
13
|
+
return parsed.published === true;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
// Unreadable, malformed, or vanished between listing and reading -
|
|
17
|
+
// never worth failing the whole sitemap over one bad entry.
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
// Generated fresh on every request, never a saved file - matches this
|
|
22
|
+
// project's own standing philosophy for exactly this class of problem
|
|
23
|
+
// (the search index is explicitly "a derived, disposable index...
|
|
24
|
+
// never authoritative", see cms-build-plan.md). A saved sitemap would
|
|
25
|
+
// go stale the moment anything is published or unpublished; this
|
|
26
|
+
// can't.
|
|
27
|
+
// Exported for site-check/run-check.ts's own reuse - it needs the
|
|
28
|
+
// identical "every published page's own URL" walk this route already
|
|
29
|
+
// does, and duplicating it would be the exact kind of drift this
|
|
30
|
+
// codebase avoids elsewhere (see slugify.ts's own "second use
|
|
31
|
+
// justifies the abstraction" precedent, cited directly in this
|
|
32
|
+
// project's own admin sibling repo).
|
|
33
|
+
export function buildSitemapUrls(config) {
|
|
34
|
+
const urls = [];
|
|
35
|
+
for (const relativePath of listFilesRecursively(config.pagesRoot, config.pagesRoot, '.json')) {
|
|
36
|
+
// The 404 page must never be listed as a real crawlable URL,
|
|
37
|
+
// regardless of its own published flag - it's a fallback
|
|
38
|
+
// convention (docs/guide-content-authoring.md), not real content.
|
|
39
|
+
if (relativePath === '404.json') {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (isPublished(config.contentRoot, relativePath)) {
|
|
43
|
+
urls.push(pagePathToUrl(relativePath));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return urls;
|
|
47
|
+
}
|
|
48
|
+
function escapeXml(value) {
|
|
49
|
+
return value
|
|
50
|
+
.replace(/&/g, '&')
|
|
51
|
+
.replace(/</g, '<')
|
|
52
|
+
.replace(/>/g, '>')
|
|
53
|
+
.replace(/"/g, '"')
|
|
54
|
+
.replace(/'/g, ''');
|
|
55
|
+
}
|
|
56
|
+
async function handleSitemapRequest(request, reply, config) {
|
|
57
|
+
// request.protocol/request.host already respect trustProxy (wired
|
|
58
|
+
// natively into the Fastify() constructor, server.ts) - no separate
|
|
59
|
+
// config field needed for this. request.host, not request.hostname:
|
|
60
|
+
// the latter unconditionally strips the port (confirmed against
|
|
61
|
+
// Fastify's own source), which silently produces a wrong origin for
|
|
62
|
+
// any site not on a standard 80/443 port - request.host is the raw
|
|
63
|
+
// Host header (or the trustProxy-forwarded equivalent), port
|
|
64
|
+
// included when present.
|
|
65
|
+
const origin = `${request.protocol}://${request.host}`;
|
|
66
|
+
const urls = buildSitemapUrls(config);
|
|
67
|
+
const entries = urls.map((url) => ` <url><loc>${escapeXml(origin + url)}</loc></url>`).join('\n');
|
|
68
|
+
const xml = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${entries}\n</urlset>\n`;
|
|
69
|
+
reply.type('application/xml; charset=utf-8').send(xml);
|
|
70
|
+
}
|
|
71
|
+
// Deliberately and permanently unauthenticated, same reasoning as
|
|
72
|
+
// assetsRoutes/mediaPublicRoutes: a sitemap must be fetchable by any
|
|
73
|
+
// crawler without a token. This exact path always wins over a
|
|
74
|
+
// same-named static file at theme/root/sitemap.xml, if a developer
|
|
75
|
+
// ever creates one - a dynamic route is a more specific match than
|
|
76
|
+
// the root-mirror check inside publicRoutes' own catch-all.
|
|
77
|
+
export const sitemapRoutes = async (fastify, opts) => {
|
|
78
|
+
fastify.get('/sitemap.xml', async (request, reply) => handleSitemapRequest(request, reply, opts.config));
|
|
79
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { FastifyPluginAsync } from 'fastify';
|
|
2
|
+
import type { SiteConfig } from '../config.ts';
|
|
3
|
+
import type { ThemeState } from '../theme-state.ts';
|
|
4
|
+
import type { TokenEntry } from '../server-config.ts';
|
|
5
|
+
export interface ThemeRouteOptions {
|
|
6
|
+
config: SiteConfig;
|
|
7
|
+
theme: ThemeState;
|
|
8
|
+
tokens: TokenEntry[];
|
|
9
|
+
}
|
|
10
|
+
export declare const themeRoutes: FastifyPluginAsync<ThemeRouteOptions>;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { isValidCommitAuthor } from "../services/git.js";
|
|
2
|
+
import { PathSafetyError } from "../services/path-safety.js";
|
|
3
|
+
import { WRITE_ROUTE_RATE_LIMIT } from "../services/rate-limit-config.js";
|
|
4
|
+
import { isValidThemePath, listThemeFiles, pushTheme, readThemeFile, ThemePushError, } from "../services/theme-files.js";
|
|
5
|
+
import { requireScope } from "../services/token-auth.js";
|
|
6
|
+
// A whole theme in one request, fonts and images included, sent as
|
|
7
|
+
// base64 - far past Fastify's 1 MB default, so this route sets its own.
|
|
8
|
+
const THEME_PUSH_BODY_LIMIT = 100 * 1024 * 1024;
|
|
9
|
+
function parseThemePushBody(body) {
|
|
10
|
+
if (typeof body !== 'object' || body === null) {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
const { writes, deletes, message, author } = body;
|
|
14
|
+
if (!Array.isArray(writes) || !Array.isArray(deletes) || typeof message !== 'string' || message === '') {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
if (!isValidCommitAuthor(author)) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
const parsedWrites = [];
|
|
21
|
+
for (const raw of writes) {
|
|
22
|
+
const { path, content, expected } = (raw ?? {});
|
|
23
|
+
if (typeof path !== 'string' || typeof content !== 'string' || (expected !== null && typeof expected !== 'string')) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
parsedWrites.push({ path, bytes: Buffer.from(content, 'base64'), expected });
|
|
27
|
+
}
|
|
28
|
+
const parsedDeletes = [];
|
|
29
|
+
for (const raw of deletes) {
|
|
30
|
+
const { path, expected } = (raw ?? {});
|
|
31
|
+
if (typeof path !== 'string' || typeof expected !== 'string') {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
parsedDeletes.push({ path, expected });
|
|
35
|
+
}
|
|
36
|
+
if (parsedWrites.length === 0 && parsedDeletes.length === 0) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
return { writes: parsedWrites, deletes: parsedDeletes, message, author };
|
|
40
|
+
}
|
|
41
|
+
async function handleThemePush(request, reply, opts) {
|
|
42
|
+
const parsed = parseThemePushBody(request.body);
|
|
43
|
+
if (!parsed) {
|
|
44
|
+
reply.code(400).send({
|
|
45
|
+
statusCode: 400,
|
|
46
|
+
error: 'Bad Request',
|
|
47
|
+
message: 'Expected { writes: { path, content (base64), expected (hash or null) }[], deletes: { path, expected }[], message, author: { name, email } }, with at least one write or delete',
|
|
48
|
+
});
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
const result = await pushTheme(opts.config, opts.theme, parsed.writes, parsed.deletes, parsed.message, parsed.author);
|
|
53
|
+
reply.send({ ok: true, ...result });
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
if (error instanceof PathSafetyError || (error instanceof ThemePushError && error.reason === 'invalid-path')) {
|
|
57
|
+
reply.code(400).send({ statusCode: 400, error: 'Bad Request', message: error.message });
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (error instanceof ThemePushError && error.reason === 'invalid-liquid') {
|
|
61
|
+
reply.code(400).send({ statusCode: 400, error: 'Bad Request', message: error.message });
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (error instanceof ThemePushError && error.reason === 'conflict') {
|
|
65
|
+
reply.code(409).send({ statusCode: 409, error: 'Conflict', message: error.message });
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// Group I: the only place a caller can learn what section/block types
|
|
72
|
+
// the active theme supports and what settings each one accepts - the
|
|
73
|
+
// admin needs this to build a schema-driven editing UI instead of a
|
|
74
|
+
// hardcoded one. themeSchemas is already fully computed at boot
|
|
75
|
+
// (loadThemeSchemas) purely for internal write-time validation; this
|
|
76
|
+
// route just serialises the same object out, verbatim, read-only.
|
|
77
|
+
export const themeRoutes = async (fastify, opts) => {
|
|
78
|
+
fastify.get('/theme/schemas', { preHandler: requireScope(opts.tokens, 'content') }, async () => opts.theme.current.themeSchemas);
|
|
79
|
+
// Group Q: lets the admin offer a template picker when creating a new
|
|
80
|
+
// page. No dedicated "create from template" endpoint - pageTemplates
|
|
81
|
+
// is already fully computed at boot (loadPageTemplates), same
|
|
82
|
+
// verbatim read-only serialisation as /theme/schemas above; actually
|
|
83
|
+
// creating a page from one is just the admin doing a normal
|
|
84
|
+
// PUT /v1/drafts/* with the chosen template's own content as the body.
|
|
85
|
+
fastify.get('/theme/page-templates', { preHandler: requireScope(opts.tokens, 'content') }, async () => ({ templates: opts.theme.current.pageTemplates }));
|
|
86
|
+
// The theme's own files, for npm run pull / push. All three need the
|
|
87
|
+
// "theme" scope: a token that can only edit content can't read or
|
|
88
|
+
// change the site's code.
|
|
89
|
+
fastify.get('/theme/files', { preHandler: requireScope(opts.tokens, 'theme') }, async () => listThemeFiles(opts.config));
|
|
90
|
+
fastify.get('/theme/files/*', { preHandler: requireScope(opts.tokens, 'theme') }, async (request, reply) => {
|
|
91
|
+
const path = request.params['*'];
|
|
92
|
+
let bytes = null;
|
|
93
|
+
try {
|
|
94
|
+
bytes = isValidThemePath(path) ? readThemeFile(opts.config, path) : null;
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
if (!(error instanceof PathSafetyError)) {
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (bytes === null) {
|
|
102
|
+
reply.code(404).send({ statusCode: 404, error: 'Not Found', message: `No theme file at "${path}"` });
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
reply.type('application/octet-stream').send(bytes);
|
|
106
|
+
});
|
|
107
|
+
// Writes and deletes theme files as one commit, then switches the
|
|
108
|
+
// running site to the new theme with no restart. Refused (409) if any
|
|
109
|
+
// file changed on the site since the client last saw it, and (400) if
|
|
110
|
+
// any Liquid template can't be parsed.
|
|
111
|
+
fastify.post('/theme/push', {
|
|
112
|
+
preHandler: requireScope(opts.tokens, 'theme'),
|
|
113
|
+
config: WRITE_ROUTE_RATE_LIMIT,
|
|
114
|
+
bodyLimit: THEME_PUSH_BODY_LIMIT,
|
|
115
|
+
}, async (request, reply) => handleThemePush(request, reply, opts));
|
|
116
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$id": "instance.schema.json",
|
|
3
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
4
|
+
"title": "Section or block instance",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"required": ["id", "type", "settings"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"id": { "type": "string", "minLength": 1 },
|
|
10
|
+
"type": { "type": "string", "minLength": 1, "pattern": "^[a-z0-9][a-z0-9-]*$" },
|
|
11
|
+
"settings": { "type": "object" },
|
|
12
|
+
"blocks": {
|
|
13
|
+
"type": "array",
|
|
14
|
+
"items": { "$ref": "#" }
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$id": "menu.schema.json",
|
|
3
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
4
|
+
"title": "Menu",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"required": ["schemaVersion", "items"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"schemaVersion": { "type": "integer", "minimum": 1 },
|
|
10
|
+
"name": { "type": "string", "minLength": 1 },
|
|
11
|
+
"items": {
|
|
12
|
+
"type": "array",
|
|
13
|
+
"items": {
|
|
14
|
+
"type": "object",
|
|
15
|
+
"additionalProperties": false,
|
|
16
|
+
"required": ["label", "url"],
|
|
17
|
+
"properties": {
|
|
18
|
+
"label": { "type": "string", "minLength": 1 },
|
|
19
|
+
"url": { "type": "string", "minLength": 1 }
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$id": "page.schema.json",
|
|
3
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
4
|
+
"title": "Page",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"required": ["schemaVersion", "name", "title", "type", "layout", "published", "sections"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"schemaVersion": { "type": "integer", "minimum": 1 },
|
|
10
|
+
"name": { "type": "string", "minLength": 1 },
|
|
11
|
+
"title": { "type": "string", "minLength": 1 },
|
|
12
|
+
"type": { "type": "string", "minLength": 1 },
|
|
13
|
+
"layout": { "type": "string", "minLength": 1 },
|
|
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
|
+
},
|
|
21
|
+
"sections": {
|
|
22
|
+
"type": "array",
|
|
23
|
+
"items": { "$ref": "instance.schema.json" }
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$id": "redirects.schema.json",
|
|
3
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
4
|
+
"title": "Redirects",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"required": ["schemaVersion", "entries"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"schemaVersion": { "type": "integer", "minimum": 1 },
|
|
10
|
+
"entries": {
|
|
11
|
+
"type": "array",
|
|
12
|
+
"items": {
|
|
13
|
+
"type": "object",
|
|
14
|
+
"additionalProperties": false,
|
|
15
|
+
"required": ["from", "to"],
|
|
16
|
+
"properties": {
|
|
17
|
+
"from": { "type": "string", "minLength": 1 },
|
|
18
|
+
"to": { "type": "string", "minLength": 1 },
|
|
19
|
+
"note": { "type": "string", "maxLength": 500 }
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { SearchDriver } from './driver.ts';
|
|
2
|
+
export declare const DRIVER_NAME = "node:sqlite";
|
|
3
|
+
export interface OpenDriverOptions {
|
|
4
|
+
readOnly?: boolean;
|
|
5
|
+
timeout?: number;
|
|
6
|
+
}
|
|
7
|
+
export declare function openNodeSqliteDriver(path: string, options?: OpenDriverOptions): SearchDriver;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
// A plain string constant, not the raw node:sqlite import itself - safe
|
|
3
|
+
// for the capabilities endpoint (Phase 2 Group A) to import without
|
|
4
|
+
// tripping the G5 grep test, and avoids hardcoding this name a second
|
|
5
|
+
// time as a literal that could drift from the actual active driver.
|
|
6
|
+
export const DRIVER_NAME = 'node:sqlite';
|
|
7
|
+
// The only file in the codebase allowed to import node:sqlite
|
|
8
|
+
// (checklist G5, enforced by a grep test in
|
|
9
|
+
// test/static/static-analysis.test.ts). DatabaseSync's own
|
|
10
|
+
// prepare()/exec()/close() already structurally match SearchDriver,
|
|
11
|
+
// so this is a thin adapter, not a reimplementation.
|
|
12
|
+
export function openNodeSqliteDriver(path, options = {}) {
|
|
13
|
+
const db = new DatabaseSync(path, options);
|
|
14
|
+
return {
|
|
15
|
+
exec: (sql) => db.exec(sql),
|
|
16
|
+
prepare: (sql) => db.prepare(sql),
|
|
17
|
+
close: () => db.close(),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { openNodeSqliteDriver } from "./drivers/node-sqlite-driver.js";
|
|
2
|
+
// Never queued: an in-flight query holding an open handle during a
|
|
3
|
+
// concurrent rebuild's unlink just keeps reading the pre-rebuild inode
|
|
4
|
+
// (stale but consistent, never torn) - queuing a read against the same
|
|
5
|
+
// queue as writes would only add latency for no correctness benefit.
|
|
6
|
+
export function queryIndex(searchIndexPath, term) {
|
|
7
|
+
const driver = openNodeSqliteDriver(searchIndexPath);
|
|
8
|
+
try {
|
|
9
|
+
const rows = driver.prepare('SELECT url, title FROM pages_fts WHERE pages_fts MATCH ?').all(term);
|
|
10
|
+
// node:sqlite returns rows as [Object: null prototype] instances;
|
|
11
|
+
// rebuilt here as plain objects so callers (and assert.deepEqual)
|
|
12
|
+
// never have to know that's a driver implementation detail.
|
|
13
|
+
return rows.map((row) => {
|
|
14
|
+
const { url, title } = row;
|
|
15
|
+
return { url, title };
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
finally {
|
|
19
|
+
driver.close();
|
|
20
|
+
}
|
|
21
|
+
}
|