@minamocms/framework 0.0.0-stage → 0.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (286) hide show
  1. package/CHANGELOG.md +240 -0
  2. package/LICENSE +105 -0
  3. package/README.md +133 -2
  4. package/dist/boot.d.ts +19 -0
  5. package/dist/boot.js +22 -0
  6. package/dist/config.d.ts +18 -0
  7. package/dist/config.js +44 -0
  8. package/dist/create-site/cli.d.ts +2 -0
  9. package/dist/create-site/cli.js +31 -0
  10. package/dist/create-site/generate-site.d.ts +12 -0
  11. package/dist/create-site/generate-site.js +168 -0
  12. package/dist/create-site/mint-token-cli.d.ts +2 -0
  13. package/dist/create-site/mint-token-cli.js +26 -0
  14. package/dist/create-site/mint-token.d.ts +7 -0
  15. package/dist/create-site/mint-token.js +49 -0
  16. package/dist/create-site/template/AGENTS.md +473 -0
  17. package/dist/create-site/template/content/menus/footerCompany.json +9 -0
  18. package/dist/create-site/template/content/menus/footerProduct.json +9 -0
  19. package/dist/create-site/template/content/menus/footerResources.json +9 -0
  20. package/dist/create-site/template/content/menus/main.json +9 -0
  21. package/dist/create-site/template/content/pages/404.json +23 -0
  22. package/dist/create-site/template/content/pages/about/careers.json +22 -0
  23. package/dist/create-site/template/content/pages/about/team.json +22 -0
  24. package/dist/create-site/template/content/pages/about.json +22 -0
  25. package/dist/create-site/template/content/pages/docs/deployment.json +22 -0
  26. package/dist/create-site/template/content/pages/docs/getting-started/quickstart.json +22 -0
  27. package/dist/create-site/template/content/pages/docs/getting-started.json +24 -0
  28. package/dist/create-site/template/content/pages/docs.json +22 -0
  29. package/dist/create-site/template/content/pages/index.json +182 -0
  30. package/dist/create-site/template/content/redirects.json +4 -0
  31. package/dist/create-site/template/dockerignore +6 -0
  32. package/dist/create-site/template/gitignore +11 -0
  33. package/dist/create-site/template/theme/assets/site.js +49 -0
  34. package/dist/create-site/template/theme/assets/style.css +1223 -0
  35. package/dist/create-site/template/theme/blocks/button.liquid +21 -0
  36. package/dist/create-site/template/theme/blocks/comparison-row.liquid +19 -0
  37. package/dist/create-site/template/theme/blocks/faq-item.liquid +22 -0
  38. package/dist/create-site/template/theme/blocks/feature-card.liquid +23 -0
  39. package/dist/create-site/template/theme/blocks/logo-mark.liquid +13 -0
  40. package/dist/create-site/template/theme/blocks/pricing-tier.liquid +42 -0
  41. package/dist/create-site/template/theme/blocks/step-card.liquid +19 -0
  42. package/dist/create-site/template/theme/blocks/testimonial-card.liquid +25 -0
  43. package/dist/create-site/template/theme/config/site_settings.json +11 -0
  44. package/dist/create-site/template/theme/layouts/theme.liquid +102 -0
  45. package/dist/create-site/template/theme/sections/comparison.liquid +41 -0
  46. package/dist/create-site/template/theme/sections/cta-banner.liquid +36 -0
  47. package/dist/create-site/template/theme/sections/faq.liquid +27 -0
  48. package/dist/create-site/template/theme/sections/feature-grid.liquid +27 -0
  49. package/dist/create-site/template/theme/sections/hero.liquid +48 -0
  50. package/dist/create-site/template/theme/sections/how-it-works.liquid +27 -0
  51. package/dist/create-site/template/theme/sections/pricing.liquid +27 -0
  52. package/dist/create-site/template/theme/sections/social-proof.liquid +21 -0
  53. package/dist/create-site/template/theme/sections/testimonials.liquid +27 -0
  54. package/dist/create-site/template/theme/snippets/icon.liquid +24 -0
  55. package/dist/create-site/template/theme/snippets/logo.liquid +7 -0
  56. package/dist/create-site/template/theme/snippets/responsive-media.liquid +107 -0
  57. package/dist/create-site/template/theme/snippets/site-name.liquid +1 -0
  58. package/dist/create-site/template/vhost/Dockerfile +33 -0
  59. package/dist/create-site/template/vhost/docker-entrypoint.sh +58 -0
  60. package/dist/index.d.ts +2 -0
  61. package/dist/index.js +4 -0
  62. package/dist/media/drivers/driver.d.ts +11 -0
  63. package/dist/media/drivers/driver.js +1 -0
  64. package/dist/media/drivers/local-fs-driver.d.ts +3 -0
  65. package/dist/media/drivers/local-fs-driver.js +94 -0
  66. package/dist/media/filename.d.ts +2 -0
  67. package/dist/media/filename.js +54 -0
  68. package/dist/media/manage-media.d.ts +19 -0
  69. package/dist/media/manage-media.js +45 -0
  70. package/dist/media/seed-media-cli.d.ts +2 -0
  71. package/dist/media/seed-media-cli.js +22 -0
  72. package/dist/media/seed-media.d.ts +11 -0
  73. package/dist/media/seed-media.js +76 -0
  74. package/dist/migrations/index.d.ts +3 -0
  75. package/dist/migrations/index.js +83 -0
  76. package/dist/pull-site/cli.d.ts +2 -0
  77. package/dist/pull-site/cli.js +78 -0
  78. package/dist/pull-site/prompt-token.d.ts +16 -0
  79. package/dist/pull-site/prompt-token.js +69 -0
  80. package/dist/pull-site/prompts.d.ts +22 -0
  81. package/dist/pull-site/prompts.js +127 -0
  82. package/dist/pull-site/pull-site.d.ts +23 -0
  83. package/dist/pull-site/pull-site.js +211 -0
  84. package/dist/renderer/engine.d.ts +2 -0
  85. package/dist/renderer/engine.js +48 -0
  86. package/dist/renderer/render-cache.d.ts +11 -0
  87. package/dist/renderer/render-cache.js +11 -0
  88. package/dist/renderer/render-page.d.ts +34 -0
  89. package/dist/renderer/render-page.js +209 -0
  90. package/dist/renderer/theme-templates.d.ts +7 -0
  91. package/dist/renderer/theme-templates.js +82 -0
  92. package/dist/routes/admin-redirect.d.ts +5 -0
  93. package/dist/routes/admin-redirect.js +26 -0
  94. package/dist/routes/assets.d.ts +6 -0
  95. package/dist/routes/assets.js +30 -0
  96. package/dist/routes/batch.d.ts +10 -0
  97. package/dist/routes/batch.js +148 -0
  98. package/dist/routes/capabilities.d.ts +6 -0
  99. package/dist/routes/capabilities.js +27 -0
  100. package/dist/routes/content.d.ts +8 -0
  101. package/dist/routes/content.js +169 -0
  102. package/dist/routes/deleted-pages.d.ts +8 -0
  103. package/dist/routes/deleted-pages.js +9 -0
  104. package/dist/routes/drafts.d.ts +10 -0
  105. package/dist/routes/drafts.js +73 -0
  106. package/dist/routes/git.d.ts +8 -0
  107. package/dist/routes/git.js +158 -0
  108. package/dist/routes/index.d.ts +12 -0
  109. package/dist/routes/index.js +86 -0
  110. package/dist/routes/links.d.ts +8 -0
  111. package/dist/routes/links.js +18 -0
  112. package/dist/routes/media-public.d.ts +11 -0
  113. package/dist/routes/media-public.js +125 -0
  114. package/dist/routes/media.d.ts +9 -0
  115. package/dist/routes/media.js +80 -0
  116. package/dist/routes/menus.d.ts +8 -0
  117. package/dist/routes/menus.js +148 -0
  118. package/dist/routes/preview-revision.d.ts +10 -0
  119. package/dist/routes/preview-revision.js +96 -0
  120. package/dist/routes/preview.d.ts +10 -0
  121. package/dist/routes/preview.js +56 -0
  122. package/dist/routes/public.d.ts +10 -0
  123. package/dist/routes/public.js +123 -0
  124. package/dist/routes/publish.d.ts +10 -0
  125. package/dist/routes/publish.js +139 -0
  126. package/dist/routes/redirects.d.ts +8 -0
  127. package/dist/routes/redirects.js +132 -0
  128. package/dist/routes/search-public.d.ts +6 -0
  129. package/dist/routes/search-public.js +104 -0
  130. package/dist/routes/search.d.ts +8 -0
  131. package/dist/routes/search.js +15 -0
  132. package/dist/routes/settings.d.ts +10 -0
  133. package/dist/routes/settings.js +68 -0
  134. package/dist/routes/sitemap.d.ts +7 -0
  135. package/dist/routes/sitemap.js +79 -0
  136. package/dist/routes/theme.d.ts +10 -0
  137. package/dist/routes/theme.js +116 -0
  138. package/dist/schemas/instance.schema.json +17 -0
  139. package/dist/schemas/menu.schema.json +24 -0
  140. package/dist/schemas/page.schema.json +26 -0
  141. package/dist/schemas/redirects.schema.json +24 -0
  142. package/dist/search/drivers/driver.d.ts +9 -0
  143. package/dist/search/drivers/driver.js +1 -0
  144. package/dist/search/drivers/node-sqlite-driver.d.ts +7 -0
  145. package/dist/search/drivers/node-sqlite-driver.js +19 -0
  146. package/dist/search/query-content.d.ts +32 -0
  147. package/dist/search/query-content.js +207 -0
  148. package/dist/search/query-index.d.ts +5 -0
  149. package/dist/search/query-index.js +21 -0
  150. package/dist/search/rebuild-index.d.ts +3 -0
  151. package/dist/search/rebuild-index.js +320 -0
  152. package/dist/server-config.d.ts +23 -0
  153. package/dist/server-config.js +237 -0
  154. package/dist/server.d.ts +13 -0
  155. package/dist/server.js +235 -0
  156. package/dist/services/batch.d.ts +35 -0
  157. package/dist/services/batch.js +145 -0
  158. package/dist/services/checkpoint.d.ts +4 -0
  159. package/dist/services/checkpoint.js +45 -0
  160. package/dist/services/content-read.d.ts +27 -0
  161. package/dist/services/content-read.js +151 -0
  162. package/dist/services/delete-content.d.ts +12 -0
  163. package/dist/services/delete-content.js +166 -0
  164. package/dist/services/deleted-pages.d.ts +11 -0
  165. package/dist/services/deleted-pages.js +83 -0
  166. package/dist/services/dev-tunnel.d.ts +5 -0
  167. package/dist/services/dev-tunnel.js +5 -0
  168. package/dist/services/drafts.d.ts +16 -0
  169. package/dist/services/drafts.js +164 -0
  170. package/dist/services/etag.d.ts +2 -0
  171. package/dist/services/etag.js +30 -0
  172. package/dist/services/fs-walk.d.ts +1 -0
  173. package/dist/services/fs-walk.js +26 -0
  174. package/dist/services/git-commit.d.ts +3 -0
  175. package/dist/services/git-commit.js +12 -0
  176. package/dist/services/git-history.d.ts +28 -0
  177. package/dist/services/git-history.js +131 -0
  178. package/dist/services/git-revert.d.ts +12 -0
  179. package/dist/services/git-revert.js +140 -0
  180. package/dist/services/git.d.ts +16 -0
  181. package/dist/services/git.js +127 -0
  182. package/dist/services/interval-job.d.ts +4 -0
  183. package/dist/services/interval-job.js +9 -0
  184. package/dist/services/ip-allowlist.d.ts +7 -0
  185. package/dist/services/ip-allowlist.js +31 -0
  186. package/dist/services/links.d.ts +22 -0
  187. package/dist/services/links.js +169 -0
  188. package/dist/services/manage-menus.d.ts +15 -0
  189. package/dist/services/manage-menus.js +138 -0
  190. package/dist/services/manage-redirects.d.ts +25 -0
  191. package/dist/services/manage-redirects.js +156 -0
  192. package/dist/services/menu-references.d.ts +3 -0
  193. package/dist/services/menu-references.js +38 -0
  194. package/dist/services/menus.d.ts +9 -0
  195. package/dist/services/menus.js +43 -0
  196. package/dist/services/migration-runner.d.ts +16 -0
  197. package/dist/services/migration-runner.js +169 -0
  198. package/dist/services/mime-types.d.ts +3 -0
  199. package/dist/services/mime-types.js +43 -0
  200. package/dist/services/move.d.ts +16 -0
  201. package/dist/services/move.js +236 -0
  202. package/dist/services/path-safety.d.ts +6 -0
  203. package/dist/services/path-safety.js +112 -0
  204. package/dist/services/pid-file.d.ts +13 -0
  205. package/dist/services/pid-file.js +54 -0
  206. package/dist/services/post-urls.d.ts +3 -0
  207. package/dist/services/post-urls.js +27 -0
  208. package/dist/services/prepared-operation.d.ts +4 -0
  209. package/dist/services/prepared-operation.js +1 -0
  210. package/dist/services/publish.d.ts +15 -0
  211. package/dist/services/publish.js +313 -0
  212. package/dist/services/rate-limit-config.d.ts +9 -0
  213. package/dist/services/rate-limit-config.js +22 -0
  214. package/dist/services/redirects.d.ts +27 -0
  215. package/dist/services/redirects.js +175 -0
  216. package/dist/services/reindex-on-write.d.ts +3 -0
  217. package/dist/services/reindex-on-write.js +30 -0
  218. package/dist/services/resolve-blog-url.d.ts +11 -0
  219. package/dist/services/resolve-blog-url.js +31 -0
  220. package/dist/services/resolve-url.d.ts +11 -0
  221. package/dist/services/resolve-url.js +20 -0
  222. package/dist/services/site-settings.d.ts +28 -0
  223. package/dist/services/site-settings.js +116 -0
  224. package/dist/services/startup-checks.d.ts +10 -0
  225. package/dist/services/startup-checks.js +81 -0
  226. package/dist/services/static-file.d.ts +7 -0
  227. package/dist/services/static-file.js +37 -0
  228. package/dist/services/theme-component-file.d.ts +6 -0
  229. package/dist/services/theme-component-file.js +39 -0
  230. package/dist/services/theme-files.d.ts +33 -0
  231. package/dist/services/theme-files.js +148 -0
  232. package/dist/services/theme-page-templates.d.ts +7 -0
  233. package/dist/services/theme-page-templates.js +53 -0
  234. package/dist/services/theme-schemas.d.ts +2 -0
  235. package/dist/services/theme-schemas.js +137 -0
  236. package/dist/services/token-auth.d.ts +10 -0
  237. package/dist/services/token-auth.js +64 -0
  238. package/dist/services/urls.d.ts +2 -0
  239. package/dist/services/urls.js +20 -0
  240. package/dist/services/validation.d.ts +26 -0
  241. package/dist/services/validation.js +195 -0
  242. package/dist/services/write-queue.d.ts +1 -0
  243. package/dist/services/write-queue.js +14 -0
  244. package/dist/site-check/cli.d.ts +2 -0
  245. package/dist/site-check/cli.js +48 -0
  246. package/dist/site-check/run-check.d.ts +12 -0
  247. package/dist/site-check/run-check.js +188 -0
  248. package/dist/site-sync/cli-helpers.d.ts +23 -0
  249. package/dist/site-sync/cli-helpers.js +75 -0
  250. package/dist/site-sync/deploy.d.ts +36 -0
  251. package/dist/site-sync/deploy.js +124 -0
  252. package/dist/site-sync/live-content.d.ts +30 -0
  253. package/dist/site-sync/live-content.js +68 -0
  254. package/dist/site-sync/prompts.d.ts +30 -0
  255. package/dist/site-sync/prompts.js +237 -0
  256. package/dist/site-sync/pull-cli.d.ts +2 -0
  257. package/dist/site-sync/pull-cli.js +75 -0
  258. package/dist/site-sync/pull-site.d.ts +28 -0
  259. package/dist/site-sync/pull-site.js +171 -0
  260. package/dist/site-sync/push-cli.d.ts +2 -0
  261. package/dist/site-sync/push-cli.js +268 -0
  262. package/dist/site-sync/push-site.d.ts +96 -0
  263. package/dist/site-sync/push-site.js +376 -0
  264. package/dist/site-sync/remote-site.d.ts +38 -0
  265. package/dist/site-sync/remote-site.js +122 -0
  266. package/dist/site-sync/sync-record.d.ts +26 -0
  267. package/dist/site-sync/sync-record.js +76 -0
  268. package/dist/site-sync/theme-sync.d.ts +34 -0
  269. package/dist/site-sync/theme-sync.js +128 -0
  270. package/dist/stop-site/cli.d.ts +2 -0
  271. package/dist/stop-site/cli.js +28 -0
  272. package/dist/stop-site/stop-site.d.ts +26 -0
  273. package/dist/stop-site/stop-site.js +56 -0
  274. package/dist/theme-state.d.ts +20 -0
  275. package/dist/theme-state.js +33 -0
  276. package/dist/upgrade/changelog.d.ts +8 -0
  277. package/dist/upgrade/changelog.js +46 -0
  278. package/dist/upgrade/finish.d.ts +9 -0
  279. package/dist/upgrade/finish.js +30 -0
  280. package/dist/upgrade/owned-files.d.ts +11 -0
  281. package/dist/upgrade/owned-files.js +42 -0
  282. package/dist/upgrade/package-names.d.ts +4 -0
  283. package/dist/upgrade/package-names.js +26 -0
  284. package/dist/upgrade/upgrade-cli.d.ts +2 -0
  285. package/dist/upgrade/upgrade-cli.js +170 -0
  286. package/package.json +64 -4
