@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,169 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { listFilesRecursively } from "./fs-walk.js";
3
+ import { sanitisePath } from "./path-safety.js";
4
+ import { siteSettingsPath } from "./site-settings.js";
5
+ import { pagePathToUrl } from "./urls.js";
6
+ // Internal links: where content points at the site's own pages, found
7
+ // and rewritten without the theme's schemas. A link is any text value
8
+ // that is wholly a site path ("/about", "/about#team") - what a link
9
+ // field, a menu item or a setting stores - and any href="..." inside a
10
+ // longer text value, which is how rich text stores one. Keeping links
11
+ // as plain paths was the owner's choice over permanent page IDs, so
12
+ // content stays readable; this is what keeps those paths correct.
13
+ // The page a link points at: its path without ?query or #fragment, and
14
+ // without a trailing slash (except the home page's "/"). null for
15
+ // anything that isn't a link to a page here - an external address,
16
+ // mailto:/tel:, a bare #anchor, protocol-relative //host, a media or
17
+ // theme asset, or a file such as /robots.txt.
18
+ export function linkTarget(href) {
19
+ const trimmed = href.trim();
20
+ if (!trimmed.startsWith('/') || trimmed.startsWith('//') || /\s/.test(trimmed)) {
21
+ return null;
22
+ }
23
+ const path = trimmed.replace(/[?#].*$/, '');
24
+ if (path.startsWith('/media/') || path.startsWith('/assets/') || /\.[A-Za-z0-9]+$/.test(path)) {
25
+ return null;
26
+ }
27
+ return path.length > 1 ? path.replace(/\/+$/, '') : path;
28
+ }
29
+ const HREF = /(href\s*=\s*)(["'])(.*?)\2/gi;
30
+ // Every link in a piece of content, as written (not yet reduced to a
31
+ // target), in document order.
32
+ export function collectLinks(value) {
33
+ const links = [];
34
+ const visit = (node) => {
35
+ if (typeof node === 'string') {
36
+ if (linkTarget(node) !== null) {
37
+ links.push(node);
38
+ }
39
+ else if (node.includes('href')) {
40
+ for (const match of node.matchAll(HREF)) {
41
+ links.push(match[3]);
42
+ }
43
+ }
44
+ }
45
+ else if (Array.isArray(node)) {
46
+ node.forEach(visit);
47
+ }
48
+ else if (node !== null && typeof node === 'object') {
49
+ Object.values(node).forEach(visit);
50
+ }
51
+ };
52
+ visit(value);
53
+ return links;
54
+ }
55
+ // A link moved from one page path to another: the page itself, or
56
+ // anything under it (a child page), keeping whatever followed the path
57
+ // (?query, #fragment, a trailing slash). null when the link isn't to a
58
+ // moved page.
59
+ function moveLink(href, from, to) {
60
+ const target = linkTarget(href);
61
+ if (target === null || (target !== from && !target.startsWith(`${from}/`))) {
62
+ return null;
63
+ }
64
+ const leading = href.length - href.trimStart().length;
65
+ const start = href.indexOf(target, leading);
66
+ return href.slice(0, start) + to + href.slice(start + from.length);
67
+ }
68
+ // A copy of some content with every link to `from` (or a page under it)
69
+ // pointing at `to` instead, and whether anything changed.
70
+ export function rewriteLinks(value, from, to) {
71
+ let changed = false;
72
+ const visit = (node) => {
73
+ if (typeof node === 'string') {
74
+ const moved = moveLink(node, from, to);
75
+ if (moved !== null) {
76
+ changed = true;
77
+ return moved;
78
+ }
79
+ if (!node.includes('href')) {
80
+ return node;
81
+ }
82
+ return node.replace(HREF, (whole, prefix, quote, href) => {
83
+ const next = moveLink(href, from, to);
84
+ if (next === null) {
85
+ return whole;
86
+ }
87
+ changed = true;
88
+ return `${prefix}${quote}${next}${quote}`;
89
+ });
90
+ }
91
+ if (Array.isArray(node)) {
92
+ return node.map(visit);
93
+ }
94
+ if (node !== null && typeof node === 'object') {
95
+ return Object.fromEntries(Object.entries(node).map(([key, child]) => [key, visit(child)]));
96
+ }
97
+ return node;
98
+ };
99
+ const next = visit(value);
100
+ return { value: next, changed };
101
+ }
102
+ // Every file whose links matter: live pages, drafts, menus and the site
103
+ // settings. Each file is sanitisePath'd under its agent-configured root.
104
+ export function linkSources(config) {
105
+ const sources = [];
106
+ const add = (kind, root, relativeTo, prefix) => {
107
+ if (!existsSync(root)) {
108
+ return;
109
+ }
110
+ for (const relative of listFilesRecursively(root, relativeTo, '.json')) {
111
+ sources.push({ kind, path: `${prefix}${relative}`, file: sanitisePath(relativeTo, relative) });
112
+ }
113
+ };
114
+ add('page', config.pagesRoot, config.contentRoot, '');
115
+ add('draft', config.draftsRoot, config.draftsRoot, 'drafts/');
116
+ add('menu', config.menusRoot, config.contentRoot, '');
117
+ const settings = siteSettingsPath(config);
118
+ if (existsSync(settings)) {
119
+ sources.push({ kind: 'settings', path: 'settings.json', file: settings });
120
+ }
121
+ return sources;
122
+ }
123
+ function readJson(file) {
124
+ try {
125
+ return JSON.parse(readFileSync(file, 'utf-8'));
126
+ }
127
+ catch {
128
+ return undefined;
129
+ }
130
+ }
131
+ function labelOf(source, content) {
132
+ if (source.kind === 'settings') {
133
+ return 'Site settings';
134
+ }
135
+ const { title, name } = (content ?? {});
136
+ if (typeof title === 'string' && title.trim() !== '') {
137
+ return title;
138
+ }
139
+ if (typeof name === 'string' && name.trim() !== '') {
140
+ return name;
141
+ }
142
+ return source.path;
143
+ }
144
+ function pageUrlOf(source) {
145
+ const relative = source.kind === 'page' ? source.path : source.kind === 'draft' ? source.path.slice('drafts/'.length) : null;
146
+ return relative !== null && relative.startsWith('pages/') ? pagePathToUrl(relative.slice('pages/'.length)) : undefined;
147
+ }
148
+ // Everything that links to one page (exactly that page, not ones under
149
+ // it): what the admin lists before a delete. A page's own links to
150
+ // itself don't count.
151
+ export function findReferences(config, to) {
152
+ const target = linkTarget(to);
153
+ if (target === null) {
154
+ return [];
155
+ }
156
+ const references = [];
157
+ for (const source of linkSources(config)) {
158
+ const url = pageUrlOf(source);
159
+ if (url === target) {
160
+ continue;
161
+ }
162
+ const content = readJson(source.file);
163
+ const hrefs = collectLinks(content).filter((href) => linkTarget(href) === target);
164
+ if (hrefs.length > 0) {
165
+ references.push({ kind: source.kind, path: source.path, label: labelOf(source, content), ...(url ? { url } : {}), hrefs });
166
+ }
167
+ }
168
+ return references;
169
+ }
@@ -0,0 +1,15 @@
1
+ import type { SiteConfig } from '../config.ts';
2
+ import type { CommitAuthor } from './git.ts';
3
+ export type ManageMenuReason = 'validation-failed' | 'not-found' | 'conflict' | 'write-failed' | 'commit-failed' | 'rollback-failed';
4
+ export declare class ManageMenuError extends Error {
5
+ readonly reason: ManageMenuReason;
6
+ constructor(reason: ManageMenuReason, message: string, options?: {
7
+ cause?: unknown;
8
+ });
9
+ }
10
+ export declare function saveMenu(config: SiteConfig, relativePath: string, content: unknown, expectedEtag: string, message: string, author: CommitAuthor): Promise<string>;
11
+ export interface RenameMenuResult {
12
+ etag: string;
13
+ staleThemeReferences: string[];
14
+ }
15
+ export declare function renameMenu(config: SiteConfig, from: string, to: string, expectedEtag: string, message: string, author: CommitAuthor): Promise<RenameMenuResult>;
@@ -0,0 +1,138 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import { ContentReadError, readContentFile } from "./content-read.js";
4
+ import { computeEtag, etagsMatch } from "./etag.js";
5
+ import { commitPaths } from "./git.js";
6
+ import { findMenuReferences, isValidMenuHandle } from "./menu-references.js";
7
+ import { sanitisePath } from "./path-safety.js";
8
+ import { validateMenu } from "./validation.js";
9
+ import { enqueue } from "./write-queue.js";
10
+ export class ManageMenuError extends Error {
11
+ reason;
12
+ constructor(reason, message, options) {
13
+ super(message, options);
14
+ this.name = 'ManageMenuError';
15
+ this.reason = reason;
16
+ }
17
+ }
18
+ // Mirrors drafts.ts's readCurrentEtag, narrowed to a single root since
19
+ // menus have no draft state to prefer over live.
20
+ function readCurrentEtag(config, relativePath) {
21
+ try {
22
+ return readContentFile(config.menusRoot, relativePath).etag;
23
+ }
24
+ catch (error) {
25
+ if (!(error instanceof ContentReadError) || error.reason !== 'not-found') {
26
+ throw error;
27
+ }
28
+ return null;
29
+ }
30
+ }
31
+ async function saveMenuJob(config, relativePath, content, expectedEtag, message, author) {
32
+ // menusRoot is optional, unlike pagesRoot - a site that has never
33
+ // had a menu has no content/menus/ at all. sanitisePath unconditionally
34
+ // realpaths its root argument, so it must exist before the very
35
+ // first menu is ever created, or a legitimate first-write crashes
36
+ // with a raw ENOENT instead of just creating the directory.
37
+ mkdirSync(config.menusRoot, { recursive: true });
38
+ const menuPath = sanitisePath(config.menusRoot, relativePath);
39
+ // The If-Match check happens here, inside the queued job, never at
40
+ // the route layer before enqueuing - same TOCTOU reasoning as
41
+ // drafts.ts's saveDraftJob (a route-layer check-then-enqueue has a
42
+ // real, empirically-verified gap; the same check run inside the job
43
+ // does not).
44
+ const currentEtag = readCurrentEtag(config, relativePath);
45
+ if (currentEtag !== null && !etagsMatch(currentEtag, expectedEtag)) {
46
+ throw new ManageMenuError('conflict', `If-Match "${expectedEtag}" does not match the current ETag for "${relativePath}"`);
47
+ }
48
+ // currentEtag === null means no menu exists yet at this path (a
49
+ // brand-new menu) - matches saveDraftJob's own precedent of skipping
50
+ // the comparison for a wholly new resource.
51
+ const result = validateMenu(content);
52
+ if (!result.valid) {
53
+ throw new ManageMenuError('validation-failed', `Menu at "${relativePath}" failed validation: ${JSON.stringify(result.errors)}`);
54
+ }
55
+ const existedBefore = existsSync(menuPath);
56
+ const originalBytes = existedBefore ? readFileSync(menuPath) : null;
57
+ mkdirSync(dirname(menuPath), { recursive: true });
58
+ const bytes = Buffer.from(JSON.stringify(content, null, 2));
59
+ writeFileSync(menuPath, bytes);
60
+ // No draft state (Group N: menus behave like redirects.json) -
61
+ // commits immediately, mirroring manage-redirects.ts's shape, rather
62
+ // than drafts.ts's write-with-no-commit.
63
+ try {
64
+ commitPaths(config.siteRoot, [menuPath], message, author);
65
+ }
66
+ catch (error) {
67
+ try {
68
+ if (existedBefore) {
69
+ writeFileSync(menuPath, originalBytes);
70
+ }
71
+ else {
72
+ unlinkSync(menuPath);
73
+ }
74
+ }
75
+ catch (rollbackError) {
76
+ throw new ManageMenuError('rollback-failed', 'Menu save failed and rolling back afterwards also failed; the working tree may be inconsistent and needs manual inspection', { cause: rollbackError });
77
+ }
78
+ const detail = error instanceof Error ? error.message : String(error);
79
+ throw new ManageMenuError('commit-failed', `Menu save failed: ${detail}`, { cause: error });
80
+ }
81
+ return computeEtag(bytes);
82
+ }
83
+ export function saveMenu(config, relativePath, content, expectedEtag, message, author) {
84
+ return enqueue(() => saveMenuJob(config, relativePath, content, expectedEtag, message, author));
85
+ }
86
+ // Changes a menu's handle: moves content/menus/<from>.json to
87
+ // <to>.json in one commit. Contents are untouched, so the returned
88
+ // etag is the same one the menu already had. Refuses rather than
89
+ // overwriting when <to> is taken, and checks If-Match against <from>
90
+ // like every other menu write, so a rename made from a stale view is
91
+ // refused rather than applied.
92
+ async function renameMenuJob(config, from, to, expectedEtag, message, author) {
93
+ if (!isValidMenuHandle(from) || !isValidMenuHandle(to)) {
94
+ throw new ManageMenuError('validation-failed', 'A menu handle may only contain letters, numbers, hyphens and underscores');
95
+ }
96
+ if (from === to) {
97
+ throw new ManageMenuError('validation-failed', `The menu is already called "${to}"`);
98
+ }
99
+ mkdirSync(config.menusRoot, { recursive: true });
100
+ const fromPath = sanitisePath(config.menusRoot, `${from}.json`);
101
+ const toPath = sanitisePath(config.menusRoot, `${to}.json`);
102
+ const currentEtag = readCurrentEtag(config, `${from}.json`);
103
+ if (currentEtag === null) {
104
+ throw new ManageMenuError('not-found', `No menu with the handle "${from}"`);
105
+ }
106
+ if (!etagsMatch(currentEtag, expectedEtag)) {
107
+ throw new ManageMenuError('conflict', `If-Match "${expectedEtag}" does not match the current ETag for "${from}.json"`);
108
+ }
109
+ // existsSync is case-insensitive on a case-insensitive filesystem
110
+ // (macOS by default), so "main" -> "Main" is refused there rather
111
+ // than risking a rename onto itself. Harmless: pick another handle.
112
+ if (existsSync(toPath)) {
113
+ throw new ManageMenuError('conflict', `A menu with the handle "${to}" already exists`);
114
+ }
115
+ try {
116
+ renameSync(fromPath, toPath);
117
+ }
118
+ catch (error) {
119
+ throw new ManageMenuError('write-failed', `Could not rename "${from}" to "${to}"`, { cause: error });
120
+ }
121
+ try {
122
+ commitPaths(config.siteRoot, [fromPath, toPath], message, author);
123
+ }
124
+ catch (error) {
125
+ try {
126
+ renameSync(toPath, fromPath);
127
+ }
128
+ catch (rollbackError) {
129
+ throw new ManageMenuError('rollback-failed', 'Menu rename failed and rolling back afterwards also failed; the working tree may be inconsistent and needs manual inspection', { cause: rollbackError });
130
+ }
131
+ const detail = error instanceof Error ? error.message : String(error);
132
+ throw new ManageMenuError('commit-failed', `Menu rename failed: ${detail}`, { cause: error });
133
+ }
134
+ return { etag: currentEtag, staleThemeReferences: findMenuReferences(config, from) };
135
+ }
136
+ export function renameMenu(config, from, to, expectedEtag, message, author) {
137
+ return enqueue(() => renameMenuJob(config, from, to, expectedEtag, message, author));
138
+ }
@@ -0,0 +1,25 @@
1
+ import type { SiteConfig } from '../config.ts';
2
+ import type { CommitAuthor } from './git.ts';
3
+ import type { PreparedOperation } from './prepared-operation.ts';
4
+ import type { RedirectEntry } from './redirects.ts';
5
+ export type ManageRedirectReason = 'invalid-from' | 'invalid-target' | 'already-exists' | 'not-found' | 'live-content-exists' | 'redirect-cycle' | 'malformed-file' | 'write-failed' | 'commit-failed' | 'rollback-failed';
6
+ export declare class ManageRedirectError extends Error {
7
+ readonly reason: ManageRedirectReason;
8
+ constructor(reason: ManageRedirectReason, message: string, options?: {
9
+ cause?: unknown;
10
+ });
11
+ }
12
+ export interface RedirectMutationResult {
13
+ entry: RedirectEntry;
14
+ retargeted: RedirectEntry[];
15
+ }
16
+ export declare function prepareCreateRedirect(config: SiteConfig, from: string, to: string, note: string | undefined): PreparedOperation & {
17
+ result: RedirectMutationResult;
18
+ };
19
+ export declare function prepareUpdateRedirect(config: SiteConfig, from: string, to: string, note: string | undefined): PreparedOperation & {
20
+ result: RedirectMutationResult;
21
+ };
22
+ export declare function prepareDeleteRedirect(config: SiteConfig, from: string): PreparedOperation;
23
+ export declare function createRedirect(config: SiteConfig, from: string, to: string, note: string | undefined, message: string, author: CommitAuthor): Promise<RedirectMutationResult | undefined>;
24
+ export declare function updateRedirect(config: SiteConfig, from: string, to: string, note: string | undefined, message: string, author: CommitAuthor): Promise<RedirectMutationResult | undefined>;
25
+ export declare function deleteRedirect(config: SiteConfig, from: string, message: string, author: CommitAuthor): Promise<undefined>;
@@ -0,0 +1,156 @@
1
+ import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { commitPaths } from "./git.js";
3
+ import { sanitisePath } from "./path-safety.js";
4
+ import { RedirectError, addRedirect, isValidRedirectTarget, loadRedirectsStrict, removeRedirectForPath, serialiseRedirects, } from "./redirects.js";
5
+ import { urlToPagePath } from "./urls.js";
6
+ import { enqueue } from "./write-queue.js";
7
+ export class ManageRedirectError extends Error {
8
+ reason;
9
+ constructor(reason, message, options) {
10
+ super(message, options);
11
+ this.name = 'ManageRedirectError';
12
+ this.reason = reason;
13
+ }
14
+ }
15
+ // Not required for correctness (resolve-url.ts already guarantees live
16
+ // content always wins over a redirect at the same URL), but rejecting
17
+ // here avoids a marketing manager creating a redirect that would
18
+ // silently never fire.
19
+ function liveContentExistsAt(config, url) {
20
+ const pageFile = sanitisePath(config.pagesRoot, urlToPagePath(url));
21
+ return existsSync(pageFile);
22
+ }
23
+ function validateFromAndTo(from, to) {
24
+ if (!isValidRedirectTarget(from)) {
25
+ throw new ManageRedirectError('invalid-from', `"from" must be an internal path starting with "/", got "${from}"`);
26
+ }
27
+ if (!isValidRedirectTarget(to)) {
28
+ throw new ManageRedirectError('invalid-target', `"to" must be an internal path starting with "/", got "${to}"`);
29
+ }
30
+ }
31
+ function readRedirectsSnapshot(config) {
32
+ const existed = existsSync(config.redirectsPath);
33
+ return { existed, original: existed ? readFileSync(config.redirectsPath, 'utf-8') : '' };
34
+ }
35
+ function undoRedirectsWrite(config, snapshot) {
36
+ const failures = [];
37
+ try {
38
+ if (snapshot.existed) {
39
+ writeFileSync(config.redirectsPath, snapshot.original);
40
+ }
41
+ else {
42
+ rmSync(config.redirectsPath, { force: true });
43
+ }
44
+ }
45
+ catch (error) {
46
+ failures.push(error);
47
+ }
48
+ return failures;
49
+ }
50
+ function loadEntriesOrThrow(config) {
51
+ try {
52
+ return loadRedirectsStrict(config).entries;
53
+ }
54
+ catch (error) {
55
+ if (error instanceof RedirectError) {
56
+ throw new ManageRedirectError('malformed-file', error.message, { cause: error });
57
+ }
58
+ throw error;
59
+ }
60
+ }
61
+ export function prepareCreateRedirect(config, from, to, note) {
62
+ validateFromAndTo(from, to);
63
+ const entries = loadEntriesOrThrow(config);
64
+ if (entries.some((entry) => entry.from === from)) {
65
+ throw new ManageRedirectError('already-exists', `A redirect already exists for "${from}"`);
66
+ }
67
+ if (liveContentExistsAt(config, from)) {
68
+ throw new ManageRedirectError('live-content-exists', `"${from}" already has live content; a redirect there would never take effect`);
69
+ }
70
+ const snapshot = readRedirectsSnapshot(config);
71
+ let addResult;
72
+ try {
73
+ addResult = addRedirect(entries, from, to, note);
74
+ }
75
+ catch (error) {
76
+ if (error instanceof RedirectError) {
77
+ throw new ManageRedirectError('redirect-cycle', error.message, { cause: error });
78
+ }
79
+ throw error;
80
+ }
81
+ writeFileSync(config.redirectsPath, serialiseRedirects(addResult.entries));
82
+ const createdEntry = addResult.entries.find((entry) => entry.from === from);
83
+ if (!createdEntry) {
84
+ throw new Error('addRedirect did not produce the expected entry - this is a bug');
85
+ }
86
+ return {
87
+ paths: [config.redirectsPath],
88
+ undo: () => undoRedirectsWrite(config, snapshot),
89
+ result: { entry: createdEntry, retargeted: addResult.retargeted },
90
+ };
91
+ }
92
+ export function prepareUpdateRedirect(config, from, to, note) {
93
+ validateFromAndTo(from, to);
94
+ const entries = loadEntriesOrThrow(config);
95
+ if (!entries.some((entry) => entry.from === from)) {
96
+ throw new ManageRedirectError('not-found', `No redirect exists for "${from}"`);
97
+ }
98
+ const snapshot = readRedirectsSnapshot(config);
99
+ let addResult;
100
+ try {
101
+ addResult = addRedirect(entries, from, to, note);
102
+ }
103
+ catch (error) {
104
+ if (error instanceof RedirectError) {
105
+ throw new ManageRedirectError('redirect-cycle', error.message, { cause: error });
106
+ }
107
+ throw error;
108
+ }
109
+ writeFileSync(config.redirectsPath, serialiseRedirects(addResult.entries));
110
+ const updatedEntry = addResult.entries.find((entry) => entry.from === from);
111
+ if (!updatedEntry) {
112
+ throw new Error('addRedirect did not produce the expected entry - this is a bug');
113
+ }
114
+ return {
115
+ paths: [config.redirectsPath],
116
+ undo: () => undoRedirectsWrite(config, snapshot),
117
+ result: { entry: updatedEntry, retargeted: addResult.retargeted },
118
+ };
119
+ }
120
+ export function prepareDeleteRedirect(config, from) {
121
+ const entries = loadEntriesOrThrow(config);
122
+ if (!entries.some((entry) => entry.from === from)) {
123
+ throw new ManageRedirectError('not-found', `No redirect exists for "${from}"`);
124
+ }
125
+ const snapshot = readRedirectsSnapshot(config);
126
+ const updated = removeRedirectForPath(entries, from);
127
+ writeFileSync(config.redirectsPath, serialiseRedirects(updated));
128
+ return {
129
+ paths: [config.redirectsPath],
130
+ undo: () => undoRedirectsWrite(config, snapshot),
131
+ };
132
+ }
133
+ async function runRedirectJob(config, prepare, message, author) {
134
+ const prepared = prepare();
135
+ try {
136
+ commitPaths(config.siteRoot, prepared.paths, message, author);
137
+ }
138
+ catch (error) {
139
+ const failures = prepared.undo();
140
+ if (failures.length > 0) {
141
+ throw new ManageRedirectError('rollback-failed', 'Redirect write failed and rolling back afterwards also failed; the working tree may be inconsistent and needs manual inspection', { cause: error });
142
+ }
143
+ const detail = error instanceof Error ? error.message : String(error);
144
+ throw new ManageRedirectError('commit-failed', `Redirect write failed: ${detail}`, { cause: error });
145
+ }
146
+ return prepared.result;
147
+ }
148
+ export function createRedirect(config, from, to, note, message, author) {
149
+ return enqueue(() => runRedirectJob(config, () => prepareCreateRedirect(config, from, to, note), message, author));
150
+ }
151
+ export function updateRedirect(config, from, to, note, message, author) {
152
+ return enqueue(() => runRedirectJob(config, () => prepareUpdateRedirect(config, from, to, note), message, author));
153
+ }
154
+ export function deleteRedirect(config, from, message, author) {
155
+ return enqueue(() => runRedirectJob(config, () => prepareDeleteRedirect(config, from), message, author));
156
+ }
@@ -0,0 +1,3 @@
1
+ import type { SiteConfig } from '../config.ts';
2
+ export declare function isValidMenuHandle(handle: string): boolean;
3
+ export declare function findMenuReferences(config: SiteConfig, handle: string): string[];
@@ -0,0 +1,38 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { listFilesRecursively } from "./fs-walk.js";
4
+ // A menu's handle is its filename without .json - what a layout writes
5
+ // as menus.<handle>.items. Menus are flat (no subfolders), and these
6
+ // are the only characters a Liquid dot-lookup reads as one name.
7
+ const MENU_HANDLE = /^[A-Za-z0-9_-]+$/;
8
+ export function isValidMenuHandle(handle) {
9
+ return MENU_HANDLE.test(handle);
10
+ }
11
+ function escapeRegExp(value) {
12
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
13
+ }
14
+ // Which theme files mention a menu by handle, as site-relative paths
15
+ // (e.g. "theme/layouts/theme.liquid"). Read-only and advisory: it lets
16
+ // the admin warn before a rename or delete leaves a nav empty. Matches
17
+ // both menus.<handle> and menus['<handle>'] / menus["<handle>"], and
18
+ // the dot form only as a whole name, so "main" never matches
19
+ // menus.mainFooter. A handle built at runtime ({% assign m = ... %})
20
+ // can't be seen here, which is why this only ever informs a warning,
21
+ // never blocks anything.
22
+ export function findMenuReferences(config, handle) {
23
+ if (!isValidMenuHandle(handle)) {
24
+ return [];
25
+ }
26
+ const escaped = escapeRegExp(handle);
27
+ const pattern = new RegExp(`menus(?:\\.${escaped}(?![A-Za-z0-9_-])|\\[\\s*(['"])${escaped}\\1\\s*\\])`);
28
+ return listFilesRecursively(config.themeRoot, config.siteRoot, '.liquid')
29
+ .filter((relativePath) => {
30
+ try {
31
+ return pattern.test(readFileSync(join(config.siteRoot, relativePath), 'utf-8'));
32
+ }
33
+ catch {
34
+ return false;
35
+ }
36
+ })
37
+ .sort();
38
+ }
@@ -0,0 +1,9 @@
1
+ import type { SiteConfig } from '../config.ts';
2
+ export interface MenuContent {
3
+ name?: string;
4
+ items: Array<{
5
+ label: string;
6
+ url: string;
7
+ }>;
8
+ }
9
+ export declare function loadMenus(config: SiteConfig): Record<string, MenuContent>;
@@ -0,0 +1,43 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { listFilesRecursively } from "./fs-walk.js";
4
+ function tryReadMenu(fullPath) {
5
+ try {
6
+ const parsed = JSON.parse(readFileSync(fullPath, 'utf-8'));
7
+ if (!Array.isArray(parsed.items)) {
8
+ return null;
9
+ }
10
+ const items = parsed.items;
11
+ return typeof parsed.name === 'string' ? { name: parsed.name, items } : { items };
12
+ }
13
+ catch {
14
+ // One malformed menu file is skipped individually, not fatal to
15
+ // the whole render - a broken menu shouldn't take down every page
16
+ // on the site (matches rebuild-index.ts's best-effort-skip
17
+ // precedent for pages).
18
+ return null;
19
+ }
20
+ }
21
+ function loadMenusFrom(root) {
22
+ const menus = {};
23
+ // listFilesRecursively already returns [] for a missing directory -
24
+ // a site that has never used menus has no content/menus/ at all, and
25
+ // that resolves to {} gracefully, no existsSync guard needed here.
26
+ for (const relativePath of listFilesRecursively(root, root, '.json')) {
27
+ const name = relativePath.replace(/\.json$/, '');
28
+ const menu = tryReadMenu(join(root, relativePath));
29
+ if (menu) {
30
+ menus[name] = menu;
31
+ }
32
+ }
33
+ return menus;
34
+ }
35
+ // Menus are content (git-tracked), but unlike pages/posts they have no
36
+ // draft/publish state at all (Group N) - direct-write with an
37
+ // immediate commit, same as redirects.json. Always the live file,
38
+ // identically in public and preview mode - there is no draft to
39
+ // overlay. Loaded fresh on every render call, not once at boot like
40
+ // theme templates/layouts/snippets.
41
+ export function loadMenus(config) {
42
+ return loadMenusFrom(config.menusRoot);
43
+ }
@@ -0,0 +1,16 @@
1
+ import type { SiteConfig } from '../config.ts';
2
+ import type { CommitAuthor } from './git.ts';
3
+ import type { ThemeSchemas } from './validation.ts';
4
+ export type MigrationFunction = (content: Record<string, unknown>) => Record<string, unknown>;
5
+ export type MigrationMap = Record<number, MigrationFunction>;
6
+ export type MigrationReason = 'migration-failed' | 'validation-failed' | 'write-failed' | 'commit-failed' | 'rollback-failed';
7
+ export declare class MigrationError extends Error {
8
+ readonly reason: MigrationReason;
9
+ constructor(reason: MigrationReason, message: string, options?: {
10
+ cause?: unknown;
11
+ });
12
+ }
13
+ export interface RunMigrationsOptions {
14
+ commit?: boolean;
15
+ }
16
+ export declare function runMigrations(config: SiteConfig, themeSchemas: ThemeSchemas, migrations: MigrationMap, currentVersion: number, author: CommitAuthor, options?: RunMigrationsOptions): Promise<string[]>;