@minamocms/framework 0.0.0-stage → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (286) hide show
  1. package/CHANGELOG.md +234 -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 +81 -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,137 @@
1
+ import { readdirSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { parseThemeComponentFile } from "./theme-component-file.js";
4
+ import { requiredFieldsHaveValidDefaults } from "./validation.js";
5
+ // themeRoot is agent configuration (the configured site's theme
6
+ // directory), not a request-supplied :path parameter. This walk is
7
+ // deliberately NOT the Group B path-sanitisation helper and must
8
+ // never be reused for untrusted request paths.
9
+ //
10
+ // Flat *.liquid files, one per type, named directly (e.g. hero.liquid,
11
+ // media-text.liquid) - no subfolder per type. Mirrors theme-templates.ts's
12
+ // loadFlatTemplates walk exactly (already established for snippets/
13
+ // layouts), extended to extract the embedded {% schema %} block instead
14
+ // of returning the raw file contents.
15
+ //
16
+ // kind is only used to word each warning ("Section" vs "Block") -
17
+ // callers already know which directory they asked for.
18
+ function loadTypeSchemas(typesDir, kind) {
19
+ const schemas = {};
20
+ const acceptsBlocks = {};
21
+ const warnings = [];
22
+ let entries;
23
+ try {
24
+ entries = readdirSync(typesDir, { withFileTypes: true })
25
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.liquid'))
26
+ .map((entry) => entry.name);
27
+ }
28
+ catch {
29
+ return { schemas, acceptsBlocks, warnings };
30
+ }
31
+ for (const fileName of entries) {
32
+ const type = fileName.slice(0, -'.liquid'.length);
33
+ const label = `${kind} type "${type}" (${fileName}) was excluded from the theme`;
34
+ let source;
35
+ try {
36
+ source = readFileSync(join(typesDir, fileName), 'utf-8');
37
+ }
38
+ catch {
39
+ continue;
40
+ }
41
+ const parsed = parseThemeComponentFile(source);
42
+ if (!parsed) {
43
+ warnings.push(`${label}: no valid {% schema %} block found (missing, or not parseable JSON).`);
44
+ continue;
45
+ }
46
+ // A type whose required settings fields lack usable defaults is
47
+ // skipped the same way a malformed schema block already is -
48
+ // never a boot failure, just excluded from what gets registered
49
+ // (guide-theme-authoring.md, Group L).
50
+ if (!requiredFieldsHaveValidDefaults(parsed.schema)) {
51
+ warnings.push(`${label}: a property listed in "required" has no valid "default" (see guide-theme-authoring.md).`);
52
+ continue;
53
+ }
54
+ schemas[type] = parsed.schema;
55
+ // The only place "does this type support nested blocks" is ever
56
+ // expressed - a markup convention (does the template loop
57
+ // blocksHtml), not a schema field (guide-theme-authoring.md).
58
+ acceptsBlocks[type] = parsed.markup.includes('blocksHtml');
59
+ }
60
+ return { schemas, acceptsBlocks, warnings };
61
+ }
62
+ // theme/config/site_settings.json: the site-wide settings the theme
63
+ // defines, in the same JSON Schema form a section's {% schema %} uses,
64
+ // vetted the same way. No file means no site settings, which is fine; a
65
+ // file that can't be used is left out with a warning, like a broken
66
+ // section.
67
+ //
68
+ // Not Shopify's config/settings_schema.json, which 0.7.0 and 0.7.1 used:
69
+ // Shopify's VS Code extension checks any file of that name against
70
+ // Shopify's own format (a list of groups), marking Minamo's as wrong -
71
+ // which is how one real site had its file rewritten into Shopify's form.
72
+ // A settings_schema.json in Minamo's form is still read, with a warning
73
+ // to rename it; one in Shopify's form (or anything else) is Shopify's,
74
+ // and left alone.
75
+ const SETTINGS_FILE = 'site_settings.json';
76
+ const OLD_SETTINGS_FILE = 'settings_schema.json';
77
+ function readConfigFile(themeRoot, name) {
78
+ try {
79
+ return readFileSync(join(themeRoot, 'config', name), 'utf-8');
80
+ }
81
+ catch {
82
+ return null;
83
+ }
84
+ }
85
+ function loadSettingsSchema(themeRoot) {
86
+ const source = readConfigFile(themeRoot, SETTINGS_FILE);
87
+ if (source === null) {
88
+ return loadOldSettingsSchema(themeRoot);
89
+ }
90
+ const label = `Site settings (config/${SETTINGS_FILE}) were excluded from the theme`;
91
+ let schema;
92
+ try {
93
+ schema = JSON.parse(source);
94
+ }
95
+ catch {
96
+ return { warning: `${label}: the file is not valid JSON.` };
97
+ }
98
+ if (typeof schema !== 'object' || schema === null || Array.isArray(schema)) {
99
+ return { warning: `${label}: the file must hold a JSON Schema object, the same form as a section's {% schema %}.` };
100
+ }
101
+ if (!requiredFieldsHaveValidDefaults(schema)) {
102
+ return { warning: `${label}: a property listed in "required" has no valid "default" (see guide-theme-authoring.md).` };
103
+ }
104
+ return { schema };
105
+ }
106
+ function loadOldSettingsSchema(themeRoot) {
107
+ const source = readConfigFile(themeRoot, OLD_SETTINGS_FILE);
108
+ if (source === null) {
109
+ return {};
110
+ }
111
+ let schema;
112
+ try {
113
+ schema = JSON.parse(source);
114
+ }
115
+ catch {
116
+ return {};
117
+ }
118
+ if (typeof schema !== 'object' || schema === null || Array.isArray(schema) || !requiredFieldsHaveValidDefaults(schema)) {
119
+ return {};
120
+ }
121
+ return {
122
+ schema,
123
+ warning: `Site settings are defined in config/${OLD_SETTINGS_FILE}, the name Shopify uses for its own, different format (its VS Code extension marks the file as wrong). Rename it to config/${SETTINGS_FILE}; nothing else changes.`,
124
+ };
125
+ }
126
+ export function loadThemeSchemas(themeRoot) {
127
+ const sections = loadTypeSchemas(join(themeRoot, 'sections'), 'Section');
128
+ const blocks = loadTypeSchemas(join(themeRoot, 'blocks'), 'Block');
129
+ const settings = loadSettingsSchema(themeRoot);
130
+ return {
131
+ sections: sections.schemas,
132
+ blocks: blocks.schemas,
133
+ acceptsBlocks: { sections: sections.acceptsBlocks, blocks: blocks.acceptsBlocks },
134
+ ...(settings.schema ? { settings: settings.schema } : {}),
135
+ warnings: [...sections.warnings, ...blocks.warnings, ...(settings.warning ? [settings.warning] : [])],
136
+ };
137
+ }
@@ -0,0 +1,10 @@
1
+ import type { FastifyRequest } from 'fastify';
2
+ import type { Scope, TokenEntry } from '../server-config.ts';
3
+ export type AuthReason = 'missing-token' | 'invalid-token' | 'missing-scope';
4
+ export declare class AuthError extends Error {
5
+ readonly reason: AuthReason;
6
+ readonly statusCode: number;
7
+ constructor(reason: AuthReason, message: string);
8
+ }
9
+ export declare function verifyToken(tokens: TokenEntry[], presentedToken: string): Scope[] | null;
10
+ export declare function requireScope(tokens: TokenEntry[], scope: Scope): (request: FastifyRequest) => Promise<void>;
@@ -0,0 +1,64 @@
1
+ import { createHash, timingSafeEqual } from 'node:crypto';
2
+ // The first domain error in this codebase's established XxxError+
3
+ // reason convention that has to cross the Fastify HTTP boundary -
4
+ // every prior one throws pre-HTTP (at boot) or predates Phase 2
5
+ // entirely. statusCode is set explicitly in the constructor so the
6
+ // existing global error handler (server.ts, which branches on
7
+ // error.statusCode) passes these through with their real message
8
+ // intact, never sanitising them as a generic 500 - confirmed
9
+ // empirically that a preHandler-thrown error with .statusCode set
10
+ // reaches that same handler correctly.
11
+ export class AuthError extends Error {
12
+ reason;
13
+ statusCode;
14
+ constructor(reason, message) {
15
+ super(message);
16
+ this.name = 'AuthError';
17
+ this.reason = reason;
18
+ this.statusCode = reason === 'missing-scope' ? 403 : 401;
19
+ }
20
+ }
21
+ const BEARER_PREFIX = 'Bearer ';
22
+ function hashToken(token) {
23
+ return createHash('sha256').update(token).digest();
24
+ }
25
+ // Never compare token digests (or hex strings) with === or
26
+ // Buffer.equals() - neither is guaranteed constant-time. Only
27
+ // timingSafeEqual, which is why both sides must first be reduced to
28
+ // a fixed-length digest: it throws on mismatched buffer lengths, and
29
+ // a raw incoming token is never the same length as another token in
30
+ // general (confirmed empirically before this was written).
31
+ export function verifyToken(tokens, presentedToken) {
32
+ const presentedDigest = hashToken(presentedToken);
33
+ for (const entry of tokens) {
34
+ const storedDigest = Buffer.from(entry.hash, 'hex');
35
+ if (timingSafeEqual(presentedDigest, storedDigest)) {
36
+ return entry.scopes;
37
+ }
38
+ }
39
+ return null;
40
+ }
41
+ // Applied as a per-route preHandler option
42
+ // (fastify.get(path, { preHandler: requireScope(tokens, 'content') }, handler)),
43
+ // not a whole-file addHook: a single route-group file may eventually
44
+ // mix scopes (e.g. a content-scoped GET and a media-scoped POST in
45
+ // the same file), so the check is granular to the route, not the
46
+ // file. Each route-group file still registers plainly, never wrapped
47
+ // in fastify-plugin's fp(), preserving the per-plugin encapsulation
48
+ // this design depends on.
49
+ export function requireScope(tokens, scope) {
50
+ return async function scopeGuard(request) {
51
+ const header = request.headers.authorization;
52
+ if (!header || !header.startsWith(BEARER_PREFIX)) {
53
+ throw new AuthError('missing-token', 'Missing or malformed Authorization header');
54
+ }
55
+ const presentedToken = header.slice(BEARER_PREFIX.length);
56
+ const scopes = verifyToken(tokens, presentedToken);
57
+ if (scopes === null) {
58
+ throw new AuthError('invalid-token', 'The presented token does not match any configured token');
59
+ }
60
+ if (!scopes.includes(scope)) {
61
+ throw new AuthError('missing-scope', `This route requires the "${scope}" scope`);
62
+ }
63
+ };
64
+ }
@@ -0,0 +1,2 @@
1
+ export declare function urlToPagePath(url: string): string;
2
+ export declare function pagePathToUrl(relativePagePath: string): string;
@@ -0,0 +1,20 @@
1
+ // Pure, filesystem-free mapping between a public URL and a page's path
2
+ // relative to pagesRoot. A page with children is a file (about.json)
3
+ // sitting alongside a same-named sibling directory (about/) holding
4
+ // descendants, so this mapping never needs to know whether a given
5
+ // page currently has children - unlike an about/index.json convention,
6
+ // which would make resolution filesystem-dependent.
7
+ export function urlToPagePath(url) {
8
+ const trimmed = url.replace(/^\/+/, '').replace(/\/+$/, '');
9
+ if (trimmed === '') {
10
+ return 'index.json';
11
+ }
12
+ return `${trimmed}.json`;
13
+ }
14
+ export function pagePathToUrl(relativePagePath) {
15
+ const withoutExtension = relativePagePath.replace(/\.json$/, '');
16
+ if (withoutExtension === 'index') {
17
+ return '/';
18
+ }
19
+ return `/${withoutExtension}`;
20
+ }
@@ -0,0 +1,26 @@
1
+ export interface ValidationError {
2
+ path: string;
3
+ message: string;
4
+ keyword: string;
5
+ }
6
+ export interface ValidationResult {
7
+ valid: boolean;
8
+ errors: ValidationError[];
9
+ }
10
+ export interface ThemeSchemas {
11
+ sections: Record<string, object>;
12
+ blocks: Record<string, object>;
13
+ acceptsBlocks: {
14
+ sections: Record<string, boolean>;
15
+ blocks: Record<string, boolean>;
16
+ };
17
+ settings?: object;
18
+ warnings?: string[];
19
+ }
20
+ export declare function requiredFieldsHaveValidDefaults(schema: object): boolean;
21
+ export declare function validateInstance(instance: unknown, kind: 'section' | 'block', themeSchemas: ThemeSchemas): ValidationResult;
22
+ export declare function validatePage(page: unknown, themeSchemas: ThemeSchemas): ValidationResult;
23
+ export declare function validateMenu(menu: unknown): ValidationResult;
24
+ export declare function validateRedirects(redirects: unknown): ValidationResult;
25
+ export declare function validateContent(relativePath: string, content: unknown, themeSchemas: ThemeSchemas): ValidationResult;
26
+ export declare function validateSiteSettings(settings: unknown, themeSchemas: ThemeSchemas): ValidationResult;
@@ -0,0 +1,195 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { Ajv } from 'ajv';
4
+ // strict: false because theme schema.json files are authored by theme
5
+ // authors, not agent code, and are validated the same lenient way
6
+ // everywhere (see theme-schemas.ts).
7
+ const ajv = new Ajv({ allErrors: true, strict: false });
8
+ // The nine format values guide-theme-authoring.md/AGENTS.md actually
9
+ // document (richtext/image/video/textarea/uri/date/color/range/toggle -
10
+ // uri and date are real standard JSON Schema formats already understood
11
+ // without this) are still, correctly, UI hints only: registering them
12
+ // as a literal no-op format (the `true` here, not a real validator
13
+ // function) doesn't make Ajv enforce anything about them, it only
14
+ // stops it printing "unknown format \"x\" ignored" for values that are
15
+ // completely expected. A theme author's genuine typo (e.g. "iamge")
16
+ // still isn't in this list, so it still warns - this only silences the
17
+ // ones we ourselves tell theme authors to use, not unknown-format
18
+ // warnings in general.
19
+ // "url" (the link field's documented name) and "uri" (its name before)
20
+ // are hints too: a link is a site path like "/about" as often as a full
21
+ // address, which a real uri check would reject.
22
+ for (const format of ['richtext', 'image', 'textarea', 'color', 'range', 'toggle', 'video', 'url', 'uri']) {
23
+ ajv.addFormat(format, true);
24
+ }
25
+ const schemasDir = join(import.meta.dirname, '..', 'schemas');
26
+ function readSchema(filename) {
27
+ return JSON.parse(readFileSync(join(schemasDir, filename), 'utf-8'));
28
+ }
29
+ const instanceSchema = readSchema('instance.schema.json');
30
+ ajv.addSchema(instanceSchema);
31
+ const validatePageEnvelope = ajv.compile(readSchema('page.schema.json'));
32
+ const validateMenuEnvelope = ajv.compile(readSchema('menu.schema.json'));
33
+ const validateRedirectsEnvelope = ajv.compile(readSchema('redirects.schema.json'));
34
+ const validateInstanceEnvelope = ajv.compile(instanceSchema);
35
+ function normaliseErrors(errors) {
36
+ if (!errors) {
37
+ return [];
38
+ }
39
+ return errors.map((error) => {
40
+ let path = error.instancePath;
41
+ if (error.keyword === 'required') {
42
+ const { missingProperty } = error.params;
43
+ path = `${error.instancePath}/${missingProperty}`;
44
+ }
45
+ else if (error.keyword === 'additionalProperties') {
46
+ const { additionalProperty } = error.params;
47
+ path = `${error.instancePath}/${additionalProperty}`;
48
+ }
49
+ return { path, message: error.message ?? '', keyword: error.keyword };
50
+ });
51
+ }
52
+ function runValidator(validate, data) {
53
+ const valid = validate(data);
54
+ return { valid: Boolean(valid), errors: normaliseErrors(validate.errors) };
55
+ }
56
+ function prefixErrors(errors, prefix) {
57
+ return errors.map((error) => ({ ...error, path: `${prefix}${error.path}` }));
58
+ }
59
+ // The theme-authored schema JSON is passed through verbatim end to end
60
+ // (see theme-schemas.ts), so an optional allowedBlocks array rides
61
+ // through the whole pipeline for free, exactly like the "title" keyword
62
+ // already does - never a real Ajv keyword, just a plain top-level
63
+ // annotation read directly off the object. Absent/malformed means
64
+ // unrestricted, matching every theme file written before this existed.
65
+ function allowedBlockTypesOf(schema) {
66
+ const value = schema?.allowedBlocks;
67
+ return Array.isArray(value) && value.every((entry) => typeof entry === 'string') ? value : undefined;
68
+ }
69
+ // A theme author's own settings schema must declare a "default" for
70
+ // every property it lists as required, and that default must itself
71
+ // satisfy the property's own sub-schema - a bare "default": "" against
72
+ // a minLength: 1 field would technically have a default but still
73
+ // leave a pre-filled instance invalid, defeating the reason this
74
+ // exists (see theme-schemas.ts, which skips a type failing this check
75
+ // exactly like it already skips a malformed {% schema %} block).
76
+ export function requiredFieldsHaveValidDefaults(schema) {
77
+ const { required, properties } = schema;
78
+ if (!Array.isArray(required)) {
79
+ return true;
80
+ }
81
+ const propertyMap = (properties && typeof properties === 'object' ? properties : {});
82
+ return required.every((name) => {
83
+ if (typeof name !== 'string') {
84
+ return false;
85
+ }
86
+ const propertySchema = propertyMap[name];
87
+ if (typeof propertySchema !== 'object' || propertySchema === null || !('default' in propertySchema)) {
88
+ return false;
89
+ }
90
+ // ajv.validate compiles+runs propertySchema in isolation, detached
91
+ // from whatever schema it was pulled out of - a "$ref" pointing at
92
+ // a "$defs" entry declared on the parent (not inside this property
93
+ // sub-schema itself) can't resolve here, and Ajv throws
94
+ // (MissingRefError) rather than returning false. That's a real
95
+ // theme-authoring mistake (this function's whole contract assumes
96
+ // a schema self-contained enough to validate standalone), not a
97
+ // reason to crash the caller - loadTypeSchemas already treats a
98
+ // false return here as "type excluded, boot warning printed", so
99
+ // folding a thrown validation error into that exact same false
100
+ // keeps every possible way a required field's default can be
101
+ // unusable on the one graceful path, instead of one of them being
102
+ // the sole exception that takes the whole server down.
103
+ try {
104
+ return ajv.validate(propertySchema, propertySchema.default) === true;
105
+ }
106
+ catch {
107
+ return false;
108
+ }
109
+ });
110
+ }
111
+ export function validateInstance(instance, kind, themeSchemas) {
112
+ const envelopeResult = runValidator(validateInstanceEnvelope, instance);
113
+ if (!envelopeResult.valid) {
114
+ return envelopeResult;
115
+ }
116
+ const typed = instance;
117
+ const typeSchemas = kind === 'section' ? themeSchemas.sections : themeSchemas.blocks;
118
+ const typeSchema = typeSchemas[typed.type];
119
+ const errors = [];
120
+ if (!typeSchema) {
121
+ errors.push({
122
+ path: '/type',
123
+ message: `Unknown ${kind} type "${typed.type}"`,
124
+ keyword: 'unknownType',
125
+ });
126
+ }
127
+ else {
128
+ const settingsValidate = ajv.compile(typeSchema);
129
+ const settingsResult = runValidator(settingsValidate, typed.settings);
130
+ errors.push(...prefixErrors(settingsResult.errors, '/settings'));
131
+ }
132
+ if (typed.blocks) {
133
+ const allowedBlocks = allowedBlockTypesOf(typeSchema);
134
+ typed.blocks.forEach((block, index) => {
135
+ const blockType = block?.type;
136
+ if (allowedBlocks && typeof blockType === 'string' && !allowedBlocks.includes(blockType)) {
137
+ errors.push({
138
+ path: `/blocks/${index}/type`,
139
+ message: `Block type "${blockType}" is not allowed here`,
140
+ keyword: 'disallowedBlockType',
141
+ });
142
+ }
143
+ const blockResult = validateInstance(block, 'block', themeSchemas);
144
+ errors.push(...prefixErrors(blockResult.errors, `/blocks/${index}`));
145
+ });
146
+ }
147
+ return { valid: errors.length === 0, errors };
148
+ }
149
+ function validateSectionedContent(envelopeValidate, content, themeSchemas) {
150
+ const envelopeResult = runValidator(envelopeValidate, content);
151
+ if (!envelopeResult.valid) {
152
+ return envelopeResult;
153
+ }
154
+ const typed = content;
155
+ const errors = [];
156
+ typed.sections.forEach((section, index) => {
157
+ const sectionResult = validateInstance(section, 'section', themeSchemas);
158
+ errors.push(...prefixErrors(sectionResult.errors, `/sections/${index}`));
159
+ });
160
+ return { valid: errors.length === 0, errors };
161
+ }
162
+ export function validatePage(page, themeSchemas) {
163
+ return validateSectionedContent(validatePageEnvelope, page, themeSchemas);
164
+ }
165
+ export function validateMenu(menu) {
166
+ return runValidator(validateMenuEnvelope, menu);
167
+ }
168
+ // Never routed through validateContent's dispatcher below: redirects.json
169
+ // lives at the site root, not under content/, so it never has a
170
+ // content/-relative path for that dispatcher to match against.
171
+ export function validateRedirects(redirects) {
172
+ return runValidator(validateRedirectsEnvelope, redirects);
173
+ }
174
+ // Dispatches on the relative path's prefix, not an in-file `type`
175
+ // field: a menu has no `type` field at all, and the correct schema to
176
+ // parse against must be known before the content is even validated.
177
+ export function validateContent(relativePath, content, themeSchemas) {
178
+ if (relativePath.startsWith('menus/')) {
179
+ return validateMenu(content);
180
+ }
181
+ return validatePage(content, themeSchemas);
182
+ }
183
+ // Site settings (content/settings.json) against the theme's own
184
+ // settings schema. A theme that defines none accepts none.
185
+ export function validateSiteSettings(settings, themeSchemas) {
186
+ if (typeof settings !== 'object' || settings === null || Array.isArray(settings)) {
187
+ return { valid: false, errors: [{ path: '', message: 'must be an object', keyword: 'type' }] };
188
+ }
189
+ if (!themeSchemas.settings) {
190
+ return Object.keys(settings).length === 0
191
+ ? { valid: true, errors: [] }
192
+ : { valid: false, errors: [{ path: '', message: 'the theme defines no site settings', keyword: 'noSettingsSchema' }] };
193
+ }
194
+ return runValidator(ajv.compile(themeSchemas.settings), settings);
195
+ }
@@ -0,0 +1 @@
1
+ export declare function enqueue<T>(job: () => Promise<T>): Promise<T>;
@@ -0,0 +1,14 @@
1
+ // The single in-process write queue: all disk and git mutations go
2
+ // through enqueue(), one job at a time (constraint 6). Ordering only -
3
+ // no domain knowledge of what "clean" means for a failed job. That is
4
+ // each job's own responsibility (see drafts.ts, publish.ts).
5
+ //
6
+ // In-memory only, does not survive restart: the API only reports
7
+ // success after a write (and commit, where applicable) completes, so an
8
+ // in-flight request during a crash simply fails and the caller retries.
9
+ let tail = Promise.resolve();
10
+ export function enqueue(job) {
11
+ const result = tail.then(job, job);
12
+ tail = result.then(() => undefined, () => undefined);
13
+ return result;
14
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env node
2
+ import { resolve } from 'node:path';
3
+ import { runSiteCheck } from "./run-check.js";
4
+ // Run from vhost/ (see the scaffold's own "check" script), so the
5
+ // site root is one level up - the exact same relative relationship
6
+ // SERVER_JS in create-site/generate-site.ts already relies on.
7
+ const siteRoot = resolve(process.cwd(), '..');
8
+ const KIND_LABELS = {
9
+ schema: 'Theme schema',
10
+ 'render-error': 'Render error',
11
+ 'missing-asset': 'Missing asset',
12
+ 'broken-link': 'Broken link',
13
+ 'misplaced-media': 'Misplaced media',
14
+ };
15
+ function printGrouped(findings) {
16
+ const byKind = new Map();
17
+ for (const finding of findings) {
18
+ const list = byKind.get(finding.kind) ?? [];
19
+ list.push(finding);
20
+ byKind.set(finding.kind, list);
21
+ }
22
+ for (const [kind, list] of byKind) {
23
+ console.log(`\n${KIND_LABELS[kind]} (${list.length}):`);
24
+ for (const finding of list) {
25
+ const location = finding.pageUrl ? ` ${finding.pageUrl}: ` : ' ';
26
+ console.log(`${location}${finding.message}`);
27
+ }
28
+ }
29
+ }
30
+ function printSuggestions(suggestions) {
31
+ if (suggestions.length === 0) {
32
+ return;
33
+ }
34
+ console.log(`\nSuggestions (${suggestions.length}):`);
35
+ for (const suggestion of suggestions) {
36
+ console.log(` ${suggestion}`);
37
+ }
38
+ }
39
+ const result = await runSiteCheck(siteRoot);
40
+ if (result.ok) {
41
+ console.log('No problems found.');
42
+ printSuggestions(result.suggestions);
43
+ process.exit(0);
44
+ }
45
+ console.log(`${result.findings.length} problem${result.findings.length === 1 ? '' : 's'} found:`);
46
+ printGrouped(result.findings);
47
+ printSuggestions(result.suggestions);
48
+ process.exit(1);
@@ -0,0 +1,12 @@
1
+ export type CheckFindingKind = 'schema' | 'render-error' | 'missing-asset' | 'broken-link' | 'misplaced-media';
2
+ export interface CheckFinding {
3
+ kind: CheckFindingKind;
4
+ message: string;
5
+ pageUrl?: string;
6
+ }
7
+ export interface CheckResult {
8
+ ok: boolean;
9
+ findings: CheckFinding[];
10
+ suggestions: string[];
11
+ }
12
+ export declare function runSiteCheck(siteRoot: string): Promise<CheckResult>;