@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,188 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { bootSite } from "../boot.js";
3
+ import { renderPage } from "../renderer/render-page.js";
4
+ import { PathSafetyError, sanitisePath } from "../services/path-safety.js";
5
+ import { buildSitemapUrls } from "../routes/sitemap.js";
6
+ import { urlToPagePath } from "../services/urls.js";
7
+ import { ALLOWED_UPLOAD_EXTENSIONS } from "../media/filename.js";
8
+ import { resolveSiteSettings } from "../services/site-settings.js";
9
+ // Link fields declared with format "uri", JSON Schema's word and
10
+ // Minamo's documented name before "url". Both still work, so this is a
11
+ // suggestion, not a problem: themes drift to the one name over time.
12
+ function uriFormatSuggestions(themeSchemas) {
13
+ const suggestions = [];
14
+ const visit = (where, schema, path) => {
15
+ if (!schema || typeof schema !== 'object') {
16
+ return;
17
+ }
18
+ const node = schema;
19
+ if (node.format === 'uri') {
20
+ suggestions.push(`${where}: "${path}" uses "format": "uri". "url" is the name to use now (both work).`);
21
+ }
22
+ for (const [name, child] of Object.entries(node.properties ?? {})) {
23
+ visit(where, child, path ? `${path}.${name}` : name);
24
+ }
25
+ visit(where, node.items, path ? `${path}[]` : '[]');
26
+ };
27
+ for (const [type, schema] of Object.entries(themeSchemas.sections)) {
28
+ visit(`Section "${type}"`, schema, '');
29
+ }
30
+ for (const [type, schema] of Object.entries(themeSchemas.blocks)) {
31
+ visit(`Block "${type}"`, schema, '');
32
+ }
33
+ if (themeSchemas.settings) {
34
+ visit('Site settings', themeSchemas.settings, '');
35
+ }
36
+ return suggestions;
37
+ }
38
+ // A reference this project's own theme conventions actually produce:
39
+ // src="...", srcset="w1 480w, w2 960w" (comma-separated, each entry a
40
+ // url then a space then a width descriptor - strip the descriptor),
41
+ // href="...". Not a full HTML parser - matching SchemaField.tsx's own
42
+ // "the schema surface here is narrow and flat... a library would be
43
+ // heavier than the problem warrants" precedent for the equivalent
44
+ // choice on the admin side.
45
+ // poster is included deliberately: a video's poster is a real content
46
+ // image and was not being checked at all before.
47
+ const ATTR_PATTERN = /\b(src|href|poster|srcset)="([^"]*)"/g;
48
+ function extractReferences(html) {
49
+ const refs = [];
50
+ for (const match of html.matchAll(ATTR_PATTERN)) {
51
+ const attribute = match[1] ?? '';
52
+ const value = match[2] ?? '';
53
+ if (attribute !== 'srcset') {
54
+ refs.push({ url: value, attribute });
55
+ }
56
+ else {
57
+ for (const entry of value.split(',')) {
58
+ const url = entry.trim().split(/\s+/)[0];
59
+ if (url) {
60
+ refs.push({ url, attribute });
61
+ }
62
+ }
63
+ }
64
+ }
65
+ return refs;
66
+ }
67
+ // Any scheme at all that is not http(s). The previous version listed
68
+ // data: explicitly and nothing else, so mailto: (and tel:, and anything
69
+ // future) fell through to the static-file branch below and was reported
70
+ // as a missing file under theme/root/, purely because the text after
71
+ // the "@" contains a dot. One real site produced 39 such findings and
72
+ // not one true one, which is worse than no check: it teaches whoever
73
+ // reads the output to ignore it.
74
+ function hasExternalScheme(ref) {
75
+ return ref.startsWith('//') || /^[a-z][a-z0-9+.-]*:/i.test(ref);
76
+ }
77
+ // A content photograph or clip, as opposed to a design asset. Keyed off
78
+ // the same extension list the media upload route accepts, so the two
79
+ // cannot drift; .svg is absent from it, which is what keeps an inline
80
+ // logo or icon from being mistaken for misplaced content.
81
+ function isUploadableMedia(path) {
82
+ const lower = path.toLowerCase();
83
+ return [...ALLOWED_UPLOAD_EXTENSIONS].some((extension) => lower.endsWith(extension));
84
+ }
85
+ // True for a root-relative static path that looks like a real file
86
+ // (has a "." in its last path segment - /favicon.ico, /robots.txt),
87
+ // as opposed to a page URL like /about or /blog/hello-world, which
88
+ // never do. Doesn't need to be perfect - a page URL with a literal dot
89
+ // in its own slug is vanishingly unlikely and, worst case, just gets
90
+ // checked against the wrong bucket and reported as the wrong kind of
91
+ // finding, never silently skipped.
92
+ function looksLikeStaticFile(path) {
93
+ const lastSegment = path.split('/').pop() ?? '';
94
+ return lastSegment.includes('.');
95
+ }
96
+ // A rendered reference is theme/content-derived, not a live HTTP
97
+ // request's own :path - but the same traversal concern still applies
98
+ // (constraint 7), so it still goes through sanitisePath rather than a
99
+ // bare join+existsSync. A reference that fails to sanitise (a "../"
100
+ // escaping root) is exactly as real a finding as one that's simply
101
+ // missing - reported the same way, not silently skipped.
102
+ function checkStaticReference(findings, root, relativePath, originalPath, rootLabel, pageUrl) {
103
+ // sanitisePath realpaths its root unconditionally, so a site that has
104
+ // no media/, theme/assets/ or theme/root/ directory at all threw a
105
+ // raw ENOENT out of the whole check rather than reporting anything -
106
+ // the check crashing with a stack trace on exactly the sites most
107
+ // likely to hold a broken reference. A root that does not exist means
108
+ // the file underneath it does not exist either, which is an ordinary
109
+ // finding, not an error.
110
+ if (!existsSync(root)) {
111
+ findings.push({ kind: 'missing-asset', message: `${originalPath} does not exist under ${rootLabel}`, pageUrl });
112
+ return;
113
+ }
114
+ try {
115
+ const filePath = sanitisePath(root, relativePath);
116
+ if (!existsSync(filePath)) {
117
+ findings.push({ kind: 'missing-asset', message: `${originalPath} does not exist under ${rootLabel}`, pageUrl });
118
+ }
119
+ }
120
+ catch (error) {
121
+ if (error instanceof PathSafetyError) {
122
+ findings.push({ kind: 'missing-asset', message: `${originalPath} is not a safe reference under ${rootLabel} (${error.message})`, pageUrl });
123
+ return;
124
+ }
125
+ throw error;
126
+ }
127
+ }
128
+ // The file exists, so nothing else would ever flag it - which is
129
+ // precisely why this needs saying. A content image outside media/ is
130
+ // invisible to the media library, cannot be replaced by an editor, and
131
+ // is not what the CMS manages.
132
+ function misplacedMessage(path, where) {
133
+ return `${path} is a content image served from ${where} - it belongs in media/ (run \`npm run seed-media -- .. <file>\` from vhost/ and use the /media/... URL it prints)`;
134
+ }
135
+ export async function runSiteCheck(siteRoot) {
136
+ const booted = bootSite(siteRoot);
137
+ const findings = [];
138
+ for (const warning of booted.themeSchemas.warnings ?? []) {
139
+ findings.push({ kind: 'schema', message: warning });
140
+ }
141
+ const publishedUrls = buildSitemapUrls(booted.config);
142
+ const publishedUrlSet = new Set(publishedUrls);
143
+ for (const pageUrl of publishedUrls) {
144
+ // urlToPagePath's own result is relative to pagesRoot (e.g.
145
+ // "about.json"), but renderPage's own relativePath is relative to
146
+ // contentRoot - the same "pages/" prefix routes/public.ts's own
147
+ // toRenderPath helper adds before every one of its renderPage
148
+ // calls.
149
+ const relativePath = `pages/${urlToPagePath(pageUrl)}`;
150
+ let html;
151
+ try {
152
+ html = await renderPage(booted.config, booted.themeTemplates, booted.layouts, booted.engine, relativePath, 'public', resolveSiteSettings(booted.config, booted.themeSchemas));
153
+ }
154
+ catch (error) {
155
+ const detail = error instanceof Error ? error.message : String(error);
156
+ findings.push({ kind: 'render-error', message: detail, pageUrl });
157
+ continue;
158
+ }
159
+ for (const { url: ref, attribute } of extractReferences(html)) {
160
+ if (hasExternalScheme(ref)) {
161
+ continue;
162
+ }
163
+ const path = ref.split(/[?#]/)[0] ?? ref;
164
+ // Only src/srcset/poster can carry a content image. An href is a
165
+ // link or a favicon, and flagging those would make this useless.
166
+ const rendersMedia = attribute !== 'href' && isUploadableMedia(path);
167
+ if (path.startsWith('/media/')) {
168
+ checkStaticReference(findings, booted.config.mediaRoot, path.slice('/media/'.length), path, 'media/', pageUrl);
169
+ }
170
+ else if (path.startsWith('/assets/')) {
171
+ if (rendersMedia) {
172
+ findings.push({ kind: 'misplaced-media', message: misplacedMessage(path, 'theme/assets/'), pageUrl });
173
+ }
174
+ checkStaticReference(findings, booted.config.assetsRoot, path.slice('/assets/'.length), path, 'theme/assets/', pageUrl);
175
+ }
176
+ else if (looksLikeStaticFile(path)) {
177
+ if (rendersMedia) {
178
+ findings.push({ kind: 'misplaced-media', message: misplacedMessage(path, 'theme/root/'), pageUrl });
179
+ }
180
+ checkStaticReference(findings, booted.config.rootMirrorRoot, path.slice(1), path, 'theme/root/', pageUrl);
181
+ }
182
+ else if (path !== '' && !publishedUrlSet.has(path)) {
183
+ findings.push({ kind: 'broken-link', message: `${path} does not point at a published page`, pageUrl });
184
+ }
185
+ }
186
+ }
187
+ return { ok: findings.length === 0, findings, suggestions: uriFormatSuggestions(booted.themeSchemas) };
188
+ }
@@ -0,0 +1,23 @@
1
+ export interface SyncArgs {
2
+ positional: string[];
3
+ flags: Set<string>;
4
+ token: string | undefined;
5
+ }
6
+ export declare function parseSyncArgs(argv: string[]): SyncArgs;
7
+ export declare function ask(question: string, hidden?: boolean): Promise<string>;
8
+ export declare const TOKEN_HELP = "The token is the live site's API token (the same one the admin uses), with the \"content\" and \"media\" scopes.";
9
+ export declare function resolveToken(args: SyncArgs, siteUrl: string): Promise<string>;
10
+ export interface PartOption {
11
+ available: boolean;
12
+ detail: string;
13
+ checked: boolean;
14
+ }
15
+ export declare function chooseParts(args: SyncArgs, verb: 'pull' | 'push', options: {
16
+ content: PartOption;
17
+ theme: PartOption;
18
+ cms?: PartOption;
19
+ }): Promise<{
20
+ content: boolean;
21
+ theme: boolean;
22
+ cms: boolean;
23
+ }>;
@@ -0,0 +1,75 @@
1
+ import { PromptCancelledError, createPrompter } from "./prompts.js";
2
+ export function parseSyncArgs(argv) {
3
+ const tokenFlag = argv.indexOf('--token');
4
+ const token = tokenFlag === -1 ? undefined : argv[tokenFlag + 1];
5
+ const rest = argv.filter((_, index) => tokenFlag === -1 || (index !== tokenFlag && index !== tokenFlag + 1));
6
+ return {
7
+ positional: rest.filter((arg) => !arg.startsWith('--')),
8
+ flags: new Set(rest.filter((arg) => arg.startsWith('--'))),
9
+ token,
10
+ };
11
+ }
12
+ // One prompter for the whole run (see createPrompter). Questions and
13
+ // warnings go to stderr, so stdout carries only the result.
14
+ const prompter = createPrompter(process.stdin, process.stderr);
15
+ export async function ask(question, hidden = false) {
16
+ try {
17
+ return await prompter.ask(question, { hidden });
18
+ }
19
+ catch (error) {
20
+ if (error instanceof PromptCancelledError) {
21
+ process.exit(130);
22
+ }
23
+ throw error;
24
+ }
25
+ }
26
+ export const TOKEN_HELP = 'The token is the live site\'s API token (the same one the admin uses), with the "content" and "media" scopes.';
27
+ // CMS_TOKEN and --token still work for scripts, but either leaves the
28
+ // token in shell history; asked for, it goes nowhere.
29
+ export async function resolveToken(args, siteUrl) {
30
+ const token = args.token ?? process.env.CMS_TOKEN ?? (await ask(`API token for ${siteUrl} (hidden): `, true));
31
+ if (!token) {
32
+ console.error('No token entered.');
33
+ console.error(TOKEN_HELP);
34
+ process.exit(1);
35
+ }
36
+ return token;
37
+ }
38
+ // Which of content and theme to include. --content and/or --theme on
39
+ // the command line decide it outright (for scripts); otherwise a
40
+ // checkbox list, showing what each part holds or why it can't be chosen.
41
+ // A CMS upgrade (push only) is offered as a third choice when the CMS
42
+ // here is newer than the live site's; --cms chooses it from a script.
43
+ export async function chooseParts(args, verb, options) {
44
+ const flagged = { content: args.flags.has('--content'), theme: args.flags.has('--theme'), cms: args.flags.has('--cms') };
45
+ if (flagged.content || flagged.theme || flagged.cms) {
46
+ if (flagged.cms && !options.cms?.available) {
47
+ console.error(`Can't push a CMS upgrade: ${options.cms?.detail ?? 'the CMS here is not newer than the live site\'s'}.`);
48
+ process.exit(1);
49
+ }
50
+ for (const part of ['content', 'theme']) {
51
+ if (flagged[part] && !options[part].available) {
52
+ console.error(`Can't ${verb} the ${part}: ${options[part].detail}.`);
53
+ process.exit(1);
54
+ }
55
+ }
56
+ return flagged;
57
+ }
58
+ const choices = [
59
+ { name: 'content', label: 'Content', detail: options.content.detail, checked: options.content.checked, disabled: !options.content.available },
60
+ { name: 'theme', label: 'Theme', detail: options.theme.detail, checked: options.theme.checked, disabled: !options.theme.available },
61
+ ];
62
+ if (options.cms?.available) {
63
+ choices.push({ name: 'cms', label: 'CMS upgrade', detail: options.cms.detail, checked: options.cms.checked });
64
+ }
65
+ try {
66
+ const chosen = new Set(await prompter.choose(`What do you want to ${verb}?`, choices));
67
+ return { content: chosen.has('content'), theme: chosen.has('theme'), cms: chosen.has('cms') };
68
+ }
69
+ catch (error) {
70
+ if (error instanceof PromptCancelledError) {
71
+ process.exit(130);
72
+ }
73
+ throw error;
74
+ }
75
+ }
@@ -0,0 +1,36 @@
1
+ export type DeployMethod = {
2
+ kind: 'command';
3
+ command: string;
4
+ } | {
5
+ kind: 'railway';
6
+ } | {
7
+ kind: 'fly';
8
+ } | {
9
+ kind: 'manual';
10
+ };
11
+ export interface DeployDeps {
12
+ succeeds: (program: string, args: string[], cwd: string) => boolean;
13
+ exists: (path: string) => boolean;
14
+ readText: (path: string) => string;
15
+ }
16
+ export declare const defaultDeployDeps: DeployDeps;
17
+ export declare function detectDeployMethod(siteRoot: string, deps?: DeployDeps): DeployMethod;
18
+ export declare function deployedVersionProblem(siteRoot: string, installedVersion: string, deps?: Pick<DeployDeps, 'readText'>): string | null;
19
+ export declare function describeDeployMethod(method: DeployMethod): string;
20
+ export declare function runDeploy(method: Exclude<DeployMethod, {
21
+ kind: 'manual';
22
+ }>, siteRoot: string): boolean;
23
+ export interface WaitOptions {
24
+ fetchImpl?: typeof fetch;
25
+ sleep?: (ms: number) => Promise<void>;
26
+ intervalMs?: number;
27
+ timeoutMs?: number;
28
+ onPoll?: (seen: string | null) => void;
29
+ }
30
+ export declare function waitForLiveVersion(siteUrl: string, version: string, options?: WaitOptions): Promise<{
31
+ ok: true;
32
+ } | {
33
+ ok: false;
34
+ lastSeen: string | null;
35
+ reason: string;
36
+ }>;
@@ -0,0 +1,124 @@
1
+ import { execFileSync, spawnSync } from 'node:child_process';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { PACKAGE_NAME, requestedVersion } from "../upgrade/package-names.js";
5
+ import { readCapabilities } from "./live-content.js";
6
+ export const defaultDeployDeps = {
7
+ succeeds: (program, args, cwd) => {
8
+ try {
9
+ execFileSync(program, args, { cwd, stdio: 'ignore' });
10
+ return true;
11
+ }
12
+ catch {
13
+ return false;
14
+ }
15
+ },
16
+ exists: existsSync,
17
+ readText: (path) => readFileSync(path, 'utf-8'),
18
+ };
19
+ // In order: the site's own vhost/deploy.json (set once, for any host -
20
+ // "git push", an ssh command...), then a site linked to Railway, then
21
+ // one set up for Fly, and otherwise the developer deploys by hand.
22
+ export function detectDeployMethod(siteRoot, deps = defaultDeployDeps) {
23
+ const deployJson = join(siteRoot, 'vhost', 'deploy.json');
24
+ if (deps.exists(deployJson)) {
25
+ let parsed;
26
+ try {
27
+ parsed = JSON.parse(deps.readText(deployJson));
28
+ }
29
+ catch {
30
+ throw new Error('vhost/deploy.json is not valid JSON. It should look like { "command": "git push" }.');
31
+ }
32
+ const command = parsed?.command;
33
+ if (typeof command !== 'string' || command.trim() === '') {
34
+ throw new Error('vhost/deploy.json needs a "command", for example { "command": "git push" }.');
35
+ }
36
+ return { kind: 'command', command };
37
+ }
38
+ if (deps.succeeds('railway', ['status'], siteRoot)) {
39
+ return { kind: 'railway' };
40
+ }
41
+ if (deps.exists(join(siteRoot, 'fly.toml')) && deps.succeeds('fly', ['version'], siteRoot)) {
42
+ return { kind: 'fly' };
43
+ }
44
+ return { kind: 'manual' };
45
+ }
46
+ // A deploy builds the CMS version vhost/package.json names, not the one
47
+ // installed here - so if the two disagree (package.json put back after
48
+ // an upgrade, seen on a real site), the live site comes back on the old
49
+ // version and push waits in vain. Returns what's wrong, or null. Only an
50
+ // exact version is checked: a range or a local link can't be judged
51
+ // from here, and the scaffold always pins one.
52
+ export function deployedVersionProblem(siteRoot, installedVersion, deps = defaultDeployDeps) {
53
+ let wanted;
54
+ try {
55
+ wanted = requestedVersion(JSON.parse(deps.readText(join(siteRoot, 'vhost', 'package.json'))).dependencies);
56
+ }
57
+ catch {
58
+ return null;
59
+ }
60
+ if (typeof wanted !== 'string' || !/^\d+\.\d+\.\d+$/.test(wanted) || wanted === installedVersion) {
61
+ return null;
62
+ }
63
+ return `vhost/package.json asks for CMS ${wanted}, but ${installedVersion} is installed here. A deploy builds what package.json asks for, so the live site would stay on ${wanted}. Set "${PACKAGE_NAME}" to "${installedVersion}" in vhost/package.json (or run npm run upgrade again), then push.`;
64
+ }
65
+ export function describeDeployMethod(method) {
66
+ switch (method.kind) {
67
+ case 'command':
68
+ return `the command in vhost/deploy.json (${method.command})`;
69
+ case 'railway':
70
+ return 'Railway (railway up)';
71
+ case 'fly':
72
+ return 'Fly.io (fly deploy)';
73
+ case 'manual':
74
+ return 'you, with your host\'s usual deploy command';
75
+ }
76
+ }
77
+ // Runs the deploy, showing its own output. Returns false if it failed.
78
+ // Manual deploys are handled by the caller (it has to ask).
79
+ export function runDeploy(method, siteRoot) {
80
+ const result = method.kind === 'command'
81
+ ? spawnSync(method.command, { cwd: siteRoot, stdio: 'inherit', shell: true })
82
+ : method.kind === 'railway'
83
+ ? spawnSync('railway', ['up', '--no-gitignore', '--ci'], { cwd: siteRoot, stdio: 'inherit' })
84
+ : spawnSync('fly', ['deploy', '--dockerfile', 'vhost/Dockerfile'], { cwd: siteRoot, stdio: 'inherit' });
85
+ return result.status === 0;
86
+ }
87
+ // Waits for the live site to report the new version, then checks its
88
+ // home page loads. Returns what was last seen if it never got there.
89
+ export async function waitForLiveVersion(siteUrl, version, options = {}) {
90
+ const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
91
+ const intervalMs = options.intervalMs ?? 5_000;
92
+ const timeoutMs = options.timeoutMs ?? 10 * 60_000;
93
+ const fetchImpl = options.fetchImpl ?? fetch;
94
+ let lastSeen = null;
95
+ for (let waited = 0; waited <= timeoutMs; waited += intervalMs) {
96
+ try {
97
+ lastSeen = (await readCapabilities(siteUrl, fetchImpl)).agentVersion;
98
+ }
99
+ catch {
100
+ // Restarting: not answering yet is expected.
101
+ lastSeen = null;
102
+ }
103
+ options.onPoll?.(lastSeen);
104
+ if (lastSeen === version) {
105
+ try {
106
+ const home = await fetchImpl(new URL('/', siteUrl), { signal: AbortSignal.timeout(30_000) });
107
+ if (home.ok) {
108
+ return { ok: true };
109
+ }
110
+ return { ok: false, lastSeen, reason: `the live site is on ${version}, but its home page returned ${home.status}` };
111
+ }
112
+ catch (error) {
113
+ const detail = error instanceof Error ? error.message : String(error);
114
+ return { ok: false, lastSeen, reason: `the live site is on ${version}, but its home page didn't load (${detail})` };
115
+ }
116
+ }
117
+ await sleep(intervalMs);
118
+ }
119
+ return {
120
+ ok: false,
121
+ lastSeen,
122
+ reason: lastSeen === null ? 'the live site stopped answering' : `the live site is still on ${lastSeen}`,
123
+ };
124
+ }
@@ -0,0 +1,30 @@
1
+ import type { RedirectEntry } from '../services/redirects.ts';
2
+ import { RemoteSite } from './remote-site.ts';
3
+ export declare const CONTENT_PATH: RegExp;
4
+ export declare const MEDIA_NAME: RegExp;
5
+ export declare function encodePath(path: string): string;
6
+ export interface SiteCapabilities {
7
+ agentVersion: string;
8
+ contentSchemaVersion: number;
9
+ }
10
+ export declare function readCapabilities(siteUrl: string, fetchImpl?: typeof fetch): Promise<SiteCapabilities>;
11
+ export interface LiveFile {
12
+ bytes: Buffer;
13
+ etag: string;
14
+ }
15
+ export interface LiveSettings {
16
+ values: Record<string, unknown>;
17
+ etag: string | null;
18
+ }
19
+ export interface LiveContent {
20
+ live: Map<string, LiveFile>;
21
+ drafts: Map<string, Buffer>;
22
+ redirects: RedirectEntry[];
23
+ settings: LiveSettings | null;
24
+ }
25
+ export declare function fetchLiveContent(remote: RemoteSite): Promise<LiveContent>;
26
+ export interface LiveMediaItem {
27
+ name: string;
28
+ size: number;
29
+ }
30
+ export declare function fetchLiveMedia(remote: RemoteSite): Promise<LiveMediaItem[]>;
@@ -0,0 +1,68 @@
1
+ import { RemoteSite, SiteSyncError } from "./remote-site.js";
2
+ // Every path from the server is untrusted input that becomes a local
3
+ // file path (pull) or is compared against one (push). Checked here for
4
+ // shape - a pages/ or menus/ JSON file, no dot segments; a flat media
5
+ // name - and again by sanitisePath before any local write.
6
+ export const CONTENT_PATH = /^(pages|menus)\/(?:[A-Za-z0-9_-][A-Za-z0-9._-]*\/)*[A-Za-z0-9_-][A-Za-z0-9._-]*\.json$/;
7
+ export const MEDIA_NAME = /^[A-Za-z0-9_-][A-Za-z0-9._-]*$/;
8
+ export function encodePath(path) {
9
+ return path.split('/').map(encodeURIComponent).join('/');
10
+ }
11
+ // GET /v1/capabilities is public, so an address can be checked before a
12
+ // token is asked for.
13
+ export async function readCapabilities(siteUrl, fetchImpl) {
14
+ const body = (await new RemoteSite(siteUrl, '', { fetchImpl }).getJson('/v1/capabilities', { auth: false }));
15
+ if (typeof body.agentVersion !== 'string' || typeof body.contentSchemaVersion !== 'number') {
16
+ throw new SiteSyncError('not-a-site', `${siteUrl} is not a Minamo site`);
17
+ }
18
+ return { agentVersion: body.agentVersion, contentSchemaVersion: body.contentSchemaVersion };
19
+ }
20
+ export async function fetchLiveContent(remote) {
21
+ const listed = await remote.getJson('/v1/content');
22
+ if (!Array.isArray(listed)) {
23
+ throw new SiteSyncError('fetch-failed', 'GET /v1/content did not return a list');
24
+ }
25
+ const live = new Map();
26
+ const drafts = new Map();
27
+ for (const entry of listed) {
28
+ const path = entry.path;
29
+ if (typeof path !== 'string' || !CONTENT_PATH.test(path)) {
30
+ throw new SiteSyncError('unsafe-path', `The live site listed an unexpected content path: ${JSON.stringify(path)}`);
31
+ }
32
+ const file = await remote.getFile(`/v1/content/${encodePath(path)}`);
33
+ if (file !== null) {
34
+ live.set(path, file);
35
+ }
36
+ if (entry.hasDraft === true) {
37
+ const draft = await remote.getBytes(`/v1/drafts/${encodePath(path)}`, { allow404: true });
38
+ if (draft !== null) {
39
+ drafts.set(path, draft);
40
+ }
41
+ }
42
+ }
43
+ const redirectsBody = (await remote.getJson('/v1/redirects'));
44
+ const redirects = Array.isArray(redirectsBody.entries) ? redirectsBody.entries : [];
45
+ let settings = null;
46
+ const settingsResponse = await remote.get('/v1/settings', { allow404: true });
47
+ if (settingsResponse !== null) {
48
+ const body = (await settingsResponse.json().catch(() => null));
49
+ if (!body || typeof body.settings !== 'object' || body.settings === null || Array.isArray(body.settings)) {
50
+ throw new SiteSyncError('fetch-failed', 'GET /v1/settings did not return settings');
51
+ }
52
+ settings = { values: body.settings, etag: settingsResponse.headers.get('etag') };
53
+ }
54
+ return { live, drafts, redirects, settings };
55
+ }
56
+ export async function fetchLiveMedia(remote) {
57
+ const listed = await remote.getJson('/v1/media');
58
+ if (!Array.isArray(listed)) {
59
+ throw new SiteSyncError('fetch-failed', 'GET /v1/media did not return a list');
60
+ }
61
+ return listed.map((item) => {
62
+ const { name, size } = item;
63
+ if (typeof name !== 'string' || !MEDIA_NAME.test(name)) {
64
+ throw new SiteSyncError('unsafe-path', `The live site listed an unexpected media name: ${JSON.stringify(name)}`);
65
+ }
66
+ return { name, size: typeof size === 'number' ? size : -1 };
67
+ });
68
+ }
@@ -0,0 +1,30 @@
1
+ export interface PromptInput {
2
+ isTTY?: boolean;
3
+ setRawMode?: (mode: boolean) => unknown;
4
+ setEncoding: (encoding: BufferEncoding) => unknown;
5
+ on: (event: 'data' | 'end', listener: (chunk?: string) => void) => unknown;
6
+ removeAllListeners: (event: 'data' | 'end') => unknown;
7
+ pause: () => unknown;
8
+ resume: () => unknown;
9
+ }
10
+ export interface PromptOutput {
11
+ write: (text: string) => unknown;
12
+ }
13
+ export declare class PromptCancelledError extends Error {
14
+ constructor();
15
+ }
16
+ export interface Choice {
17
+ name: string;
18
+ label: string;
19
+ detail?: string;
20
+ checked: boolean;
21
+ disabled?: boolean;
22
+ }
23
+ export interface Prompter {
24
+ ask: (question: string, options?: {
25
+ hidden?: boolean;
26
+ }) => Promise<string>;
27
+ choose: (question: string, choices: Choice[]) => Promise<string[]>;
28
+ }
29
+ export declare function createPrompter(input: PromptInput, output: PromptOutput): Prompter;
30
+ export declare function normaliseSiteUrl(value: string): string | null;