@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,7 @@
1
+ <span class="logo">
2
+ <svg class="logo__mark" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
3
+ <path d="M16 2 L29 9 V23 L16 30 L3 23 V9 Z" fill="currentColor" fill-opacity="0.08" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>
4
+ <path d="M16 2 L16 30 M3 9 L16 15 L29 9 M3 23 L16 15" stroke="var(--accent)" stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round"/>
5
+ </svg>
6
+ <span>Minamo</span>
7
+ </span>
@@ -0,0 +1,107 @@
1
+ {%- comment -%}
2
+ responsive-media - the only way a content image or video should reach a page.
3
+
4
+ Renders a `format: "image"` or `format: "video"` setting, and marks the
5
+ result as a drag-and-drop target so an editor can drag a file from the
6
+ admin's media library straight onto it in the live preview.
7
+
8
+ {% render 'responsive-media',
9
+ media: section.settings.image, the whole setting object, not .url
10
+ kind: 'image', 'image' or 'video'
11
+ field: 'image', REQUIRED - the settings key this
12
+ renders, which is what a drop writes
13
+ back to
14
+ alt: 'What is actually in the picture',
15
+ ratio: '16 / 9', any CSS aspect-ratio value
16
+ loading: 'eager', 'eager' above the fold, else lazy
17
+ priority: true, adds fetchpriority=high
18
+ need: 'Wide shot of the still house', shown in the empty slot
19
+ class: 'extra-class' %}
20
+
21
+ Why `field` matters
22
+ -------------------
23
+ The admin finds a drop target by looking for data-cms-media, and uses its
24
+ value to know which setting to write the new URL into. Get it wrong and the
25
+ drop silently writes to the wrong field; omit it and the image simply is not
26
+ droppable. It must match the schema property name exactly.
27
+
28
+ An empty slot is NOT a drop target (decided directly). A theme has no way to
29
+ tell whether it is rendering for the admin preview or for the public site -
30
+ the renderer does not expose that - so an editor-only affordance would leak
31
+ onto the live site as a dashed box in front of real visitors. Guard the call
32
+ instead, exactly as hero.liquid and cta-banner.liquid do:
33
+
34
+ {% if section.settings.image.url %}{% render 'responsive-media', ... %}{% endif %}
35
+
36
+ so a section with nothing set renders nothing at all. Set the first image
37
+ through the Fields panel's own picker; drag-and-drop then replaces it.
38
+
39
+ No srcset, on purpose
40
+ ---------------------
41
+ The CMS names uploaded files by a hash of their own bytes, so a resized
42
+ variant gets a completely different name - "photo-480w.webp" is not
43
+ derivable from "photo-3f9a2b7c1e04.jpg". Hand-built variant URLs therefore
44
+ point at files that do not exist, which renders a page that looks perfectly
45
+ fine except at one breakpoint. Do not add a srcset here. One image is one
46
+ file and one URL; let CSS do the responsive work.
47
+ {%- endcomment -%}
48
+ {%- assign media_kind = kind | default: 'image' -%}
49
+ {%- assign media_field = field | default: 'image' -%}
50
+ {%- assign media_url = media.url -%}
51
+ {%- capture ratio_style -%}--media-ratio: {{ ratio | default: '3 / 2' }};{%- endcapture -%}
52
+
53
+ {%- if media_url == nil or media_url == '' -%}
54
+ {%- comment -%}
55
+ Reached only if a caller renders this without guarding on a url - the
56
+ scaffold's own call sites guard, so this never appears on a real page.
57
+ Kept as a graceful fallback for a theme that genuinely wants a visible
58
+ placeholder, and deliberately carrying NO drag attributes: see above.
59
+ {%- endcomment -%}
60
+ <div class="media media--empty {{ class }}"
61
+ style="{{ ratio_style }}"
62
+ role="img"
63
+ aria-label="Placeholder. {{ need | default: alt }}">
64
+ <span class="media__hint">{{ need | default: alt | default: 'Drag a file here' }}</span>
65
+ </div>
66
+ {%- elsif media_kind == 'video' -%}
67
+ {%- comment -%}
68
+ No data-cms-image on a video: an older admin understands only that
69
+ attribute and would write an image-shaped value ({url, focalX, focalY})
70
+ over this field's own {url, poster}. Better it ignores the slot entirely
71
+ than corrupts it.
72
+
73
+ A background loop is muted and looping by definition, so it may autoplay.
74
+ The poster is what shows before the clip loads and wherever it will not
75
+ play at all, which is why it is worth setting rather than optional.
76
+ {%- endcomment -%}
77
+ <div class="media {{ class }}{% if media.poster and media.poster != '' %} media--has-poster{% endif %}"
78
+ style="{{ ratio_style }}{% if media.poster and media.poster != '' %} --media-poster: url('{{ media.poster }}');{% endif %}"
79
+ data-cms-media="{{ media_field }}"
80
+ data-cms-media-kind="video">
81
+ <video
82
+ src="{{ media_url }}"
83
+ {% if media.poster and media.poster != '' %}poster="{{ media.poster }}"{% endif %}
84
+ autoplay
85
+ muted
86
+ loop
87
+ playsinline
88
+ preload="metadata"
89
+ {% if alt and alt != '' %}aria-label="{{ alt }}"{% else %}aria-hidden="true"{% endif %}></video>
90
+ </div>
91
+ {%- else -%}
92
+ {%- assign fx = media.focalX | default: 0.5 | times: 100 | round: 1 -%}
93
+ {%- assign fy = media.focalY | default: 0.5 | times: 100 | round: 1 -%}
94
+ <div class="media {{ class }}"
95
+ style="{{ ratio_style }}"
96
+ data-cms-media="{{ media_field }}"
97
+ data-cms-media-kind="image"
98
+ data-cms-image="{{ media_field }}">
99
+ <img
100
+ src="{{ media_url }}"
101
+ alt="{{ alt }}"
102
+ loading="{{ loading | default: 'lazy' }}"
103
+ decoding="async"
104
+ {% if priority %}fetchpriority="high"{% endif %}
105
+ style="object-position: {{ fx }}% {{ fy }}%;">
106
+ </div>
107
+ {%- endif -%}
@@ -0,0 +1 @@
1
+ <span class="site-name">{{ name }}</span>
@@ -0,0 +1,33 @@
1
+ # Generic and site-content-agnostic - this file never needs to change
2
+ # per site. Runs identically via plain `docker build`/`docker run` on a
3
+ # bare VPS, Fly, ECS, or any PaaS - nothing platform-specific is baked
4
+ # in here (see docs/guide-hosting.md for platform-specific deploy config,
5
+ # which always layers on top of this, never inside it).
6
+ #
7
+ # Lives in vhost/, not the site root, alongside the rest of the site's
8
+ # own serving config (package.json, server.js, site.config.json) - the
9
+ # build context is still the site root, so build with:
10
+ # docker build -f vhost/Dockerfile -t my-site .
11
+ FROM node:22-alpine
12
+
13
+ # git is a hard runtime requirement (services/startup-checks.ts fails
14
+ # fast without it) - content, drafts and publishes are all real git
15
+ # operations, not a database.
16
+ RUN apk add --no-cache git
17
+
18
+ # Build-time install happens against the image-baked copy of the site
19
+ # ("/seed"), never directly against "/site" - "/site" is where a
20
+ # persistent volume gets mounted at runtime, and anything written there
21
+ # during the build would simply be shadowed by that mount.
22
+ WORKDIR /seed
23
+ COPY . .
24
+ RUN cd vhost && npm install --omit=dev
25
+
26
+ WORKDIR /site
27
+ COPY vhost/docker-entrypoint.sh /docker-entrypoint.sh
28
+ RUN chmod +x /docker-entrypoint.sh
29
+
30
+ # Must match vhost/site.config.json's own "port" field.
31
+ EXPOSE 3000
32
+
33
+ ENTRYPOINT ["/docker-entrypoint.sh"]
@@ -0,0 +1,58 @@
1
+ #!/bin/sh
2
+ set -e
3
+
4
+ # /seed is the image-baked copy of the site; /site is where the
5
+ # persistent volume is mounted. Overridable only so the test suite can
6
+ # run this exact script against temporary directories.
7
+ SEED="${CMS_SEED_DIR:-/seed}"
8
+ SITE="${CMS_SITE_DIR:-/site}"
9
+
10
+ # First boot against an empty mounted volume: seed it from the
11
+ # image-baked copy at /seed (content, theme, the already-`npm
12
+ # install`ed vhost/node_modules). A later boot finds /site/vhost
13
+ # already populated and skips this, so the same image works
14
+ # identically with a real persistent volume mounted at /site
15
+ # (production) or with nothing mounted at all (a quick local trial,
16
+ # where content just lives in the ephemeral container layer).
17
+ if [ ! -d "$SITE/vhost" ]; then
18
+ cp -r "$SEED/." "$SITE/"
19
+ else
20
+ # Every later boot: refresh the CMS itself from the image, and
21
+ # nothing else. Content, drafts, media and theme on the volume are
22
+ # the live site's own and are never touched - but the installed CMS
23
+ # is code, not content, so without this a redeploy built from a newer
24
+ # version would keep running the old one from the volume forever.
25
+ # Only the dependency manifest, its installed packages and server.js
26
+ # (the CMS's own, which imports it by name: the package was renamed
27
+ # from @o-a/cms-agent to @minamocms/framework) are copied; site.config.json
28
+ # (tokens) stays as it is on the volume.
29
+ #
30
+ # This leaves vhost/package.json (and package-lock.json) showing as
31
+ # modified in the volume's git working tree after an upgrade. That is
32
+ # deliberate: nothing here commits, since publishing is the only
33
+ # routine operation that creates a commit.
34
+ cp "$SEED/vhost/package.json" "$SITE/vhost/package.json"
35
+ if [ -f "$SEED/vhost/package-lock.json" ]; then
36
+ cp "$SEED/vhost/package-lock.json" "$SITE/vhost/package-lock.json"
37
+ fi
38
+ rm -rf "$SITE/vhost/node_modules"
39
+ cp -r "$SEED/vhost/node_modules" "$SITE/vhost/node_modules"
40
+ cp "$SEED/vhost/server.js" "$SITE/vhost/server.js"
41
+ fi
42
+
43
+ # The site root must be a real git repository (services/startup-checks.ts) -
44
+ # a plain `docker build` from a full local checkout preserves .git, but
45
+ # some platforms (Railway's `railway up` included) build from a
46
+ # git-archive-style upload of tracked file contents only, which never
47
+ # includes .git at all. Recovered here rather than assumed away: if
48
+ # it's missing, start a fresh repo over the already-seeded content.
49
+ if [ ! -d "$SITE/.git" ]; then
50
+ git -C "$SITE" init --quiet
51
+ git -C "$SITE" add -A
52
+ GIT_AUTHOR_NAME="Minamo" GIT_AUTHOR_EMAIL="minamo@localhost" \
53
+ GIT_COMMITTER_NAME="Minamo" GIT_COMMITTER_EMAIL="minamo@localhost" \
54
+ git -C "$SITE" commit --quiet -m "chore: initial scaffold (recovered - .git was not part of the deploy upload)"
55
+ fi
56
+
57
+ cd "$SITE/vhost"
58
+ exec node server.js
@@ -0,0 +1,2 @@
1
+ export { startServer, buildServer } from './server.ts';
2
+ export type { BuildServerOptions } from './server.ts';
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ // The package's public entry point (package.json's "main"): a site's
2
+ // real server.js ("import the agent, point it at this directory,
3
+ // start", per the build plan) imports startServer from here.
4
+ export { startServer, buildServer } from "./server.js";
@@ -0,0 +1,11 @@
1
+ export interface MediaListEntry {
2
+ name: string;
3
+ size: number;
4
+ mtimeMs: number;
5
+ }
6
+ export interface MediaStorageDriver {
7
+ list(): Promise<MediaListEntry[]>;
8
+ put(filename: string, bytes: Buffer): Promise<void>;
9
+ get(filename: string): Promise<Buffer | null>;
10
+ delete(filename: string): Promise<boolean>;
11
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,3 @@
1
+ import type { MediaStorageDriver } from './driver.ts';
2
+ export declare const DRIVER_NAME = "local-fs";
3
+ export declare function openLocalFsMediaDriver(root: string): MediaStorageDriver;
@@ -0,0 +1,94 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import { PathSafetyError, sanitisePath } from "../../services/path-safety.js";
4
+ // A plain string constant, matching node-sqlite-driver.ts's own
5
+ // DRIVER_NAME convention - safe for the capabilities endpoint to
6
+ // report without needing to import anything fs-touching.
7
+ export const DRIVER_NAME = 'local-fs';
8
+ // The default (and, for this build increment, only) MediaStorageDriver
9
+ // implementation. No driver-containment static-analysis test exists
10
+ // for this yet, unlike search/drivers' own G5 check for node:sqlite -
11
+ // node:fs is a single already-pervasive Node builtin, not a swappable
12
+ // third-party library the way node:sqlite/better-sqlite3 are, so
13
+ // there's no "which alternative" question to fence off yet. The
14
+ // natural trigger for that kind of test is a future object-storage
15
+ // driver's own third-party SDK import.
16
+ //
17
+ // Every method resolves filename through sanitisePath itself before
18
+ // touching disk - this file importing both node:fs and sanitisePath
19
+ // is what satisfies the B7 static-analysis rule with no allowlist
20
+ // entry needed, matching assets.ts/content.ts's own pattern of the
21
+ // fs-touching file being the one that calls it.
22
+ export function openLocalFsMediaDriver(root) {
23
+ return {
24
+ async list() {
25
+ mkdirSync(root, { recursive: true });
26
+ return readdirSync(root, { withFileTypes: true })
27
+ .filter((entry) => entry.isFile())
28
+ .map((entry) => {
29
+ const stats = statSync(sanitisePath(root, entry.name));
30
+ return { name: entry.name, size: stats.size, mtimeMs: stats.mtimeMs };
31
+ });
32
+ },
33
+ async put(filename, bytes) {
34
+ // The root first: sanitisePath resolves the root's real path, which
35
+ // fails outright on a fresh deploy whose media folder was never
36
+ // created (media/ is git-ignored, so a host building from the
37
+ // repository has none).
38
+ mkdirSync(root, { recursive: true });
39
+ const resolved = sanitisePath(root, filename);
40
+ // Harmless no-op for a flat, content-addressed namespace (media
41
+ // filenames never contain subdirectories) - matches
42
+ // manage-menus.ts's own defensive precedent of creating the
43
+ // parent directory before a write rather than assuming it
44
+ // already exists.
45
+ mkdirSync(dirname(resolved), { recursive: true });
46
+ // No existence check first: content-addressed naming means an
47
+ // identical re-upload writes identical bytes over identical
48
+ // bytes - a harmless idempotent overwrite, not something that
49
+ // needs special-casing.
50
+ writeFileSync(resolved, bytes);
51
+ },
52
+ async get(filename) {
53
+ // No media folder yet is no media yet: nothing to find, and a read
54
+ // has no business creating the folder.
55
+ if (!existsSync(root)) {
56
+ return null;
57
+ }
58
+ let resolved;
59
+ try {
60
+ resolved = sanitisePath(root, filename);
61
+ }
62
+ catch (error) {
63
+ // An invalid/traversal-shaped filename reads as "not found"
64
+ // from this method's own contract - the route layer is what
65
+ // decides whether that's a 404 or something else, the driver
66
+ // itself stays mechanical.
67
+ if (error instanceof PathSafetyError) {
68
+ return null;
69
+ }
70
+ throw error;
71
+ }
72
+ if (!existsSync(resolved) || !statSync(resolved).isFile()) {
73
+ return null;
74
+ }
75
+ return readFileSync(resolved);
76
+ },
77
+ async delete(filename) {
78
+ // Unlike get(), a malformed delete path is let through
79
+ // uncaught - worth surfacing distinctly rather than silently
80
+ // reading as "already didn't exist", matching
81
+ // manage-redirects.ts's own not-found-is-a-distinct-reason
82
+ // precedent.
83
+ if (!existsSync(root)) {
84
+ return false;
85
+ }
86
+ const resolved = sanitisePath(root, filename);
87
+ if (!existsSync(resolved)) {
88
+ return false;
89
+ }
90
+ unlinkSync(resolved);
91
+ return true;
92
+ },
93
+ };
94
+ }
@@ -0,0 +1,2 @@
1
+ export declare const ALLOWED_UPLOAD_EXTENSIONS: Set<string>;
2
+ export declare function buildMediaFilename(originalFilename: string, bytes: Buffer): string;
@@ -0,0 +1,54 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { basename, extname } from 'node:path';
3
+ // Content-addressed: the filename is derived from a hash of the
4
+ // file's own bytes, so two uploads can never collide, and an
5
+ // identical re-upload naturally produces the identical filename (a
6
+ // harmless overwrite in local-fs-driver.ts's put(), not a duplicate).
7
+ // No separate manifest/index recording original filenames - this
8
+ // codebase avoids a second source of truth on principle (the same
9
+ // reasoning that keeps the SQLite search index disposable rather than
10
+ // authoritative), so the original name is folded into the filename
11
+ // itself instead of tracked anywhere else.
12
+ //
13
+ // Truncated to 12 hex chars (48 bits), not the full 64-char digest -
14
+ // git itself only shows short hashes for the same reason. At this
15
+ // length a collision between two different files stays astronomically
16
+ // unlikely (well past a 1-in-a-trillion chance even at tens of
17
+ // thousands of uploads to one site), while keeping filenames/URLs/git
18
+ // history actually readable.
19
+ //
20
+ // Slug regex matches generate-site.ts's own sanitisePackageName -
21
+ // lowercase, non [a-z0-9-] runs collapsed to a single "-", trimmed -
22
+ // not a second, independently-invented convention.
23
+ const HASH_LENGTH = 12;
24
+ // Images and short videos - not a general document library. Video was
25
+ // added deliberately (confirmed with the user) for silent looping
26
+ // clips used where a hero image would otherwise go; long-form video
27
+ // belongs on YouTube/Vimeo and needs nothing from here. The upload cap
28
+ // (site.config.json's media.maxUploadBytes, 10MB by default) is what
29
+ // keeps that distinction honest - it comfortably fits a well-compressed
30
+ // 10-15s loop and comfortably rejects a real film.
31
+ //
32
+ // .svg is rejected regardless of this list even though it's
33
+ // technically an image format - docs/cms-build-plan.md's own "SVG
34
+ // rejected outright, not sanitised" decision (a real stored-XSS path
35
+ // otherwise: mime-types.ts maps .svg to a real image/svg+xml content
36
+ // type, and an SVG loaded as a top-level navigation, not just <img>-
37
+ // embedded, can execute a script it carries).
38
+ //
39
+ // Exported from here, not left as routes/media.ts's own private
40
+ // constant, specifically so seed-media.ts (an offline tool with no
41
+ // HTTP request to validate) enforces the exact same rule rather than
42
+ // a second, independently-maintained copy of it.
43
+ export const ALLOWED_UPLOAD_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.mp4', '.webm']);
44
+ export function buildMediaFilename(originalFilename, bytes) {
45
+ const hash = createHash('sha256').update(bytes).digest('hex').slice(0, HASH_LENGTH);
46
+ const base = basename(originalFilename, extname(originalFilename));
47
+ const cleaned = base.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '');
48
+ const slug = cleaned.length > 0 ? cleaned : 'file';
49
+ const extension = extname(originalFilename).toLowerCase();
50
+ // Slug first, hash last (requested directly) - the readable part of
51
+ // the filename sorts and scans first in a directory listing/URL,
52
+ // with the hash as a suffix rather than something to read past.
53
+ return `${slug}-${hash}${extension}`;
54
+ }
@@ -0,0 +1,19 @@
1
+ import type { SiteConfig } from '../config.ts';
2
+ export type ManageMediaReason = 'not-found' | 'invalid-file-type';
3
+ export declare class ManageMediaError extends Error {
4
+ readonly reason: ManageMediaReason;
5
+ constructor(reason: ManageMediaReason, message: string);
6
+ }
7
+ export interface MediaEntry {
8
+ name: string;
9
+ size: number;
10
+ mtimeMs: number;
11
+ url: string;
12
+ }
13
+ export declare function listMedia(config: SiteConfig): Promise<MediaEntry[]>;
14
+ export declare function putMedia(config: SiteConfig, originalFilename: string, bytes: Buffer): Promise<{
15
+ name: string;
16
+ size: number;
17
+ url: string;
18
+ }>;
19
+ export declare function deleteMedia(config: SiteConfig, filename: string): Promise<void>;
@@ -0,0 +1,45 @@
1
+ import { enqueue } from "../services/write-queue.js";
2
+ import { openLocalFsMediaDriver } from "./drivers/local-fs-driver.js";
3
+ import { buildMediaFilename } from "./filename.js";
4
+ export class ManageMediaError extends Error {
5
+ reason;
6
+ constructor(reason, message) {
7
+ super(message);
8
+ this.name = 'ManageMediaError';
9
+ this.reason = reason;
10
+ }
11
+ }
12
+ function mediaUrl(name) {
13
+ return `/media/${name}`;
14
+ }
15
+ // Opens a fresh driver per call rather than threading one long-lived
16
+ // instance through BootedSite - unlike DatabaseSync, plain fs calls
17
+ // have no connection/handle to reuse, so there's no driver-lifecycle
18
+ // wiring needed. The only call site that decides which driver to open
19
+ // is here, hardcoded, matching how rebuild-index.ts hardcodes
20
+ // openNodeSqliteDriver today with no config-driven selection either -
21
+ // this is where a future config-driven driver choice would go.
22
+ function driverFor(config) {
23
+ return openLocalFsMediaDriver(config.mediaRoot);
24
+ }
25
+ // Not enqueued - reads bypass the write queue, matching
26
+ // handleListRedirects' own precedent of only serialising mutations.
27
+ export async function listMedia(config) {
28
+ const entries = await driverFor(config).list();
29
+ return entries.map((entry) => ({ ...entry, url: mediaUrl(entry.name) }));
30
+ }
31
+ export function putMedia(config, originalFilename, bytes) {
32
+ return enqueue(async () => {
33
+ const name = buildMediaFilename(originalFilename, bytes);
34
+ await driverFor(config).put(name, bytes);
35
+ return { name, size: bytes.length, url: mediaUrl(name) };
36
+ });
37
+ }
38
+ export function deleteMedia(config, filename) {
39
+ return enqueue(async () => {
40
+ const existed = await driverFor(config).delete(filename);
41
+ if (!existed) {
42
+ throw new ManageMediaError('not-found', `No media file at "${filename}"`);
43
+ }
44
+ });
45
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env node
2
+ import { resolve } from 'node:path';
3
+ import { seedMedia } from "./seed-media.js";
4
+ const [targetArg, ...sourceArgs] = process.argv.slice(2);
5
+ if (!targetArg || sourceArgs.length === 0) {
6
+ console.error('Usage: seed-media <site-directory> <path> [<path> ...]');
7
+ process.exit(1);
8
+ }
9
+ const siteDir = resolve(process.cwd(), targetArg);
10
+ const sourcePaths = sourceArgs.map((sourceArg) => resolve(process.cwd(), sourceArg));
11
+ const result = seedMedia(siteDir, sourcePaths);
12
+ for (const entry of result.entries) {
13
+ if (entry.status === 'seeded') {
14
+ console.log(`${entry.sourcePath} -> ${entry.url}`);
15
+ }
16
+ else {
17
+ console.log(`${entry.sourcePath}: skipped (${entry.reason})`);
18
+ }
19
+ }
20
+ if (!result.ok) {
21
+ process.exit(1);
22
+ }
@@ -0,0 +1,11 @@
1
+ export interface SeedMediaEntry {
2
+ sourcePath: string;
3
+ status: 'seeded' | 'skipped-invalid-type';
4
+ url?: string;
5
+ reason?: string;
6
+ }
7
+ export interface SeedMediaResult {
8
+ ok: boolean;
9
+ entries: SeedMediaEntry[];
10
+ }
11
+ export declare function seedMedia(siteRoot: string, sourcePaths: string[]): SeedMediaResult;
@@ -0,0 +1,76 @@
1
+ import { mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
2
+ import { basename, extname, join } from 'node:path';
3
+ import { ALLOWED_UPLOAD_EXTENSIONS, buildMediaFilename } from "./filename.js";
4
+ // A directory argument expands to its own direct child files only -
5
+ // one level, not recursive, matching theme-schemas.ts's own flat-
6
+ // directory convention for exactly the same reason (a predictable,
7
+ // easy-to-reason-about walk, not a surprise deep scan).
8
+ function expandSourcePaths(sourcePaths) {
9
+ const files = [];
10
+ for (const sourcePath of sourcePaths) {
11
+ if (statSync(sourcePath).isDirectory()) {
12
+ for (const entry of readdirSync(sourcePath, { withFileTypes: true })) {
13
+ if (entry.isFile()) {
14
+ files.push(join(sourcePath, entry.name));
15
+ }
16
+ }
17
+ }
18
+ else {
19
+ files.push(sourcePath);
20
+ }
21
+ }
22
+ return files;
23
+ }
24
+ // Seeds one or more local image files directly into a site's media/,
25
+ // computing the exact same content-addressed filename a real
26
+ // POST /v1/media upload would - see this module's own filename.ts for
27
+ // why that's safe to do offline (a pure function of a filename and the
28
+ // file's own bytes, no server/database involved). For an AI agent (or
29
+ // any offline tool) generating a site's starter content before a
30
+ // server is even running - GET /media/* just serves whatever file
31
+ // exists at the requested path, so the result is indistinguishable
32
+ // from a real upload.
33
+ //
34
+ // siteRoot is an operator/agent-supplied directory, the same category
35
+ // as scaffoldSite/mintToken - not a web request's own :path, so
36
+ // sanitisePath's traversal concern doesn't apply here (see
37
+ // mint-token.ts's own comment on this exact point).
38
+ //
39
+ // Never a git commit - media is never git-tracked (constraint 2,
40
+ // matching manage-media.ts's own "no commit, no author... Media is
41
+ // never git-tracked" comment).
42
+ //
43
+ // A disallowed file type is skipped, not a reason to abort the whole
44
+ // batch - an agent seeding a dozen images should still get the eleven
45
+ // good ones, with the one bad one clearly reported. The allowlist
46
+ // itself is the one thing that must not be relaxed here: it's the
47
+ // same rule the real upload route enforces (ALLOWED_UPLOAD_EXTENSIONS,
48
+ // shared, not a second copy), because skipping it would reopen the
49
+ // real stored-XSS path SVG rejection exists to close, for whoever
50
+ // later visits the live site - not a concern about this tool's own
51
+ // caller, who already has full local trust.
52
+ export function seedMedia(siteRoot, sourcePaths) {
53
+ const mediaRoot = join(siteRoot, 'media');
54
+ mkdirSync(mediaRoot, { recursive: true });
55
+ const entries = [];
56
+ for (const sourcePath of expandSourcePaths(sourcePaths)) {
57
+ const extension = extname(sourcePath).toLowerCase();
58
+ if (!ALLOWED_UPLOAD_EXTENSIONS.has(extension)) {
59
+ entries.push({
60
+ sourcePath,
61
+ status: 'skipped-invalid-type',
62
+ reason: `"${extension}" is not an accepted image type`,
63
+ });
64
+ continue;
65
+ }
66
+ const bytes = readFileSync(sourcePath);
67
+ const name = buildMediaFilename(basename(sourcePath), bytes);
68
+ // No existence check first: content-addressed naming means
69
+ // re-seeding the same bytes writes identical bytes over identical
70
+ // bytes - a harmless idempotent overwrite, matching
71
+ // local-fs-driver.ts's own put() precedent exactly.
72
+ writeFileSync(join(mediaRoot, name), bytes);
73
+ entries.push({ sourcePath, status: 'seeded', url: `/media/${name}` });
74
+ }
75
+ return { ok: entries.every((entry) => entry.status === 'seeded'), entries };
76
+ }
@@ -0,0 +1,3 @@
1
+ import type { MigrationMap } from '../services/migration-runner.ts';
2
+ export declare const CURRENT_SCHEMA_VERSION = 7;
3
+ export declare const migrations: MigrationMap;