@@ -0,0 +1,48 @@
1
+ import { Liquid } from 'liquidjs';
2
+ // Called exactly once, inside bootSite - never a setter invoked again
3
+ // mid-process. This is what keeps "every theme file read once at
4
+ // boot" a structural guarantee rather than a convention: a mutable
5
+ // singleton with a snippets setter would let a future caller silently
6
+ // reload snippets without a restart, exactly what this design exists
7
+ // to avoid.
8
+ //
9
+ // Nothing beyond these constructor options is ever configured: no
10
+ // registerTag, no registerFilter, no plugin() - ever (constraint: no
11
+ // dynamically registered Liquid tags or filters). fs and
12
+ // relativeReference are stock constructor options, not registered
13
+ // tags/filters - confirmed empirically that the built-in `render` tag
14
+ // resolves purely against the in-memory `snippets` map below, with no
15
+ // disk access at all, and that a snippet rendered this way is still
16
+ // bounded by renderLimit (see test/renderer/engine.test.ts, D8, and
17
+ // test/renderer/snippets.test.ts).
18
+ //
19
+ // outputEscape: 'escape' makes {{ value }} escape HTML by default (a
20
+ // stock constructor option, not a registered filter/tag), with an
21
+ // explicit `| raw` as the only opt-out - used for pre-rendered block
22
+ // HTML that was already safely rendered by an earlier parseAndRender
23
+ // call (see render-page.ts), and for content_for_layout in a layout
24
+ // template for the same reason.
25
+ //
26
+ // renderLimit: 50 (ms) is the only reliable defence against a runaway
27
+ // template: an external Promise.race/setTimeout wrapper cannot abort a
28
+ // synchronous-only render (LiquidJS's drive loop only yields to the
29
+ // event loop when it awaits an actual Promise), confirmed empirically.
30
+ // This bounds a single parseAndRender call, not a whole page: a page
31
+ // with many sections/blocks each individually finishing just under
32
+ // budget has no aggregate ceiling. A page-level wall-clock guard is a
33
+ // Phase 2 route-handler concern, once there is an actual request to
34
+ // bound - not solved here.
35
+ export function createEngine(snippets) {
36
+ return new Liquid({
37
+ outputEscape: 'escape',
38
+ renderLimit: 50,
39
+ relativeReference: false,
40
+ fs: {
41
+ exists: async (file) => file in snippets,
42
+ existsSync: (file) => file in snippets,
43
+ readFile: async (file) => snippets[file],
44
+ readFileSync: (file) => snippets[file],
45
+ resolve: (_root, file) => file,
46
+ },
47
+ });
48
+ }
@@ -0,0 +1,11 @@
1
+ export interface RenderCacheEntry {
2
+ html: string;
3
+ pageMtimeMs: number;
4
+ menusMtimeMs: number;
5
+ themeGeneration: number;
6
+ }
7
+ export interface RenderCache {
8
+ get(renderPath: string): RenderCacheEntry | undefined;
9
+ set(renderPath: string, entry: RenderCacheEntry): void;
10
+ }
11
+ export declare function createRenderCache(): RenderCache;
@@ -0,0 +1,11 @@
1
+ export function createRenderCache() {
2
+ const map = new Map();
3
+ return {
4
+ get(renderPath) {
5
+ return map.get(renderPath);
6
+ },
7
+ set(renderPath, entry) {
8
+ map.set(renderPath, entry);
9
+ },
10
+ };
11
+ }
@@ -0,0 +1,34 @@
1
+ import type { Liquid } from 'liquidjs';
2
+ import type { SiteConfig } from '../config.ts';
3
+ import type { ThemeTemplates } from './theme-templates.ts';
4
+ import { type SiteSettings } from '../services/site-settings.ts';
5
+ export type PageRenderReason = 'page-not-found' | 'missing-section-type' | 'missing-block-type' | 'missing-layout' | 'template-error';
6
+ export declare class PageRenderError extends Error {
7
+ readonly reason: PageRenderReason;
8
+ constructor(reason: PageRenderReason, message: string, options?: {
9
+ cause?: unknown;
10
+ });
11
+ }
12
+ export interface SectionOrBlockInstance {
13
+ id: string;
14
+ type: string;
15
+ settings: Record<string, unknown>;
16
+ blocks?: SectionOrBlockInstance[];
17
+ }
18
+ export interface PageContent {
19
+ schemaVersion: number;
20
+ title: string;
21
+ published: boolean;
22
+ layout: string;
23
+ sections: SectionOrBlockInstance[];
24
+ author?: string;
25
+ publishDate?: string;
26
+ tags?: string[];
27
+ }
28
+ export declare function renderSections(page: PageContent, themeTemplates: ThemeTemplates, engine: Liquid, siteSettings?: SiteSettings): Promise<string>;
29
+ export type RenderMode = 'public' | 'preview';
30
+ export declare function parsePageContent(raw: string): PageContent;
31
+ export declare function renderLoadedPage(page: PageContent, config: SiteConfig, themeTemplates: ThemeTemplates, layouts: Record<string, string>, engine: Liquid, siteSettings?: SiteSettings): Promise<string>;
32
+ export declare function renderPage(config: SiteConfig, themeTemplates: ThemeTemplates, layouts: Record<string, string>, engine: Liquid, relativePath: string, mode: RenderMode, siteSettings?: SiteSettings): Promise<string>;
33
+ export declare function getPageMtimeMs(config: SiteConfig, relativePath: string): number | null;
34
+ export declare function getMenusMtimeMs(config: SiteConfig): number;
@@ -0,0 +1,209 @@
1
+ import { readFileSync, statSync } from 'node:fs';
2
+ import { listFilesRecursively } from "../services/fs-walk.js";
3
+ import { loadMenus } from "../services/menus.js";
4
+ import { sanitisePath } from "../services/path-safety.js";
5
+ import { siteSettingsMtimeMs } from "../services/site-settings.js";
6
+ export class PageRenderError extends Error {
7
+ reason;
8
+ constructor(reason, message, options) {
9
+ super(message, options);
10
+ this.name = 'PageRenderError';
11
+ this.reason = reason;
12
+ }
13
+ }
14
+ function pageEnvelope(page) {
15
+ return { title: page.title, author: page.author, publishDate: page.publishDate, tags: page.tags };
16
+ }
17
+ // Renders one section or block, recursively rendering any nested blocks
18
+ // first (instance.schema.json is self-referential, so a block can carry
19
+ // its own nested blocks). Never uses {% include %}/{% render %}: block
20
+ // HTML is pre-rendered here in JS and handed to the parent template as
21
+ // a plain array, which sidesteps LiquidJS's own filesystem include
22
+ // resolution entirely.
23
+ // Site settings reach every template in a render - layout, sections,
24
+ // blocks, and snippets pulled in with {% render %} (which otherwise sees
25
+ // only what it is passed) - as `settings`, via Liquid's per-render
26
+ // globals. Shopify's name for the same thing; no clash with
27
+ // section.settings/block.settings, which are nested.
28
+ function settingsGlobals(siteSettings) {
29
+ return { globals: { settings: siteSettings } };
30
+ }
31
+ async function renderInstance(instance, kind, themeTemplates, engine, page, siteSettings) {
32
+ const templates = kind === 'section' ? themeTemplates.sections : themeTemplates.blocks;
33
+ const template = templates[instance.type];
34
+ if (!template) {
35
+ throw new PageRenderError(kind === 'section' ? 'missing-section-type' : 'missing-block-type', `${kind === 'section' ? 'Section' : 'Block'} type "${instance.type}" is missing from the theme (${kind} id: "${instance.id}")`);
36
+ }
37
+ const blocksHtml = [];
38
+ for (const block of instance.blocks ?? []) {
39
+ blocksHtml.push(await renderInstance(block, 'block', themeTemplates, engine, page, siteSettings));
40
+ }
41
+ // Shopify-style scope shape: settings nested under the instance, not
42
+ // flattened, so templates read section.settings.x / block.settings.x.
43
+ // `page` is the same built-in envelope a layout gets (title plus the
44
+ // optional author/publishDate/tags fields) - lets a section print a
45
+ // byline/date without duplicating it into a settings field.
46
+ const scope = kind === 'section'
47
+ ? { section: { id: instance.id, type: instance.type, settings: instance.settings }, blocksHtml, page }
48
+ : { block: { id: instance.id, type: instance.type, settings: instance.settings }, blocksHtml, page };
49
+ try {
50
+ return (await engine.parseAndRender(template, scope, settingsGlobals(siteSettings)));
51
+ }
52
+ catch (error) {
53
+ if (error instanceof PageRenderError) {
54
+ throw error;
55
+ }
56
+ const detail = error instanceof Error ? error.message : String(error);
57
+ throw new PageRenderError('template-error', `Failed to render ${kind} "${instance.id}" (type "${instance.type}"): ${detail}`, { cause: error });
58
+ }
59
+ }
60
+ // Renders a page's top-level sections, in declared order, concatenated.
61
+ // No page-level layout/wrapper concept in Phase 1 - this is the whole
62
+ // output.
63
+ export async function renderSections(page, themeTemplates, engine, siteSettings = {}) {
64
+ const envelope = pageEnvelope(page);
65
+ const html = [];
66
+ for (const section of page.sections) {
67
+ html.push(await renderInstance(section, 'section', themeTemplates, engine, envelope, siteSettings));
68
+ }
69
+ return html.join('');
70
+ }
71
+ // The layout a page with no layout field (or an invalid one) gets
72
+ // defaulted to. boot.ts never auto-migrates content, so a real site
73
+ // can have pre-existing pages that predate this field entirely - this
74
+ // default must match migrateV3ToV4's own default exactly, so render
75
+ // behaviour is identical whether or not a site has ever run its
76
+ // migrations (content-read.ts established this same defensive-read
77
+ // pattern for the "type" field).
78
+ const DEFAULT_LAYOUT = 'theme';
79
+ // Throws on invalid JSON rather than swallowing to null - callers reading
80
+ // a specific historical revision (preview-revision.ts) want to know "the
81
+ // JSON at this ref is broken" as a distinct, reportable case, whereas
82
+ // tryReadPage's null return means "nothing here, try the next root".
83
+ export function parsePageContent(raw) {
84
+ const parsed = JSON.parse(raw);
85
+ const layout = typeof parsed.layout === 'string' && parsed.layout.length > 0 ? parsed.layout : DEFAULT_LAYOUT;
86
+ return { ...parsed, layout };
87
+ }
88
+ function tryReadPage(root, relativePath) {
89
+ const fullPath = sanitisePath(root, relativePath);
90
+ try {
91
+ return parsePageContent(readFileSync(fullPath, 'utf-8'));
92
+ }
93
+ catch {
94
+ return null;
95
+ }
96
+ }
97
+ // Public mode reads /content/ only, and treats published: false the same
98
+ // as "doesn't exist" - this is what makes unpublish actually take a
99
+ // page offline (checklist C8: the renderer skips unpublished pages).
100
+ // Preview mode overlays /drafts/ over /content/, falling back to live
101
+ // when no draft exists, and deliberately does NOT filter on published:
102
+ // an editor needs to see a currently-unpublished page to review it
103
+ // before republishing.
104
+ function loadPageForRender(config, relativePath, mode) {
105
+ if (mode === 'preview') {
106
+ const draft = tryReadPage(config.draftsRoot, relativePath);
107
+ if (draft) {
108
+ return draft;
109
+ }
110
+ const live = tryReadPage(config.contentRoot, relativePath);
111
+ if (live) {
112
+ return live;
113
+ }
114
+ throw new PageRenderError('page-not-found', `No page found at "${relativePath}"`);
115
+ }
116
+ const live = tryReadPage(config.contentRoot, relativePath);
117
+ if (!live || live.published === false) {
118
+ throw new PageRenderError('page-not-found', `No publicly reachable page found at "${relativePath}"`);
119
+ }
120
+ return live;
121
+ }
122
+ // The shared back half of rendering: takes a page already loaded from
123
+ // wherever (current draft/live disk state via loadPageForRender, or a
124
+ // historical git blob via readFileAtRevision + parsePageContent) and runs
125
+ // it through the identical layout/section/menu pipeline - so "render
126
+ // what's on disk" and "render this specific historical revision" never
127
+ // duplicate this logic.
128
+ export async function renderLoadedPage(page, config, themeTemplates, layouts, engine, siteSettings = {}) {
129
+ // Checked before rendering any sections, matching the fail-fast
130
+ // ordering missing-section-type/missing-block-type already
131
+ // establish - no point rendering a page's whole body just to
132
+ // discover its layout doesn't exist.
133
+ const layoutTemplate = layouts[page.layout];
134
+ if (!layoutTemplate) {
135
+ throw new PageRenderError('missing-layout', `Layout "${page.layout}" is missing from the theme`);
136
+ }
137
+ const bodyHtml = await renderSections(page, themeTemplates, engine, siteSettings);
138
+ // Menus are content, not theme data - loaded fresh here rather than
139
+ // once at boot, same freshness guarantee as the page itself. Scoped
140
+ // to the layout only (not threaded into renderSections/renderInstance
141
+ // above), matching the confirmed requirement: a layout renders nav,
142
+ // sections/blocks don't need it in this pass. No mode distinction:
143
+ // menus have no draft state (Group N), always the live file. This
144
+ // also means a historical page preview always shows today's menus,
145
+ // never a menu as it existed at that revision - an accepted limitation.
146
+ const menus = loadMenus(config);
147
+ // content_for_layout is already-rendered, already-safe HTML (like
148
+ // blocksHtml handed to a section template) - the layout template
149
+ // itself must use `{{ content_for_layout | raw }}`, or engine.ts's
150
+ // outputEscape: 'escape' double-escapes it into literal text.
151
+ return (await engine.parseAndRender(layoutTemplate, {
152
+ content_for_layout: bodyHtml,
153
+ page: pageEnvelope(page),
154
+ menus,
155
+ }, settingsGlobals(siteSettings)));
156
+ }
157
+ export async function renderPage(config, themeTemplates, layouts, engine, relativePath, mode, siteSettings = {}) {
158
+ const page = loadPageForRender(config, relativePath, mode);
159
+ return renderLoadedPage(page, config, themeTemplates, layouts, engine, siteSettings);
160
+ }
161
+ // For public.ts's render cache: the page's own current mtime, so a
162
+ // cached render can be validated against real filesystem state rather
163
+ // than needing every write path (publish/unpublish/delete/move/batch)
164
+ // to remember to invalidate something. null means "not cacheable this
165
+ // time" (the file vanished between resolveUrl confirming it exists and
166
+ // this call - vanishingly unlikely, not a hard failure) rather than
167
+ // throwing, since a cache-freshness check is never the place to
168
+ // surface a real error - the ordinary render path below still will.
169
+ export function getPageMtimeMs(config, relativePath) {
170
+ try {
171
+ return statSync(sanitisePath(config.contentRoot, relativePath)).mtimeMs;
172
+ }
173
+ catch {
174
+ return null;
175
+ }
176
+ }
177
+ // A menu edit affects every page's rendered nav, not just one page, so
178
+ // the cache's freshness check needs one value covering all menus
179
+ // together rather than per-page tracking. The max mtime across every
180
+ // menu file covers an edit. It does not cover a menu being renamed (a
181
+ // handle change keeps the file's mtime) or deleted (removes one), so
182
+ // the menus directory's own mtime is included too: that changes
183
+ // whenever an entry is added, renamed or removed. Menus are flat, so
184
+ // the one directory is enough. 0 (never stale relative to anything)
185
+ // when there are no menus at all, matching listFilesRecursively's own
186
+ // "missing directory returns []" behaviour.
187
+ export function getMenusMtimeMs(config) {
188
+ // Site settings too: like menus, they feed every page.
189
+ let max = siteSettingsMtimeMs(config);
190
+ try {
191
+ max = Math.max(max, statSync(config.menusRoot).mtimeMs);
192
+ }
193
+ catch {
194
+ // No content/menus/ at all - nothing to track.
195
+ }
196
+ for (const relativePath of listFilesRecursively(config.menusRoot, config.menusRoot, '.json')) {
197
+ try {
198
+ const { mtimeMs } = statSync(sanitisePath(config.menusRoot, relativePath));
199
+ if (mtimeMs > max) {
200
+ max = mtimeMs;
201
+ }
202
+ }
203
+ catch {
204
+ // Vanished between the directory walk and this stat (e.g. a
205
+ // concurrent delete) - doesn't contribute, not fatal.
206
+ }
207
+ }
208
+ return max;
209
+ }
@@ -0,0 +1,7 @@
1
+ export interface ThemeTemplates {
2
+ sections: Record<string, string>;
3
+ blocks: Record<string, string>;
4
+ }
5
+ export declare function loadThemeTemplates(themeRoot: string): ThemeTemplates;
6
+ export declare function loadSnippets(themeRoot: string): Record<string, string>;
7
+ export declare function loadLayouts(themeRoot: string): Record<string, string>;
@@ -0,0 +1,82 @@
1
+ import { readdirSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { parseThemeComponentFile } from "../services/theme-component-file.js";
4
+ // themeRoot is agent configuration (the configured site's theme
5
+ // directory), not a request-supplied :path parameter. This walk is
6
+ // deliberately NOT the Group B path-sanitisation helper and must
7
+ // never be reused for untrusted request paths.
8
+ //
9
+ // Flat *.liquid files, one per type, named directly (e.g. hero.liquid,
10
+ // media-text.liquid) - no subfolder per type, no separate schema.json.
11
+ // Mirrors loadFlatTemplates below exactly, extended to strip the
12
+ // embedded {% schema %} block (via theme-component-file.ts's shared
13
+ // parser - the same parse theme-schemas.ts uses for the other half of
14
+ // the same file) before handing the remaining markup to the Liquid
15
+ // engine, since {% schema %} is never a real, live Liquid tag.
16
+ function loadTypeTemplates(typesDir) {
17
+ const templates = {};
18
+ let entries;
19
+ try {
20
+ entries = readdirSync(typesDir, { withFileTypes: true })
21
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.liquid'))
22
+ .map((entry) => entry.name);
23
+ }
24
+ catch {
25
+ return templates;
26
+ }
27
+ for (const fileName of entries) {
28
+ const type = fileName.slice(0, -'.liquid'.length);
29
+ let source;
30
+ try {
31
+ source = readFileSync(join(typesDir, fileName), 'utf-8');
32
+ }
33
+ catch {
34
+ continue;
35
+ }
36
+ const parsed = parseThemeComponentFile(source);
37
+ if (!parsed) {
38
+ continue;
39
+ }
40
+ templates[type] = parsed.markup;
41
+ }
42
+ return templates;
43
+ }
44
+ export function loadThemeTemplates(themeRoot) {
45
+ return {
46
+ sections: loadTypeTemplates(join(themeRoot, 'sections')),
47
+ blocks: loadTypeTemplates(join(themeRoot, 'blocks')),
48
+ };
49
+ }
50
+ // Flat *.liquid files, one per name, no subdirectory-per-name and no
51
+ // co-located schema.json - unlike sections/blocks, snippets and
52
+ // layouts are theme-author code with no user-facing settings to
53
+ // declare, matching Shopify's own flat snippets/layouts convention.
54
+ // Same agent-configuration-not-request-path caveat as loadTypeTemplates.
55
+ function loadFlatTemplates(dir) {
56
+ const templates = {};
57
+ let entries;
58
+ try {
59
+ entries = readdirSync(dir, { withFileTypes: true })
60
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.liquid'))
61
+ .map((entry) => entry.name);
62
+ }
63
+ catch {
64
+ return templates;
65
+ }
66
+ for (const fileName of entries) {
67
+ const name = fileName.slice(0, -'.liquid'.length);
68
+ try {
69
+ templates[name] = readFileSync(join(dir, fileName), 'utf-8');
70
+ }
71
+ catch {
72
+ continue;
73
+ }
74
+ }
75
+ return templates;
76
+ }
77
+ export function loadSnippets(themeRoot) {
78
+ return loadFlatTemplates(join(themeRoot, 'snippets'));
79
+ }
80
+ export function loadLayouts(themeRoot) {
81
+ return loadFlatTemplates(join(themeRoot, 'layouts'));
82
+ }
@@ -0,0 +1,5 @@
1
+ import type { FastifyPluginAsync } from 'fastify';
2
+ export interface AdminRedirectRouteOptions {
3
+ adminBaseUrl: string;
4
+ }
5
+ export declare const adminRedirectRoutes: FastifyPluginAsync<AdminRedirectRouteOptions>;
@@ -0,0 +1,26 @@
1
+ import { NO_AUTH_ROUTE_RATE_LIMIT } from "../services/rate-limit-config.js";
2
+ // Only ever registered by server.ts when serverConfig.adminBaseUrl is
3
+ // actually set - that's what makes this genuinely opt-in, not a
4
+ // reserved namespace like /media or /assets. A site that hasn't
5
+ // configured this can still use "admin" as an ordinary page path,
6
+ // same reasoning as GET /search.json over reserving all of /search.
7
+ //
8
+ // Redirects to adminBaseUrl with the requesting host appended as
9
+ // ?site=, never a deep link with a site ID baked in - keeps this
10
+ // agent completely decoupled from any particular admin app's own URL
11
+ // scheme, so a self-hoster running a different admin frontend just
12
+ // points adminBaseUrl at it. request.host (not request.hostname,
13
+ // which strips the port - same reasoning sitemap.ts already
14
+ // documents) so the admin side can match it directly against
15
+ // new URL(registeredSiteUrl).host.
16
+ //
17
+ // Deliberately unauthenticated: this is a discovery aid an operator
18
+ // (or anyone) can hit before they have a token, same class of
19
+ // zero-credential route as GET /v1/capabilities and GET /search.json.
20
+ export const adminRedirectRoutes = async (fastify, opts) => {
21
+ fastify.get('/admin', { config: NO_AUTH_ROUTE_RATE_LIMIT }, async (request, reply) => {
22
+ const target = new URL(opts.adminBaseUrl);
23
+ target.searchParams.set('site', request.host);
24
+ reply.code(302).header('location', target.toString()).send();
25
+ });
26
+ };
@@ -0,0 +1,6 @@
1
+ import type { FastifyPluginAsync } from 'fastify';
2
+ import type { SiteConfig } from '../config.ts';
3
+ export interface AssetsRouteOptions {
4
+ config: SiteConfig;
5
+ }
6
+ export declare const assetsRoutes: FastifyPluginAsync<AssetsRouteOptions>;
@@ -0,0 +1,30 @@
1
+ import { findStaticFile, sendStaticFile } from "../services/static-file.js";
2
+ async function handleAssetRequest(request, reply, config) {
3
+ const relativePath = request.params['*'];
4
+ const match = findStaticFile(config.assetsRoot, relativePath);
5
+ if (!match) {
6
+ reply.code(404).send({ statusCode: 404, error: 'Not Found', message: `No asset at "${relativePath}"` });
7
+ return;
8
+ }
9
+ // A synchronous buffered read, not a stream: reply.send(stream) was
10
+ // verified empirically to produce an empty body in this Fastify
11
+ // version (confirmed both via .inject() and a real socket, isolated
12
+ // down to a minimal repro) - a real, environment-specific problem,
13
+ // not a mistake in this route's own logic. No Range/If-Range support
14
+ // in this pass either way (nothing has asked for large-file
15
+ // streaming yet), so buffering the whole file is not a meaningful
16
+ // regression. sendStaticFile (services/static-file.ts) does this
17
+ // read, shared with routes/public.ts's root-mirror check.
18
+ sendStaticFile(reply, match);
19
+ }
20
+ // Deliberately and permanently unauthenticated: static assets (CSS/JS/
21
+ // images) must be fetchable by any visitor's browser without a token,
22
+ // same as the public website itself. Registered without a /v1 prefix,
23
+ // directly on app in server.ts, alongside publicRoutes. No guard
24
+ // against the public catch-all's own /* wildcard is needed here - this
25
+ // is the more specific static-prefixed route winning cleanly (verified
26
+ // empirically), not the reverse case that needed public.ts's /v1/*
27
+ // guard.
28
+ export const assetsRoutes = async (fastify, opts) => {
29
+ fastify.get('/assets/*', async (request, reply) => handleAssetRequest(request, reply, opts.config));
30
+ };
@@ -0,0 +1,10 @@
1
+ import type { FastifyPluginAsync } from 'fastify';
2
+ import type { SiteConfig } from '../config.ts';
3
+ import type { ThemeState } from '../theme-state.ts';
4
+ import type { TokenEntry } from '../server-config.ts';
5
+ export interface BatchRouteOptions {
6
+ config: SiteConfig;
7
+ theme: ThemeState;
8
+ tokens: TokenEntry[];
9
+ }
10
+ export declare const batchRoutes: FastifyPluginAsync<BatchRouteOptions>;
@@ -0,0 +1,148 @@
1
+ import { BatchError, runBatch } from "../services/batch.js";
2
+ import { isValidCommitAuthor } from "../services/git.js";
3
+ import { PathSafetyError } from "../services/path-safety.js";
4
+ import { WRITE_ROUTE_RATE_LIMIT } from "../services/rate-limit-config.js";
5
+ import { requireScope } from "../services/token-auth.js";
6
+ function isNonEmptyString(value) {
7
+ return typeof value === 'string' && value.length > 0;
8
+ }
9
+ function isPlainObject(value) {
10
+ return typeof value === 'object' && value !== null;
11
+ }
12
+ function parseOperation(value) {
13
+ if (!isPlainObject(value)) {
14
+ return null;
15
+ }
16
+ const { type } = value;
17
+ switch (type) {
18
+ case 'draft-write': {
19
+ const { path, content, expectedEtag } = value;
20
+ if (!isNonEmptyString(path) || !isPlainObject(content) || !isNonEmptyString(expectedEtag)) {
21
+ return null;
22
+ }
23
+ return { type, path, content, expectedEtag };
24
+ }
25
+ case 'draft-discard': {
26
+ const { path } = value;
27
+ if (!isNonEmptyString(path)) {
28
+ return null;
29
+ }
30
+ return { type, path };
31
+ }
32
+ case 'content-delete': {
33
+ const { path, redirectTo } = value;
34
+ if (!isNonEmptyString(path) || (redirectTo !== undefined && !isNonEmptyString(redirectTo))) {
35
+ return null;
36
+ }
37
+ return { type, path, redirectTo };
38
+ }
39
+ case 'move': {
40
+ const { from, to } = value;
41
+ if (!isNonEmptyString(from) || !isNonEmptyString(to)) {
42
+ return null;
43
+ }
44
+ return { type, from, to };
45
+ }
46
+ default:
47
+ return null;
48
+ }
49
+ }
50
+ function parsePublish(value) {
51
+ if (!isPlainObject(value)) {
52
+ return null;
53
+ }
54
+ const { paths } = value;
55
+ if (!Array.isArray(paths) || paths.length === 0 || !paths.every(isNonEmptyString)) {
56
+ return null;
57
+ }
58
+ return { relativePaths: paths };
59
+ }
60
+ const BATCH_BODY_SHAPE = 'Expected { operations: BatchOperation[], publish?: { paths: string[] }, message: string, author: { name, email } }';
61
+ function parseBatchBody(body) {
62
+ if (!isPlainObject(body)) {
63
+ return null;
64
+ }
65
+ const { operations, publish, message, author } = body;
66
+ if (!Array.isArray(operations)) {
67
+ return null;
68
+ }
69
+ const parsedOperations = [];
70
+ for (const raw of operations) {
71
+ const operation = parseOperation(raw);
72
+ if (!operation) {
73
+ return null;
74
+ }
75
+ parsedOperations.push(operation);
76
+ }
77
+ let parsedPublish;
78
+ if (publish !== undefined) {
79
+ const result = parsePublish(publish);
80
+ if (!result) {
81
+ return null;
82
+ }
83
+ parsedPublish = result;
84
+ }
85
+ if (!isNonEmptyString(message) || !isValidCommitAuthor(author)) {
86
+ return null;
87
+ }
88
+ return { operations: parsedOperations, publish: parsedPublish, message, author };
89
+ }
90
+ // Maps every BatchReason to a status code, mirroring the same mapping
91
+ // each originating service's own route already applies (publish.ts's
92
+ // replyForPublishError, content.ts's move/delete handlers, drafts.ts's
93
+ // conflict/validation-failed handling) - kept as one switch here since
94
+ // batch.ts's BatchReason is a superset drawn from all four services.
95
+ function replyForBatchError(reply, error) {
96
+ switch (error.reason) {
97
+ case 'conflict':
98
+ reply.code(409).send({ statusCode: 409, error: 'Conflict', message: error.message });
99
+ return;
100
+ case 'destination-exists':
101
+ reply.code(409).send({ statusCode: 409, error: 'Conflict', message: error.message });
102
+ return;
103
+ case 'has-children':
104
+ reply.code(409).send({ statusCode: 409, error: 'Conflict', message: error.message });
105
+ return;
106
+ case 'validation-failed':
107
+ case 'duplicate-path':
108
+ case 'redirect-cycle':
109
+ reply.code(400).send({ statusCode: 400, error: 'Bad Request', message: error.message });
110
+ return;
111
+ case 'page-not-found':
112
+ case 'source-not-found':
113
+ case 'draft-not-found':
114
+ reply.code(404).send({ statusCode: 404, error: 'Not Found', message: error.message });
115
+ return;
116
+ default:
117
+ // write-failed / commit-failed / rollback-failed: no client-side
118
+ // fix applies, fall through to the global handler's sanitised 500.
119
+ throw error;
120
+ }
121
+ }
122
+ export const batchRoutes = async (fastify, opts) => {
123
+ fastify.post('/batch', { preHandler: requireScope(opts.tokens, 'content'), config: WRITE_ROUTE_RATE_LIMIT }, async (request, reply) => {
124
+ const parsed = parseBatchBody(request.body);
125
+ if (!parsed) {
126
+ reply.code(400).send({ statusCode: 400, error: 'Bad Request', message: BATCH_BODY_SHAPE });
127
+ return;
128
+ }
129
+ try {
130
+ await runBatch(opts.config, opts.theme.current.themeSchemas, parsed.operations, parsed.publish, parsed.message, parsed.author);
131
+ reply.send({ ok: true });
132
+ }
133
+ catch (error) {
134
+ // PathSafetyError has no .statusCode - left uncaught it falls
135
+ // through to the global handler's sanitised 500, the same gap
136
+ // every other route touching a :path already guards against.
137
+ if (error instanceof PathSafetyError) {
138
+ reply.code(404).send({ statusCode: 404, error: 'Not Found', message: 'No content at that path' });
139
+ return;
140
+ }
141
+ if (error instanceof BatchError) {
142
+ replyForBatchError(reply, error);
143
+ return;
144
+ }
145
+ throw error;
146
+ }
147
+ });
148
+ };
@@ -0,0 +1,6 @@
1
+ import type { FastifyPluginAsync } from 'fastify';
2
+ export declare function readAgentVersion(): string;
3
+ export interface CapabilitiesRouteOptions {
4
+ maxUploadBytes: number;
5
+ }
6
+ export declare const capabilitiesRoutes: FastifyPluginAsync<CapabilitiesRouteOptions>;