@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.
Files changed (286) hide show
  1. package/CHANGELOG.md +240 -0
  2. package/LICENSE +105 -0
  3. package/README.md +133 -2
  4. package/dist/boot.d.ts +19 -0
  5. package/dist/boot.js +22 -0
  6. package/dist/config.d.ts +18 -0
  7. package/dist/config.js +44 -0
  8. package/dist/create-site/cli.d.ts +2 -0
  9. package/dist/create-site/cli.js +31 -0
  10. package/dist/create-site/generate-site.d.ts +12 -0
  11. package/dist/create-site/generate-site.js +168 -0
  12. package/dist/create-site/mint-token-cli.d.ts +2 -0
  13. package/dist/create-site/mint-token-cli.js +26 -0
  14. package/dist/create-site/mint-token.d.ts +7 -0
  15. package/dist/create-site/mint-token.js +49 -0
  16. package/dist/create-site/template/AGENTS.md +473 -0
  17. package/dist/create-site/template/content/menus/footerCompany.json +9 -0
  18. package/dist/create-site/template/content/menus/footerProduct.json +9 -0
  19. package/dist/create-site/template/content/menus/footerResources.json +9 -0
  20. package/dist/create-site/template/content/menus/main.json +9 -0
  21. package/dist/create-site/template/content/pages/404.json +23 -0
  22. package/dist/create-site/template/content/pages/about/careers.json +22 -0
  23. package/dist/create-site/template/content/pages/about/team.json +22 -0
  24. package/dist/create-site/template/content/pages/about.json +22 -0
  25. package/dist/create-site/template/content/pages/docs/deployment.json +22 -0
  26. package/dist/create-site/template/content/pages/docs/getting-started/quickstart.json +22 -0
  27. package/dist/create-site/template/content/pages/docs/getting-started.json +24 -0
  28. package/dist/create-site/template/content/pages/docs.json +22 -0
  29. package/dist/create-site/template/content/pages/index.json +182 -0
  30. package/dist/create-site/template/content/redirects.json +4 -0
  31. package/dist/create-site/template/dockerignore +6 -0
  32. package/dist/create-site/template/gitignore +11 -0
  33. package/dist/create-site/template/theme/assets/site.js +49 -0
  34. package/dist/create-site/template/theme/assets/style.css +1223 -0
  35. package/dist/create-site/template/theme/blocks/button.liquid +21 -0
  36. package/dist/create-site/template/theme/blocks/comparison-row.liquid +19 -0
  37. package/dist/create-site/template/theme/blocks/faq-item.liquid +22 -0
  38. package/dist/create-site/template/theme/blocks/feature-card.liquid +23 -0
  39. package/dist/create-site/template/theme/blocks/logo-mark.liquid +13 -0
  40. package/dist/create-site/template/theme/blocks/pricing-tier.liquid +42 -0
  41. package/dist/create-site/template/theme/blocks/step-card.liquid +19 -0
  42. package/dist/create-site/template/theme/blocks/testimonial-card.liquid +25 -0
  43. package/dist/create-site/template/theme/config/site_settings.json +11 -0
  44. package/dist/create-site/template/theme/layouts/theme.liquid +102 -0
  45. package/dist/create-site/template/theme/sections/comparison.liquid +41 -0
  46. package/dist/create-site/template/theme/sections/cta-banner.liquid +36 -0
  47. package/dist/create-site/template/theme/sections/faq.liquid +27 -0
  48. package/dist/create-site/template/theme/sections/feature-grid.liquid +27 -0
  49. package/dist/create-site/template/theme/sections/hero.liquid +48 -0
  50. package/dist/create-site/template/theme/sections/how-it-works.liquid +27 -0
  51. package/dist/create-site/template/theme/sections/pricing.liquid +27 -0
  52. package/dist/create-site/template/theme/sections/social-proof.liquid +21 -0
  53. package/dist/create-site/template/theme/sections/testimonials.liquid +27 -0
  54. package/dist/create-site/template/theme/snippets/icon.liquid +24 -0
  55. package/dist/create-site/template/theme/snippets/logo.liquid +7 -0
  56. package/dist/create-site/template/theme/snippets/responsive-media.liquid +107 -0
  57. package/dist/create-site/template/theme/snippets/site-name.liquid +1 -0
  58. package/dist/create-site/template/vhost/Dockerfile +33 -0
  59. package/dist/create-site/template/vhost/docker-entrypoint.sh +58 -0
  60. package/dist/index.d.ts +2 -0
  61. package/dist/index.js +4 -0
  62. package/dist/media/drivers/driver.d.ts +11 -0
  63. package/dist/media/drivers/driver.js +1 -0
  64. package/dist/media/drivers/local-fs-driver.d.ts +3 -0
  65. package/dist/media/drivers/local-fs-driver.js +94 -0
  66. package/dist/media/filename.d.ts +2 -0
  67. package/dist/media/filename.js +54 -0
  68. package/dist/media/manage-media.d.ts +19 -0
  69. package/dist/media/manage-media.js +45 -0
  70. package/dist/media/seed-media-cli.d.ts +2 -0
  71. package/dist/media/seed-media-cli.js +22 -0
  72. package/dist/media/seed-media.d.ts +11 -0
  73. package/dist/media/seed-media.js +76 -0
  74. package/dist/migrations/index.d.ts +3 -0
  75. package/dist/migrations/index.js +83 -0
  76. package/dist/pull-site/cli.d.ts +2 -0
  77. package/dist/pull-site/cli.js +78 -0
  78. package/dist/pull-site/prompt-token.d.ts +16 -0
  79. package/dist/pull-site/prompt-token.js +69 -0
  80. package/dist/pull-site/prompts.d.ts +22 -0
  81. package/dist/pull-site/prompts.js +127 -0
  82. package/dist/pull-site/pull-site.d.ts +23 -0
  83. package/dist/pull-site/pull-site.js +211 -0
  84. package/dist/renderer/engine.d.ts +2 -0
  85. package/dist/renderer/engine.js +48 -0
  86. package/dist/renderer/render-cache.d.ts +11 -0
  87. package/dist/renderer/render-cache.js +11 -0
  88. package/dist/renderer/render-page.d.ts +34 -0
  89. package/dist/renderer/render-page.js +209 -0
  90. package/dist/renderer/theme-templates.d.ts +7 -0
  91. package/dist/renderer/theme-templates.js +82 -0
  92. package/dist/routes/admin-redirect.d.ts +5 -0
  93. package/dist/routes/admin-redirect.js +26 -0
  94. package/dist/routes/assets.d.ts +6 -0
  95. package/dist/routes/assets.js +30 -0
  96. package/dist/routes/batch.d.ts +10 -0
  97. package/dist/routes/batch.js +148 -0
  98. package/dist/routes/capabilities.d.ts +6 -0
  99. package/dist/routes/capabilities.js +27 -0
  100. package/dist/routes/content.d.ts +8 -0
  101. package/dist/routes/content.js +169 -0
  102. package/dist/routes/deleted-pages.d.ts +8 -0
  103. package/dist/routes/deleted-pages.js +9 -0
  104. package/dist/routes/drafts.d.ts +10 -0
  105. package/dist/routes/drafts.js +73 -0
  106. package/dist/routes/git.d.ts +8 -0
  107. package/dist/routes/git.js +158 -0
  108. package/dist/routes/index.d.ts +12 -0
  109. package/dist/routes/index.js +86 -0
  110. package/dist/routes/links.d.ts +8 -0
  111. package/dist/routes/links.js +18 -0
  112. package/dist/routes/media-public.d.ts +11 -0
  113. package/dist/routes/media-public.js +125 -0
  114. package/dist/routes/media.d.ts +9 -0
  115. package/dist/routes/media.js +80 -0
  116. package/dist/routes/menus.d.ts +8 -0
  117. package/dist/routes/menus.js +148 -0
  118. package/dist/routes/preview-revision.d.ts +10 -0
  119. package/dist/routes/preview-revision.js +96 -0
  120. package/dist/routes/preview.d.ts +10 -0
  121. package/dist/routes/preview.js +56 -0
  122. package/dist/routes/public.d.ts +10 -0
  123. package/dist/routes/public.js +123 -0
  124. package/dist/routes/publish.d.ts +10 -0
  125. package/dist/routes/publish.js +139 -0
  126. package/dist/routes/redirects.d.ts +8 -0
  127. package/dist/routes/redirects.js +132 -0
  128. package/dist/routes/search-public.d.ts +6 -0
  129. package/dist/routes/search-public.js +104 -0
  130. package/dist/routes/search.d.ts +8 -0
  131. package/dist/routes/search.js +15 -0
  132. package/dist/routes/settings.d.ts +10 -0
  133. package/dist/routes/settings.js +68 -0
  134. package/dist/routes/sitemap.d.ts +7 -0
  135. package/dist/routes/sitemap.js +79 -0
  136. package/dist/routes/theme.d.ts +10 -0
  137. package/dist/routes/theme.js +116 -0
  138. package/dist/schemas/instance.schema.json +17 -0
  139. package/dist/schemas/menu.schema.json +24 -0
  140. package/dist/schemas/page.schema.json +26 -0
  141. package/dist/schemas/redirects.schema.json +24 -0
  142. package/dist/search/drivers/driver.d.ts +9 -0
  143. package/dist/search/drivers/driver.js +1 -0
  144. package/dist/search/drivers/node-sqlite-driver.d.ts +7 -0
  145. package/dist/search/drivers/node-sqlite-driver.js +19 -0
  146. package/dist/search/query-content.d.ts +32 -0
  147. package/dist/search/query-content.js +207 -0
  148. package/dist/search/query-index.d.ts +5 -0
  149. package/dist/search/query-index.js +21 -0
  150. package/dist/search/rebuild-index.d.ts +3 -0
  151. package/dist/search/rebuild-index.js +320 -0
  152. package/dist/server-config.d.ts +23 -0
  153. package/dist/server-config.js +237 -0
  154. package/dist/server.d.ts +13 -0
  155. package/dist/server.js +235 -0
  156. package/dist/services/batch.d.ts +35 -0
  157. package/dist/services/batch.js +145 -0
  158. package/dist/services/checkpoint.d.ts +4 -0
  159. package/dist/services/checkpoint.js +45 -0
  160. package/dist/services/content-read.d.ts +27 -0
  161. package/dist/services/content-read.js +151 -0
  162. package/dist/services/delete-content.d.ts +12 -0
  163. package/dist/services/delete-content.js +166 -0
  164. package/dist/services/deleted-pages.d.ts +11 -0
  165. package/dist/services/deleted-pages.js +83 -0
  166. package/dist/services/dev-tunnel.d.ts +5 -0
  167. package/dist/services/dev-tunnel.js +5 -0
  168. package/dist/services/drafts.d.ts +16 -0
  169. package/dist/services/drafts.js +164 -0
  170. package/dist/services/etag.d.ts +2 -0
  171. package/dist/services/etag.js +30 -0
  172. package/dist/services/fs-walk.d.ts +1 -0
  173. package/dist/services/fs-walk.js +26 -0
  174. package/dist/services/git-commit.d.ts +3 -0
  175. package/dist/services/git-commit.js +12 -0
  176. package/dist/services/git-history.d.ts +28 -0
  177. package/dist/services/git-history.js +131 -0
  178. package/dist/services/git-revert.d.ts +12 -0
  179. package/dist/services/git-revert.js +140 -0
  180. package/dist/services/git.d.ts +16 -0
  181. package/dist/services/git.js +127 -0
  182. package/dist/services/interval-job.d.ts +4 -0
  183. package/dist/services/interval-job.js +9 -0
  184. package/dist/services/ip-allowlist.d.ts +7 -0
  185. package/dist/services/ip-allowlist.js +31 -0
  186. package/dist/services/links.d.ts +22 -0
  187. package/dist/services/links.js +169 -0
  188. package/dist/services/manage-menus.d.ts +15 -0
  189. package/dist/services/manage-menus.js +138 -0
  190. package/dist/services/manage-redirects.d.ts +25 -0
  191. package/dist/services/manage-redirects.js +156 -0
  192. package/dist/services/menu-references.d.ts +3 -0
  193. package/dist/services/menu-references.js +38 -0
  194. package/dist/services/menus.d.ts +9 -0
  195. package/dist/services/menus.js +43 -0
  196. package/dist/services/migration-runner.d.ts +16 -0
  197. package/dist/services/migration-runner.js +169 -0
  198. package/dist/services/mime-types.d.ts +3 -0
  199. package/dist/services/mime-types.js +43 -0
  200. package/dist/services/move.d.ts +16 -0
  201. package/dist/services/move.js +236 -0
  202. package/dist/services/path-safety.d.ts +6 -0
  203. package/dist/services/path-safety.js +112 -0
  204. package/dist/services/pid-file.d.ts +13 -0
  205. package/dist/services/pid-file.js +54 -0
  206. package/dist/services/post-urls.d.ts +3 -0
  207. package/dist/services/post-urls.js +27 -0
  208. package/dist/services/prepared-operation.d.ts +4 -0
  209. package/dist/services/prepared-operation.js +1 -0
  210. package/dist/services/publish.d.ts +15 -0
  211. package/dist/services/publish.js +313 -0
  212. package/dist/services/rate-limit-config.d.ts +9 -0
  213. package/dist/services/rate-limit-config.js +22 -0
  214. package/dist/services/redirects.d.ts +27 -0
  215. package/dist/services/redirects.js +175 -0
  216. package/dist/services/reindex-on-write.d.ts +3 -0
  217. package/dist/services/reindex-on-write.js +30 -0
  218. package/dist/services/resolve-blog-url.d.ts +11 -0
  219. package/dist/services/resolve-blog-url.js +31 -0
  220. package/dist/services/resolve-url.d.ts +11 -0
  221. package/dist/services/resolve-url.js +20 -0
  222. package/dist/services/site-settings.d.ts +28 -0
  223. package/dist/services/site-settings.js +116 -0
  224. package/dist/services/startup-checks.d.ts +10 -0
  225. package/dist/services/startup-checks.js +81 -0
  226. package/dist/services/static-file.d.ts +7 -0
  227. package/dist/services/static-file.js +37 -0
  228. package/dist/services/theme-component-file.d.ts +6 -0
  229. package/dist/services/theme-component-file.js +39 -0
  230. package/dist/services/theme-files.d.ts +33 -0
  231. package/dist/services/theme-files.js +148 -0
  232. package/dist/services/theme-page-templates.d.ts +7 -0
  233. package/dist/services/theme-page-templates.js +53 -0
  234. package/dist/services/theme-schemas.d.ts +2 -0
  235. package/dist/services/theme-schemas.js +137 -0
  236. package/dist/services/token-auth.d.ts +10 -0
  237. package/dist/services/token-auth.js +64 -0
  238. package/dist/services/urls.d.ts +2 -0
  239. package/dist/services/urls.js +20 -0
  240. package/dist/services/validation.d.ts +26 -0
  241. package/dist/services/validation.js +195 -0
  242. package/dist/services/write-queue.d.ts +1 -0
  243. package/dist/services/write-queue.js +14 -0
  244. package/dist/site-check/cli.d.ts +2 -0
  245. package/dist/site-check/cli.js +48 -0
  246. package/dist/site-check/run-check.d.ts +12 -0
  247. package/dist/site-check/run-check.js +188 -0
  248. package/dist/site-sync/cli-helpers.d.ts +23 -0
  249. package/dist/site-sync/cli-helpers.js +75 -0
  250. package/dist/site-sync/deploy.d.ts +36 -0
  251. package/dist/site-sync/deploy.js +124 -0
  252. package/dist/site-sync/live-content.d.ts +30 -0
  253. package/dist/site-sync/live-content.js +68 -0
  254. package/dist/site-sync/prompts.d.ts +30 -0
  255. package/dist/site-sync/prompts.js +237 -0
  256. package/dist/site-sync/pull-cli.d.ts +2 -0
  257. package/dist/site-sync/pull-cli.js +75 -0
  258. package/dist/site-sync/pull-site.d.ts +28 -0
  259. package/dist/site-sync/pull-site.js +171 -0
  260. package/dist/site-sync/push-cli.d.ts +2 -0
  261. package/dist/site-sync/push-cli.js +268 -0
  262. package/dist/site-sync/push-site.d.ts +96 -0
  263. package/dist/site-sync/push-site.js +376 -0
  264. package/dist/site-sync/remote-site.d.ts +38 -0
  265. package/dist/site-sync/remote-site.js +122 -0
  266. package/dist/site-sync/sync-record.d.ts +26 -0
  267. package/dist/site-sync/sync-record.js +76 -0
  268. package/dist/site-sync/theme-sync.d.ts +34 -0
  269. package/dist/site-sync/theme-sync.js +128 -0
  270. package/dist/stop-site/cli.d.ts +2 -0
  271. package/dist/stop-site/cli.js +28 -0
  272. package/dist/stop-site/stop-site.d.ts +26 -0
  273. package/dist/stop-site/stop-site.js +56 -0
  274. package/dist/theme-state.d.ts +20 -0
  275. package/dist/theme-state.js +33 -0
  276. package/dist/upgrade/changelog.d.ts +8 -0
  277. package/dist/upgrade/changelog.js +46 -0
  278. package/dist/upgrade/finish.d.ts +9 -0
  279. package/dist/upgrade/finish.js +30 -0
  280. package/dist/upgrade/owned-files.d.ts +11 -0
  281. package/dist/upgrade/owned-files.js +42 -0
  282. package/dist/upgrade/package-names.d.ts +4 -0
  283. package/dist/upgrade/package-names.js +26 -0
  284. package/dist/upgrade/upgrade-cli.d.ts +2 -0
  285. package/dist/upgrade/upgrade-cli.js +170 -0
  286. 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, '&lt;')
52
+ .replace(/>/g, '&gt;')
53
+ .replace(/"/g, '&quot;')
54
+ .replace(/'/g, '&apos;');
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,9 @@
1
+ export interface PreparedStatement {
2
+ run(...params: unknown[]): void;
3
+ all(...params: unknown[]): unknown[];
4
+ }
5
+ export interface SearchDriver {
6
+ exec(sql: string): void;
7
+ prepare(sql: string): PreparedStatement;
8
+ close(): void;
9
+ }
@@ -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,5 @@
1
+ export interface SearchResult {
2
+ url: string;
3
+ title: string;
4
+ }
5
+ export declare function queryIndex(searchIndexPath: string, term: string): SearchResult[];
@@ -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
+ }
@@ -0,0 +1,3 @@
1
+ import type { SiteConfig } from '../config.ts';
2
+ export declare function rebuildIndex(config: SiteConfig): Promise<void>;
3
+ export declare function rebuildIndexIfMissing(config: SiteConfig): Promise<void>;