@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,31 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { sanitisePath } from "./path-safety.js";
3
+ import { buildRedirectLookup, loadRedirects } from "./redirects.js";
4
+ import { urlToPostPath } from "./post-urls.js";
5
+ // Mirrors resolve-url.ts's shape exactly (a live post always wins over
6
+ // a redirect at the same URL), kept as its own distinct result type
7
+ // rather than reusing ResolvedUrl - clearer branching at the call site
8
+ // and avoids touching Group C's already-tested resolve-url.ts.
9
+ //
10
+ // Unlike pagesRoot (always expected to exist on a real site),
11
+ // postsRoot is optional - a site that has never used blog posts has
12
+ // no content/posts/ directory at all. sanitisePath calls realpathSync
13
+ // directly on its root argument, which throws a raw, uncaught ENOENT
14
+ // if that root itself is missing - so postsRoot's existence is checked
15
+ // first, before ever calling sanitisePath, rather than letting a
16
+ // perfectly ordinary "no posts yet" site crash on its first /blog/ hit.
17
+ export function resolveBlogUrl(config, url) {
18
+ const relativePath = urlToPostPath(url);
19
+ if (relativePath !== null && existsSync(config.postsRoot)) {
20
+ const postFile = sanitisePath(config.postsRoot, relativePath);
21
+ if (existsSync(postFile)) {
22
+ return { kind: 'post', relativePath };
23
+ }
24
+ }
25
+ const lookup = buildRedirectLookup(loadRedirects(config).entries);
26
+ const to = lookup.get(url);
27
+ if (to !== undefined) {
28
+ return { kind: 'redirect', to };
29
+ }
30
+ return { kind: 'not-found' };
31
+ }
@@ -0,0 +1,11 @@
1
+ import type { SiteConfig } from '../config.ts';
2
+ export type ResolvedUrl = {
3
+ kind: 'page';
4
+ relativePath: string;
5
+ } | {
6
+ kind: 'redirect';
7
+ to: string;
8
+ } | {
9
+ kind: 'not-found';
10
+ };
11
+ export declare function resolveUrl(config: SiteConfig, url: string): ResolvedUrl;
@@ -0,0 +1,20 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { sanitisePath } from "./path-safety.js";
3
+ import { buildRedirectLookup, loadRedirects } from "./redirects.js";
4
+ import { urlToPagePath } from "./urls.js";
5
+ // A redirect whose "from" matches a live page is never served: the
6
+ // page always wins (E6). Only consults redirects.json when no live
7
+ // page exists at the requested URL.
8
+ export function resolveUrl(config, url) {
9
+ const relativePath = urlToPagePath(url);
10
+ const pageFile = sanitisePath(config.pagesRoot, relativePath);
11
+ if (existsSync(pageFile)) {
12
+ return { kind: 'page', relativePath };
13
+ }
14
+ const lookup = buildRedirectLookup(loadRedirects(config).entries);
15
+ const to = lookup.get(url);
16
+ if (to !== undefined) {
17
+ return { kind: 'redirect', to };
18
+ }
19
+ return { kind: 'not-found' };
20
+ }
@@ -0,0 +1,28 @@
1
+ import type { SiteConfig } from '../config.ts';
2
+ import type { CommitAuthor } from './git.ts';
3
+ import { type ThemeSchemas } from './validation.ts';
4
+ export type SiteSettings = Record<string, unknown>;
5
+ export declare function siteSettingsPath(config: SiteConfig): string;
6
+ export interface StoredSiteSettings {
7
+ settings: SiteSettings;
8
+ etag: string | null;
9
+ }
10
+ export declare function readSiteSettings(config: SiteConfig): StoredSiteSettings;
11
+ export declare function resolveSiteSettings(config: SiteConfig, themeSchemas: ThemeSchemas): SiteSettings;
12
+ export declare function resolveSiteSettingsFrom(saved: SiteSettings, themeSchemas: ThemeSchemas): SiteSettings;
13
+ export declare function siteSettingsMtimeMs(config: SiteConfig): number;
14
+ export type SaveSiteSettingsReason = 'validation-failed' | 'conflict' | 'commit-failed' | 'rollback-failed';
15
+ export declare class SaveSiteSettingsError extends Error {
16
+ readonly reason: SaveSiteSettingsReason;
17
+ readonly errors: Array<{
18
+ path: string;
19
+ message: string;
20
+ }>;
21
+ constructor(reason: SaveSiteSettingsReason, message: string, errors?: Array<{
22
+ path: string;
23
+ message: string;
24
+ }>, options?: {
25
+ cause?: unknown;
26
+ });
27
+ }
28
+ export declare function saveSiteSettings(config: SiteConfig, themeSchemas: ThemeSchemas, settings: unknown, expectedEtag: string, message: string, author: CommitAuthor): Promise<string>;
@@ -0,0 +1,116 @@
1
+ import { existsSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { CURRENT_SCHEMA_VERSION } from "../migrations/index.js";
3
+ import { computeEtag, etagsMatch } from "./etag.js";
4
+ import { commitPaths } from "./git.js";
5
+ import { sanitisePath } from "./path-safety.js";
6
+ import { validateSiteSettings } from "./validation.js";
7
+ import { enqueue } from "./write-queue.js";
8
+ export function siteSettingsPath(config) {
9
+ return sanitisePath(config.contentRoot, 'settings.json');
10
+ }
11
+ // A settings.json that isn't a readable object counts as empty rather
12
+ // than breaking every page: settings only ever fill in a theme's
13
+ // defaults, and the admin shows the file's problem when it's saved.
14
+ export function readSiteSettings(config) {
15
+ const path = siteSettingsPath(config);
16
+ if (!existsSync(path)) {
17
+ return { settings: {}, etag: null };
18
+ }
19
+ const bytes = readFileSync(path);
20
+ let settings = {};
21
+ try {
22
+ const parsed = JSON.parse(bytes.toString('utf-8'));
23
+ if (typeof parsed.settings === 'object' && parsed.settings !== null && !Array.isArray(parsed.settings)) {
24
+ settings = parsed.settings;
25
+ }
26
+ }
27
+ catch {
28
+ settings = {};
29
+ }
30
+ return { settings, etag: computeEtag(bytes) };
31
+ }
32
+ // What templates see as `settings`: every setting the theme defines,
33
+ // with its default where nothing has been saved. Only the theme's own
34
+ // settings - a saved value for a setting the theme no longer defines is
35
+ // left out.
36
+ export function resolveSiteSettings(config, themeSchemas) {
37
+ return resolveSiteSettingsFrom(readSiteSettings(config).settings, themeSchemas);
38
+ }
39
+ // The same, from values given rather than read from disk: the preview
40
+ // of settings an editor has changed but not saved yet.
41
+ export function resolveSiteSettingsFrom(saved, themeSchemas) {
42
+ const properties = themeSchemas.settings?.properties;
43
+ if (!properties) {
44
+ return {};
45
+ }
46
+ const resolved = {};
47
+ for (const [name, property] of Object.entries(properties)) {
48
+ if (name in saved) {
49
+ resolved[name] = saved[name];
50
+ }
51
+ else if (property && typeof property === 'object' && 'default' in property) {
52
+ resolved[name] = property.default;
53
+ }
54
+ }
55
+ return resolved;
56
+ }
57
+ // For the render cache: pages depend on settings like they depend on
58
+ // menus, so a save must make every cached page stale. 0 when there is
59
+ // no settings.json.
60
+ export function siteSettingsMtimeMs(config) {
61
+ try {
62
+ return statSync(siteSettingsPath(config)).mtimeMs;
63
+ }
64
+ catch {
65
+ return 0;
66
+ }
67
+ }
68
+ export class SaveSiteSettingsError extends Error {
69
+ reason;
70
+ errors;
71
+ constructor(reason, message, errors = [], options) {
72
+ super(message, options);
73
+ this.name = 'SaveSiteSettingsError';
74
+ this.reason = reason;
75
+ this.errors = errors;
76
+ }
77
+ }
78
+ async function saveSiteSettingsJob(config, themeSchemas, settings, expectedEtag, message, author) {
79
+ // Checked inside the queued job, never before it (the same TOCTOU
80
+ // reasoning as drafts.ts and manage-menus.ts). No file yet means
81
+ // nothing to conflict with.
82
+ const current = readSiteSettings(config);
83
+ if (current.etag !== null && !etagsMatch(current.etag, expectedEtag)) {
84
+ throw new SaveSiteSettingsError('conflict', 'The site settings changed since you opened them');
85
+ }
86
+ const result = validateSiteSettings(settings, themeSchemas);
87
+ if (!result.valid) {
88
+ throw new SaveSiteSettingsError('validation-failed', `Site settings failed validation: ${result.errors.map((error) => `${error.path || '/'} ${error.message}`).join('; ')}`, result.errors);
89
+ }
90
+ const path = siteSettingsPath(config);
91
+ const before = existsSync(path) ? readFileSync(path) : null;
92
+ const bytes = Buffer.from(`${JSON.stringify({ schemaVersion: CURRENT_SCHEMA_VERSION, settings }, null, 2)}\n`);
93
+ writeFileSync(path, bytes);
94
+ try {
95
+ commitPaths(config.siteRoot, [path], message, author);
96
+ }
97
+ catch (error) {
98
+ try {
99
+ if (before === null) {
100
+ unlinkSync(path);
101
+ }
102
+ else {
103
+ writeFileSync(path, before);
104
+ }
105
+ }
106
+ catch (rollbackError) {
107
+ throw new SaveSiteSettingsError('rollback-failed', 'Saving the site settings failed and putting them back also failed; content/settings.json needs manual inspection', [], { cause: rollbackError });
108
+ }
109
+ const detail = error instanceof Error ? error.message : String(error);
110
+ throw new SaveSiteSettingsError('commit-failed', `Saving the site settings failed: ${detail}`, [], { cause: error });
111
+ }
112
+ return computeEtag(bytes);
113
+ }
114
+ export function saveSiteSettings(config, themeSchemas, settings, expectedEtag, message, author) {
115
+ return enqueue(() => saveSiteSettingsJob(config, themeSchemas, settings, expectedEtag, message, author));
116
+ }
@@ -0,0 +1,10 @@
1
+ export type StartupCheckReason = 'node-version-too-low' | 'site-root-missing' | 'git-binary-absent' | 'not-a-git-repo' | 'invalid-site-config' | 'invalid-token-config';
2
+ export declare class StartupCheckError extends Error {
3
+ readonly reason: StartupCheckReason;
4
+ constructor(reason: StartupCheckReason, message: string);
5
+ }
6
+ export interface StartupCheckOptions {
7
+ env?: NodeJS.ProcessEnv;
8
+ nodeVersion?: string;
9
+ }
10
+ export declare function runStartupChecks(siteRoot: string, options?: StartupCheckOptions): void;
@@ -0,0 +1,81 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { readFileSync, statSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ export class StartupCheckError extends Error {
5
+ reason;
6
+ constructor(reason, message) {
7
+ super(message);
8
+ this.name = 'StartupCheckError';
9
+ this.reason = reason;
10
+ }
11
+ }
12
+ function parseVersion(version) {
13
+ const [major = 0, minor = 0, patch = 0] = version
14
+ .replace(/^v/, '')
15
+ .split('.')
16
+ .map((part) => Number.parseInt(part, 10) || 0);
17
+ return { major, minor, patch };
18
+ }
19
+ function isBelow(version, floor) {
20
+ if (version.major !== floor.major) {
21
+ return version.major < floor.major;
22
+ }
23
+ if (version.minor !== floor.minor) {
24
+ return version.minor < floor.minor;
25
+ }
26
+ return version.patch < floor.patch;
27
+ }
28
+ // Reads the Node version floor from the agent's own package.json rather
29
+ // than hardcoding a second literal here, so the dev-toolchain minimum
30
+ // (package.json engines.node, driven by --experimental-strip-types) and
31
+ // the shipped-agent's runtime minimum can't drift apart by accident.
32
+ // This is the agent's own bundled file, not site data, so resolving it
33
+ // relative to this module's own location is correct here, the same way
34
+ // validation.ts resolves its own schema files.
35
+ function readNodeVersionFloor() {
36
+ const packageJsonPath = join(import.meta.dirname, '..', '..', 'package.json');
37
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
38
+ const floor = packageJson.engines?.node;
39
+ if (!floor) {
40
+ throw new Error('package.json is missing engines.node');
41
+ }
42
+ return floor.replace(/^[^\d]*/, '');
43
+ }
44
+ // Fail-fast, in this specific order. Checking "is a git repo" before
45
+ // confirming the site root exists would make a missing root fail with
46
+ // the same ENOENT as "git binary absent" (both execFileSync failures),
47
+ // which would misreport one case as the other.
48
+ export function runStartupChecks(siteRoot, options = {}) {
49
+ const nodeVersion = options.nodeVersion ?? process.version;
50
+ const floor = parseVersion(readNodeVersionFloor());
51
+ if (isBelow(parseVersion(nodeVersion), floor)) {
52
+ throw new StartupCheckError('node-version-too-low', `Node ${readNodeVersionFloor()} or later is required, found ${nodeVersion}`);
53
+ }
54
+ try {
55
+ if (!statSync(siteRoot).isDirectory()) {
56
+ throw new Error('not a directory');
57
+ }
58
+ }
59
+ catch {
60
+ throw new StartupCheckError('site-root-missing', `Site root does not exist or is not a directory: ${siteRoot}`);
61
+ }
62
+ const env = options.env ?? process.env;
63
+ try {
64
+ // Deliberately not run with cwd: siteRoot, so this check never
65
+ // depends on the site root existing.
66
+ execFileSync('git', ['--version'], { env, stdio: 'ignore' });
67
+ }
68
+ catch {
69
+ throw new StartupCheckError('git-binary-absent', 'git binary not found on PATH');
70
+ }
71
+ try {
72
+ execFileSync('git', ['rev-parse', '--is-inside-work-tree'], {
73
+ cwd: siteRoot,
74
+ env,
75
+ stdio: 'ignore',
76
+ });
77
+ }
78
+ catch {
79
+ throw new StartupCheckError('not-a-git-repo', `Site root is not a git repository: ${siteRoot}`);
80
+ }
81
+ }
@@ -0,0 +1,7 @@
1
+ import type { FastifyReply } from 'fastify';
2
+ export interface StaticFileMatch {
3
+ fullPath: string;
4
+ contentType: string;
5
+ }
6
+ export declare function findStaticFile(root: string, relativePath: string): StaticFileMatch | null;
7
+ export declare function sendStaticFile(reply: FastifyReply, match: StaticFileMatch): void;
@@ -0,0 +1,37 @@
1
+ import { existsSync, readFileSync, statSync } from 'node:fs';
2
+ import { mimeTypeFor } from "./mime-types.js";
3
+ import { PathSafetyError, sanitisePath } from "./path-safety.js";
4
+ export function findStaticFile(root, relativePath) {
5
+ // sanitisePath's own symlink-escape check calls realpathSync on the
6
+ // root itself, which throws (a raw ENOENT, not a PathSafetyError) if
7
+ // the root doesn't exist at all. theme/assets/ is always created by
8
+ // scaffoldSite, but theme/root/ is genuinely optional (found live:
9
+ // this crashed with a 500 on a fixture site with no theme/root/ at
10
+ // all) - checked here rather than in sanitisePath itself, since
11
+ // every other caller of that shared, security-audited function
12
+ // legitimately expects its root to exist.
13
+ if (!existsSync(root)) {
14
+ return null;
15
+ }
16
+ try {
17
+ const fullPath = sanitisePath(root, relativePath);
18
+ if (!existsSync(fullPath) || !statSync(fullPath).isFile()) {
19
+ return null;
20
+ }
21
+ return { fullPath, contentType: mimeTypeFor(fullPath) };
22
+ }
23
+ catch (error) {
24
+ if (error instanceof PathSafetyError) {
25
+ return null;
26
+ }
27
+ throw error;
28
+ }
29
+ }
30
+ // Access-Control-Allow-Origin: * - both callers are already
31
+ // deliberately unauthenticated, public routes (see assets.ts's own
32
+ // comment for the full reasoning: a font loaded via @font-face
33
+ // enforces CORS unconditionally, and the admin's preview route makes
34
+ // genuinely cross-origin requests for paths that look same-origin).
35
+ export function sendStaticFile(reply, match) {
36
+ reply.header('Access-Control-Allow-Origin', '*').type(match.contentType).send(readFileSync(match.fullPath));
37
+ }
@@ -0,0 +1,6 @@
1
+ export interface ParsedThemeComponentFile {
2
+ markup: string;
3
+ schema: object;
4
+ }
5
+ export declare function parseThemeComponentFile(source: string): ParsedThemeComponentFile | null;
6
+ export declare function stripSchemaBlock(source: string): string;
@@ -0,0 +1,39 @@
1
+ // {% schema %} ... {% endschema %} embedded in a single .liquid file,
2
+ // matching Shopify's own theme-section convention - the natural fit
3
+ // since this project already deliberately mirrors Shopify-style
4
+ // sections and blocks. Never a real, live Liquid tag (CLAUDE.md: no
5
+ // dynamically registered Liquid tags or filters, ever) - extracted by
6
+ // plain string parsing before the file is ever handed to the Liquid
7
+ // engine, and stripped out of what actually gets rendered.
8
+ const SCHEMA_BLOCK_PATTERN = /\{%-?\s*schema\s*-?%\}([\s\S]*?)\{%-?\s*endschema\s*-?%\}/;
9
+ // Returns null if no schema block is found, or its content isn't
10
+ // valid JSON - callers treat this the same as today's "file missing/
11
+ // unreadable" case (skip, don't throw). A deliberate behaviour change
12
+ // from the old two-file shape: previously markup and schema loaded and
13
+ // failed independently; now they're one file, so a broken schema block
14
+ // fails the whole component, not just the schema half.
15
+ export function parseThemeComponentFile(source) {
16
+ const match = SCHEMA_BLOCK_PATTERN.exec(source);
17
+ if (!match) {
18
+ return null;
19
+ }
20
+ let schema;
21
+ try {
22
+ schema = JSON.parse(match[1]);
23
+ }
24
+ catch {
25
+ return null;
26
+ }
27
+ if (typeof schema !== 'object' || schema === null) {
28
+ return null;
29
+ }
30
+ const markup = source.slice(0, match.index) + source.slice(match.index + match[0].length);
31
+ return { markup, schema };
32
+ }
33
+ // The markup Liquid actually sees: {% schema %} is never a real, live
34
+ // Liquid tag, so it is removed before anything is parsed - including
35
+ // when a pushed theme's syntax is checked (theme-files.ts).
36
+ export function stripSchemaBlock(source) {
37
+ const match = SCHEMA_BLOCK_PATTERN.exec(source);
38
+ return match ? source.slice(0, match.index) + source.slice(match.index + match[0].length) : source;
39
+ }
@@ -0,0 +1,33 @@
1
+ import type { SiteConfig } from '../config.ts';
2
+ import type { ThemeState } from '../theme-state.ts';
3
+ import type { CommitAuthor } from './git.ts';
4
+ export declare function isValidThemePath(path: string): boolean;
5
+ export declare function hashThemeFile(bytes: Buffer): string;
6
+ export interface ThemeFileEntry {
7
+ path: string;
8
+ size: number;
9
+ hash: string;
10
+ }
11
+ export declare function listThemeFiles(config: SiteConfig): ThemeFileEntry[];
12
+ export declare function readThemeFile(config: SiteConfig, path: string): Buffer | null;
13
+ export type ThemePushReason = 'invalid-path' | 'conflict' | 'invalid-liquid' | 'write-failed' | 'commit-failed' | 'rollback-failed';
14
+ export declare class ThemePushError extends Error {
15
+ readonly reason: ThemePushReason;
16
+ constructor(reason: ThemePushReason, message: string, options?: {
17
+ cause?: unknown;
18
+ });
19
+ }
20
+ export interface ThemeWrite {
21
+ path: string;
22
+ bytes: Buffer;
23
+ expected: string | null;
24
+ }
25
+ export interface ThemeDelete {
26
+ path: string;
27
+ expected: string;
28
+ }
29
+ export interface ThemePushResult {
30
+ generation: number;
31
+ warnings: string[];
32
+ }
33
+ export declare function pushTheme(config: SiteConfig, theme: ThemeState, writes: ThemeWrite[], deletes: ThemeDelete[], message: string, author: CommitAuthor): Promise<ThemePushResult>;
@@ -0,0 +1,148 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
3
+ import { dirname, relative } from 'node:path';
4
+ import { createEngine } from "../renderer/engine.js";
5
+ import { listFilesRecursively } from "./fs-walk.js";
6
+ import { commitPaths } from "./git.js";
7
+ import { sanitisePath } from "./path-safety.js";
8
+ import { stripSchemaBlock } from "./theme-component-file.js";
9
+ import { enqueue } from "./write-queue.js";
10
+ // A theme file path, relative to theme/: folders and a file name, no dot
11
+ // segments and no hidden files (.DS_Store and the like are never part of
12
+ // a theme).
13
+ const THEME_PATH = /^(?:[A-Za-z0-9_-][A-Za-z0-9._-]*\/)*[A-Za-z0-9_-][A-Za-z0-9._-]*$/;
14
+ export function isValidThemePath(path) {
15
+ return THEME_PATH.test(path);
16
+ }
17
+ export function hashThemeFile(bytes) {
18
+ return createHash('sha256').update(bytes).digest('hex');
19
+ }
20
+ export function listThemeFiles(config) {
21
+ return listFilesRecursively(config.themeRoot, config.themeRoot, '')
22
+ .filter(isValidThemePath)
23
+ .sort()
24
+ .map((path) => {
25
+ const full = sanitisePath(config.themeRoot, path);
26
+ const bytes = readFileSync(full);
27
+ return { path, size: statSync(full).size, hash: hashThemeFile(bytes) };
28
+ });
29
+ }
30
+ export function readThemeFile(config, path) {
31
+ if (!isValidThemePath(path)) {
32
+ return null;
33
+ }
34
+ const full = sanitisePath(config.themeRoot, path);
35
+ return existsSync(full) ? readFileSync(full) : null;
36
+ }
37
+ export class ThemePushError extends Error {
38
+ reason;
39
+ constructor(reason, message, options) {
40
+ super(message, options);
41
+ this.name = 'ThemePushError';
42
+ this.reason = reason;
43
+ }
44
+ }
45
+ function currentHash(path) {
46
+ return existsSync(path) ? hashThemeFile(readFileSync(path)) : null;
47
+ }
48
+ // Only syntax: a template that can't be parsed breaks every page using
49
+ // it, so it is refused. Anything that parses is left to render as the
50
+ // theme author wrote it. Parsed exactly as the site will: the agent's
51
+ // own engine settings, with any {% schema %} block removed first.
52
+ function checkLiquid(writes) {
53
+ const parser = createEngine({});
54
+ for (const write of writes) {
55
+ if (!write.path.endsWith('.liquid')) {
56
+ continue;
57
+ }
58
+ try {
59
+ parser.parse(stripSchemaBlock(write.bytes.toString('utf-8')));
60
+ }
61
+ catch (error) {
62
+ const detail = error instanceof Error ? error.message : String(error);
63
+ throw new ThemePushError('invalid-liquid', `theme/${write.path} is not valid Liquid: ${detail}`);
64
+ }
65
+ }
66
+ }
67
+ // Writes and deletes theme files as one change: every file is checked
68
+ // against what the client expected first (so a file changed on the site
69
+ // since the client last pulled it is never overwritten), then everything
70
+ // is written, committed once, and the running theme reloaded. If the
71
+ // commit fails, every file is put back as it was.
72
+ async function pushThemeJob(config, theme, writes, deletes, message, author) {
73
+ const all = [...writes.map((write) => write.path), ...deletes.map((entry) => entry.path)];
74
+ for (const path of all) {
75
+ if (!isValidThemePath(path)) {
76
+ throw new ThemePushError('invalid-path', `Not a theme file path: ${JSON.stringify(path)}`);
77
+ }
78
+ }
79
+ if (new Set(all).size !== all.length) {
80
+ throw new ThemePushError('invalid-path', 'The same theme file appears more than once in one push');
81
+ }
82
+ mkdirSync(config.themeRoot, { recursive: true });
83
+ const resolved = new Map(all.map((path) => [path, sanitisePath(config.themeRoot, path)]));
84
+ const conflicts = [];
85
+ for (const write of writes) {
86
+ if (currentHash(resolved.get(write.path)) !== write.expected) {
87
+ conflicts.push(write.path);
88
+ }
89
+ }
90
+ for (const entry of deletes) {
91
+ if (currentHash(resolved.get(entry.path)) !== entry.expected) {
92
+ conflicts.push(entry.path);
93
+ }
94
+ }
95
+ if (conflicts.length > 0) {
96
+ throw new ThemePushError('conflict', `Changed on the site since you last saw them: ${conflicts.map((path) => `theme/${path}`).join(', ')}`);
97
+ }
98
+ checkLiquid(writes);
99
+ const originals = new Map();
100
+ for (const path of all) {
101
+ const full = resolved.get(path);
102
+ originals.set(path, existsSync(full) ? readFileSync(full) : null);
103
+ }
104
+ function restore() {
105
+ for (const [path, bytes] of originals) {
106
+ const full = resolved.get(path);
107
+ if (bytes === null) {
108
+ rmSync(full, { force: true });
109
+ }
110
+ else {
111
+ mkdirSync(dirname(full), { recursive: true });
112
+ writeFileSync(full, bytes);
113
+ }
114
+ }
115
+ }
116
+ try {
117
+ for (const write of writes) {
118
+ const full = resolved.get(write.path);
119
+ mkdirSync(dirname(full), { recursive: true });
120
+ writeFileSync(full, write.bytes);
121
+ }
122
+ for (const entry of deletes) {
123
+ rmSync(resolved.get(entry.path), { force: true });
124
+ }
125
+ }
126
+ catch (error) {
127
+ restore();
128
+ throw new ThemePushError('write-failed', 'Writing the theme failed; nothing was changed', { cause: error });
129
+ }
130
+ try {
131
+ commitPaths(config.siteRoot, [...resolved.values()].map((full) => relative(config.siteRoot, full)), message, author);
132
+ }
133
+ catch (error) {
134
+ try {
135
+ restore();
136
+ }
137
+ catch (rollbackError) {
138
+ throw new ThemePushError('rollback-failed', 'Committing the theme failed and putting it back also failed; theme/ needs manual inspection', { cause: rollbackError });
139
+ }
140
+ const detail = error instanceof Error ? error.message : String(error);
141
+ throw new ThemePushError('commit-failed', `Committing the theme failed: ${detail}`, { cause: error });
142
+ }
143
+ const reloaded = theme.reload();
144
+ return { generation: reloaded.generation, warnings: reloaded.themeSchemas.warnings ?? [] };
145
+ }
146
+ export function pushTheme(config, theme, writes, deletes, message, author) {
147
+ return enqueue(() => pushThemeJob(config, theme, writes, deletes, message, author));
148
+ }
@@ -0,0 +1,7 @@
1
+ import { type ThemeSchemas } from './validation.ts';
2
+ export interface PageTemplate {
3
+ id: string;
4
+ title: string;
5
+ content: unknown;
6
+ }
7
+ export declare function loadPageTemplates(templatesRoot: string, themeSchemas: ThemeSchemas): PageTemplate[];
@@ -0,0 +1,53 @@
1
+ import { readdirSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { validatePage } from "./validation.js";
4
+ // templatesRoot is agent configuration (config.templatesRoot), not a
5
+ // request-supplied :path parameter - same non-sanitised, boot-time-only
6
+ // directory walk as theme-schemas.ts's own loadTypeSchemas, never
7
+ // reused for untrusted request paths.
8
+ //
9
+ // Flat *.json files, one per template, named directly (e.g.
10
+ // blog-article.json) - mirrors loadTypeSchemas's walk exactly. Each
11
+ // file must already be a fully valid page (the same page.schema.json
12
+ // every real content/pages/*.json file is validated against, via the
13
+ // theme's own current section/block schemas) - a template IS a real
14
+ // page file, nothing more, so a theme author can create one by
15
+ // literally copying and adapting a real page rather than learning a
16
+ // new authoring convention. A template that fails to parse or fails
17
+ // validation is skipped, never a boot failure - same defensive
18
+ // handling loadTypeSchemas already gives a malformed schema.json.
19
+ export function loadPageTemplates(templatesRoot, themeSchemas) {
20
+ const templates = [];
21
+ let entries;
22
+ try {
23
+ entries = readdirSync(templatesRoot, { withFileTypes: true })
24
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
25
+ .map((entry) => entry.name);
26
+ }
27
+ catch {
28
+ return templates;
29
+ }
30
+ for (const fileName of entries) {
31
+ const id = fileName.slice(0, -'.json'.length);
32
+ let raw;
33
+ try {
34
+ raw = readFileSync(join(templatesRoot, fileName), 'utf-8');
35
+ }
36
+ catch {
37
+ continue;
38
+ }
39
+ let content;
40
+ try {
41
+ content = JSON.parse(raw);
42
+ }
43
+ catch {
44
+ continue;
45
+ }
46
+ if (!validatePage(content, themeSchemas).valid) {
47
+ continue;
48
+ }
49
+ const title = content.title;
50
+ templates.push({ id, title, content });
51
+ }
52
+ return templates;
53
+ }
@@ -0,0 +1,2 @@
1
+ import { type ThemeSchemas } from './validation.ts';
2
+ export declare function loadThemeSchemas(themeRoot: string): ThemeSchemas;