canopycms 0.0.67 → 0.0.68-int.101

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 (281) hide show
  1. package/README.md +24 -17
  2. package/dist/ai/handler.js +8 -0
  3. package/dist/api/admin-branch-health.js +17 -20
  4. package/dist/api/admin.d.ts +30 -9
  5. package/dist/api/admin.js +32 -2
  6. package/dist/api/assets.js +84 -76
  7. package/dist/api/branch-create-window.d.ts +12 -0
  8. package/dist/api/branch-create-window.js +13 -0
  9. package/dist/api/branch-review.d.ts +3 -3
  10. package/dist/api/branch-review.js +14 -5
  11. package/dist/api/branch-status.d.ts +5 -5
  12. package/dist/api/branch-status.js +26 -6
  13. package/dist/api/branch-withdraw.d.ts +2 -2
  14. package/dist/api/branch-withdraw.js +7 -2
  15. package/dist/api/branch.d.ts +15 -2
  16. package/dist/api/branch.js +118 -92
  17. package/dist/api/client.d.ts +18 -6
  18. package/dist/api/client.js +79 -12
  19. package/dist/api/content.d.ts +6 -5
  20. package/dist/api/content.js +44 -41
  21. package/dist/api/entries.js +16 -15
  22. package/dist/api/github-sync.d.ts +19 -1
  23. package/dist/api/github-sync.js +57 -10
  24. package/dist/api/index.d.ts +1 -1
  25. package/dist/api/reference-options.js +1 -1
  26. package/dist/api/resolve-references.js +14 -40
  27. package/dist/api/settings-helpers.d.ts +1 -1
  28. package/dist/api/settings-helpers.js +1 -4
  29. package/dist/api/user.d.ts +3 -0
  30. package/dist/api/user.js +3 -0
  31. package/dist/assets/asset-url.d.ts +22 -16
  32. package/dist/assets/asset-url.js +42 -25
  33. package/dist/assets/factory.d.ts +5 -0
  34. package/dist/assets/factory.js +5 -0
  35. package/dist/assets/finalize.js +3 -0
  36. package/dist/assets/index.d.ts +1 -1
  37. package/dist/assets/materialize.d.ts +153 -0
  38. package/dist/assets/materialize.js +402 -0
  39. package/dist/assets/store-local.d.ts +19 -5
  40. package/dist/assets/store-local.js +69 -10
  41. package/dist/assets/store-s3.d.ts +38 -7
  42. package/dist/assets/store-s3.js +128 -22
  43. package/dist/assets/transform-directives.d.ts +52 -13
  44. package/dist/assets/transform-directives.js +87 -27
  45. package/dist/assets/transform.d.ts +3 -3
  46. package/dist/assets/transform.js +13 -8
  47. package/dist/assets/types.d.ts +31 -4
  48. package/dist/auth/file-based-auth-cache.js +7 -8
  49. package/dist/authorization/content.d.ts +5 -4
  50. package/dist/authorization/content.js +5 -5
  51. package/dist/authorization/path.d.ts +11 -6
  52. package/dist/authorization/path.js +11 -6
  53. package/dist/authorization/types.d.ts +2 -2
  54. package/dist/branch-health.d.ts +7 -1
  55. package/dist/branch-health.js +8 -3
  56. package/dist/branch-metadata.d.ts +5 -5
  57. package/dist/branch-metadata.js +44 -36
  58. package/dist/branch-provisioning.d.ts +167 -0
  59. package/dist/branch-provisioning.js +501 -0
  60. package/dist/branch-schema-cache.d.ts +5 -3
  61. package/dist/branch-schema-cache.js +29 -11
  62. package/dist/branch-sparse.d.ts +23 -0
  63. package/dist/branch-sparse.js +118 -0
  64. package/dist/branch-workspace.d.ts +42 -3
  65. package/dist/branch-workspace.js +240 -94
  66. package/dist/build/asset-refs.d.ts +93 -0
  67. package/dist/build/asset-refs.js +251 -0
  68. package/dist/build-identity.d.ts +3 -0
  69. package/dist/build-identity.js +13 -0
  70. package/dist/cli/asset-refs.d.ts +52 -0
  71. package/dist/cli/asset-refs.js +194 -0
  72. package/dist/cli/cli.d.ts +15 -1
  73. package/dist/cli/cli.js +5928 -2195
  74. package/dist/cli/configured-asset-store.d.ts +12 -0
  75. package/dist/cli/configured-asset-store.js +46 -0
  76. package/dist/cli/generate-ai-content.js +2185 -587
  77. package/dist/cli/github-app-manifest.d.ts +4 -3
  78. package/dist/cli/github-app-manifest.js +4 -3
  79. package/dist/cli/init.js +30 -27
  80. package/dist/cli/migrate.js +12 -8
  81. package/dist/cli/sync.js +83 -37
  82. package/dist/cli/template-files/Dockerfile.cms.template +8 -0
  83. package/dist/cli/template-files/cdk-app.ts.template +4 -0
  84. package/dist/cli/template-files/cms-stack.ts.template +23 -12
  85. package/dist/cli/template-files/deploy-cms.yml.template +2 -0
  86. package/dist/client.d.ts +1 -1
  87. package/dist/client.js +1 -1
  88. package/dist/config/helpers.js +1 -2
  89. package/dist/config/schemas/config.d.ts +19 -32
  90. package/dist/config/schemas/config.js +9 -11
  91. package/dist/config/schemas/field.js +1 -0
  92. package/dist/config/schemas/media.d.ts +4 -13
  93. package/dist/config/schemas/media.js +5 -8
  94. package/dist/config/schemas/url.d.ts +4 -20
  95. package/dist/config/schemas/url.js +6 -22
  96. package/dist/config/types.d.ts +30 -20
  97. package/dist/config/validation.d.ts +6 -0
  98. package/dist/config/validation.js +37 -0
  99. package/dist/content-listing.d.ts +12 -12
  100. package/dist/content-listing.js +10 -6
  101. package/dist/content-reader.d.ts +11 -2
  102. package/dist/content-reader.js +22 -14
  103. package/dist/content-store.d.ts +37 -9
  104. package/dist/content-store.js +70 -43
  105. package/dist/content-tree.d.ts +1 -1
  106. package/dist/content-tree.js +2 -2
  107. package/dist/context.d.ts +27 -9
  108. package/dist/context.js +154 -70
  109. package/dist/editor/BranchManager.d.ts +7 -1
  110. package/dist/editor/BranchManager.js +29 -14
  111. package/dist/editor/CanopyEditor.d.ts +1 -1
  112. package/dist/editor/CanopyEditor.js +2 -6
  113. package/dist/editor/Editor.d.ts +4 -3
  114. package/dist/editor/Editor.js +32 -22
  115. package/dist/editor/FormRenderer.js +34 -18
  116. package/dist/editor/PreviewFrame.d.ts +24 -0
  117. package/dist/editor/PreviewFrame.js +140 -0
  118. package/dist/editor/admin/SystemHealthPanel.js +47 -7
  119. package/dist/editor/components/BranchesDrawer.d.ts +9 -0
  120. package/dist/editor/components/BranchesDrawer.js +4 -0
  121. package/dist/editor/components/NoEditPermissionNotice.d.ts +10 -0
  122. package/dist/editor/components/NoEditPermissionNotice.js +17 -0
  123. package/dist/editor/context/AssetContext.d.ts +18 -35
  124. package/dist/editor/context/AssetContext.js +20 -29
  125. package/dist/editor/context/index.d.ts +1 -1
  126. package/dist/editor/context/index.js +1 -1
  127. package/dist/editor/editor-config.d.ts +1 -2
  128. package/dist/editor/editor-config.js +0 -23
  129. package/dist/editor/editor-utils.d.ts +24 -21
  130. package/dist/editor/editor-utils.js +75 -62
  131. package/dist/editor/fields/BlockField.d.ts +1 -0
  132. package/dist/editor/fields/BlockField.js +6 -4
  133. package/dist/editor/fields/CodeField.d.ts +1 -0
  134. package/dist/editor/fields/CodeField.js +2 -2
  135. package/dist/editor/fields/DateTimeField.d.ts +1 -0
  136. package/dist/editor/fields/DateTimeField.js +2 -2
  137. package/dist/editor/fields/FieldDescription.d.ts +16 -0
  138. package/dist/editor/fields/FieldDescription.js +11 -0
  139. package/dist/editor/fields/ImageField.d.ts +1 -0
  140. package/dist/editor/fields/ImageField.js +3 -2
  141. package/dist/editor/fields/InlineGroupField.js +4 -1
  142. package/dist/editor/fields/MarkdownField.d.ts +1 -0
  143. package/dist/editor/fields/MarkdownField.js +115 -20
  144. package/dist/editor/fields/NumberField.d.ts +1 -0
  145. package/dist/editor/fields/NumberField.js +2 -2
  146. package/dist/editor/fields/NumberListField.d.ts +1 -0
  147. package/dist/editor/fields/NumberListField.js +2 -2
  148. package/dist/editor/fields/ObjectField.d.ts +1 -0
  149. package/dist/editor/fields/ObjectField.js +5 -2
  150. package/dist/editor/fields/ReferenceField.d.ts +1 -0
  151. package/dist/editor/fields/ReferenceField.js +5 -4
  152. package/dist/editor/fields/SelectField.d.ts +1 -0
  153. package/dist/editor/fields/SelectField.js +2 -2
  154. package/dist/editor/fields/StringListField.d.ts +1 -0
  155. package/dist/editor/fields/StringListField.js +2 -2
  156. package/dist/editor/fields/TextField.d.ts +1 -0
  157. package/dist/editor/fields/TextField.js +2 -2
  158. package/dist/editor/fields/ToggleField.d.ts +1 -0
  159. package/dist/editor/fields/ToggleField.js +7 -4
  160. package/dist/editor/fields/entry-link/InsertEntryLink.d.ts +1 -1
  161. package/dist/editor/fields/entry-link/InsertEntryLink.js +3 -2
  162. package/dist/editor/fields/mdx-jsx-support.d.ts +1 -0
  163. package/dist/editor/fields/mdx-jsx-support.js +137 -0
  164. package/dist/editor/hooks/create-branch-request.d.ts +20 -0
  165. package/dist/editor/hooks/create-branch-request.js +52 -0
  166. package/dist/editor/hooks/useBranchActions.d.ts +13 -2
  167. package/dist/editor/hooks/useBranchActions.js +48 -17
  168. package/dist/editor/hooks/useBranchManager.d.ts +21 -0
  169. package/dist/editor/hooks/useBranchManager.js +204 -37
  170. package/dist/editor/hooks/useBranchesData.d.ts +6 -0
  171. package/dist/editor/hooks/useBranchesData.js +9 -3
  172. package/dist/editor/hooks/useCommentSystem.js +25 -6
  173. package/dist/editor/hooks/useDraftManager.d.ts +16 -1
  174. package/dist/editor/hooks/useDraftManager.js +193 -36
  175. package/dist/editor/hooks/useEntriesData.js +3 -3
  176. package/dist/editor/hooks/useEntryManager.d.ts +2 -1
  177. package/dist/editor/hooks/useEntryManager.js +34 -19
  178. package/dist/editor/media/AssetCard.d.ts +1 -1
  179. package/dist/editor/media/crop-math.d.ts +3 -4
  180. package/dist/editor/media/crop-math.js +11 -22
  181. package/dist/editor/media/editor-image-src.d.ts +7 -0
  182. package/dist/editor/media/editor-image-src.js +12 -0
  183. package/dist/editor/preview-asset-base.d.ts +8 -0
  184. package/dist/editor/preview-asset-base.js +13 -0
  185. package/dist/editor/preview-bridge.d.ts +9 -21
  186. package/dist/editor/preview-bridge.js +19 -139
  187. package/dist/editor/preview-path.d.ts +10 -0
  188. package/dist/editor/preview-path.js +20 -0
  189. package/dist/editor/theme.js +2 -2
  190. package/dist/entry-schema-registry.d.ts +4 -4
  191. package/dist/entry-schema-registry.js +10 -6
  192. package/dist/entry-schema.d.ts +27 -4
  193. package/dist/entry-schema.js +20 -3
  194. package/dist/git-manager.d.ts +173 -6
  195. package/dist/git-manager.js +594 -76
  196. package/dist/github-service.d.ts +15 -0
  197. package/dist/github-service.js +19 -1
  198. package/dist/http/handler.js +37 -13
  199. package/dist/http/index.d.ts +2 -0
  200. package/dist/http/index.js +2 -0
  201. package/dist/http/router.d.ts +2 -0
  202. package/dist/http/router.js +2 -1
  203. package/dist/http/types.d.ts +2 -0
  204. package/dist/http/worker-not-ready.d.ts +10 -0
  205. package/dist/http/worker-not-ready.js +24 -0
  206. package/dist/operating-mode/client-unsafe-strategy.js +0 -6
  207. package/dist/operating-mode/types.d.ts +1 -4
  208. package/dist/paths/branch-name.d.ts +5 -0
  209. package/dist/paths/branch-name.js +9 -0
  210. package/dist/paths/branch.d.ts +6 -2
  211. package/dist/paths/branch.js +16 -10
  212. package/dist/paths/index.d.ts +3 -3
  213. package/dist/paths/index.js +3 -3
  214. package/dist/paths/normalize.d.ts +7 -0
  215. package/dist/paths/normalize.js +9 -0
  216. package/dist/paths/validation.js +0 -2
  217. package/dist/preview.d.ts +8 -0
  218. package/dist/preview.js +7 -0
  219. package/dist/reference-resolver.d.ts +5 -21
  220. package/dist/reference-resolver.js +9 -44
  221. package/dist/resolve-canopy-user.js +2 -1
  222. package/dist/schema/schema-store.d.ts +54 -31
  223. package/dist/schema/schema-store.js +77 -31
  224. package/dist/server.d.ts +64 -9
  225. package/dist/server.js +48 -8
  226. package/dist/services.d.ts +18 -6
  227. package/dist/services.js +85 -70
  228. package/dist/settings-workspace.d.ts +23 -3
  229. package/dist/settings-workspace.js +197 -45
  230. package/dist/static/seo.d.ts +2 -14
  231. package/dist/static/seo.js +2 -25
  232. package/dist/submission-attribution.d.ts +73 -0
  233. package/dist/submission-attribution.js +221 -0
  234. package/dist/sync-core.d.ts +12 -1
  235. package/dist/sync-core.js +31 -13
  236. package/dist/task-queue/worker-status.d.ts +8 -0
  237. package/dist/task-queue/worker-status.js +17 -0
  238. package/dist/types.d.ts +31 -2
  239. package/dist/utils/content-serialize.d.ts +5 -2
  240. package/dist/utils/content-serialize.js +98 -24
  241. package/dist/utils/content-write-lock.d.ts +18 -7
  242. package/dist/utils/content-write-lock.js +18 -9
  243. package/dist/utils/debug.d.ts +8 -0
  244. package/dist/utils/debug.js +10 -2
  245. package/dist/utils/git.d.ts +32 -0
  246. package/dist/utils/git.js +42 -0
  247. package/dist/utils/occ-json-write.js +10 -2
  248. package/dist/utils/provision-log.d.ts +25 -0
  249. package/dist/utils/provision-log.js +49 -0
  250. package/dist/utils/provisioning-lock.d.ts +35 -5
  251. package/dist/utils/provisioning-lock.js +73 -12
  252. package/dist/utils/request-timing.d.ts +25 -0
  253. package/dist/utils/request-timing.js +101 -0
  254. package/dist/utils/sanitize-href.d.ts +8 -22
  255. package/dist/utils/sanitize-href.js +11 -28
  256. package/dist/utils/url-prefix.d.ts +30 -0
  257. package/dist/utils/url-prefix.js +61 -0
  258. package/dist/utils/yaml-source-splice.d.ts +58 -0
  259. package/dist/utils/yaml-source-splice.js +515 -0
  260. package/dist/validation/entry-validator.js +9 -3
  261. package/dist/version.d.ts +1 -0
  262. package/dist/version.js +3 -0
  263. package/dist/worker/canopy-state.d.ts +45 -0
  264. package/dist/worker/canopy-state.js +75 -0
  265. package/dist/worker/cms-worker.d.ts +8 -3
  266. package/dist/worker/cms-worker.js +38 -2
  267. package/dist/worker/git-sync.d.ts +30 -12
  268. package/dist/worker/git-sync.js +253 -58
  269. package/dist/worker/history-rewrite.d.ts +1 -1
  270. package/dist/worker/history-rewrite.js +1 -1
  271. package/dist/worker/provisioned-workspace.d.ts +35 -0
  272. package/dist/worker/provisioned-workspace.js +50 -0
  273. package/dist/worker/rebase.d.ts +1 -1
  274. package/dist/worker/rebase.js +95 -23
  275. package/dist/worker/remote-git-maintenance.d.ts +3 -0
  276. package/dist/worker/remote-git-maintenance.js +11 -0
  277. package/dist/worker/sparse-cone.d.ts +20 -0
  278. package/dist/worker/sparse-cone.js +132 -0
  279. package/dist/worker/task-runner.js +99 -14
  280. package/dist/worker/worker-context.d.ts +6 -1
  281. package/package.json +8 -2
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Collect the asset URLs a static build references, so a release can make them exist first.
3
+ *
4
+ * The public `/assets/t/` path serves only what is stored, and widths are chosen by site code at
5
+ * render time, so build output is the one place every final URL appears. The adopter contract
6
+ * that follows: every `/assets/t/` URL the site can request must appear as text in its build
7
+ * output (ARCHITECTURE.md, "Why transform by URL directive and materialize at release?").
8
+ */
9
+ import fs from 'node:fs/promises';
10
+ import path from 'node:path';
11
+ import { z } from 'zod';
12
+ import { ASSET_PREFIXES } from '../assets/asset-prefixes.js';
13
+ import { canonicalizeTransformPath } from '../assets/transform-directives.js';
14
+ import { atomicWriteFile } from '../utils/atomic-write.js';
15
+ /** @internal Written into the scanned directory, so a manifest the adopter builds afterwards covers it. */
16
+ export const ASSET_REFS_FILENAME = 'canopy-asset-refs.json';
17
+ /**
18
+ * Text outputs a URL can hide in: pages, RSC payloads, data, scripts, styles, feeds, generated
19
+ * markdown. A file with no extension (a route handler's output) is scanned unless it is binary.
20
+ */
21
+ const SCANNED_EXTENSIONS = new Set([
22
+ '',
23
+ '.html',
24
+ '.htm',
25
+ '.txt',
26
+ '.rsc',
27
+ '.json',
28
+ '.js',
29
+ '.mjs',
30
+ '.cjs',
31
+ '.css',
32
+ '.xml',
33
+ '.svg',
34
+ '.md',
35
+ '.webmanifest',
36
+ ]);
37
+ /**
38
+ * Anchored on the path, never on an origin or a mount prefix, so whatever precedes `/assets/` —
39
+ * an absolute origin, a basePath, the editor's authenticated prefix — is dropped. The directive
40
+ * and filename classes stop at anything that ends a URL in HTML, JSON, JS or CSS (quotes,
41
+ * whitespace, a backslash escape, parens, a query; the filename also at a srcset comma) or at any
42
+ * non-ASCII character (typographic quotes and dashes in prose; no stored key has one), and exclude
43
+ * `{`, `}` and `$`, so a URL template in a script never reads as a URL. A non-ASCII character
44
+ * inside a directive segment therefore ends the match and the URL is not seen. The hash class
45
+ * admits upper case only so that such a URL is reported rather than skipped.
46
+ */
47
+ const URL_STOP = String.raw `\s"'\x60<>()\\/?#&{}$\u0080-\uffff`;
48
+ // eslint-disable-next-line security/detect-non-literal-regexp -- built from constants above
49
+ const TRANSFORM_URL_RE = new RegExp(String.raw `/${ASSET_PREFIXES.transform}/([^${URL_STOP}]+)/([a-fA-F0-9]{32})/([^${URL_STOP},]+)`, 'g');
50
+ // eslint-disable-next-line security/detect-non-literal-regexp -- built from constants above
51
+ const STATIC_URL_RE = new RegExp(String.raw `/${ASSET_PREFIXES.public}/([a-fA-F0-9]{32})/([^${URL_STOP},]+)`, 'g');
52
+ const STATIC_FILENAME_RE = /^[a-z0-9-]+\.[a-z0-9]{1,10}$/;
53
+ const HASH32_RE = /^[a-f0-9]{32}$/;
54
+ const assetRefEntrySchema = z
55
+ .object({
56
+ key: z.string().min(1),
57
+ routes: z.array(z.string()),
58
+ files: z.array(z.string()),
59
+ })
60
+ .strict();
61
+ const assetRefsFileSchema = z
62
+ .object({
63
+ version: z.literal(1),
64
+ transforms: z.array(assetRefEntrySchema),
65
+ statics: z.array(assetRefEntrySchema),
66
+ })
67
+ .strict();
68
+ /** The output references URLs that cannot be served from the store; nothing was written. */
69
+ export class AssetRefsError extends Error {
70
+ constructor(problems) {
71
+ super(`${problems.length} asset URL(s) in the build output cannot be served from the store:\n` +
72
+ problems.map((p) => ` ${p.file}: ${p.url}\n ${p.error}`).join('\n'));
73
+ this.problems = problems;
74
+ this.name = 'AssetRefsError';
75
+ }
76
+ }
77
+ /** Output-relative POSIX paths of every file under `root` with a scanned extension, sorted. */
78
+ async function listScannedFiles(root) {
79
+ const entries = await fs.readdir(root, { recursive: true, withFileTypes: true });
80
+ return entries
81
+ .filter((entry) => entry.isFile() && SCANNED_EXTENSIONS.has(path.extname(entry.name)))
82
+ .map((entry) => path.relative(root, path.join(entry.parentPath, entry.name)))
83
+ .map((rel) => rel.split(path.sep).join('/'))
84
+ .filter((rel) => rel !== ASSET_REFS_FILENAME)
85
+ .sort();
86
+ }
87
+ /** The route a static-export HTML file serves (`about/index.html` and `about.html` are `/about`). */
88
+ function routeForFile(file) {
89
+ if (!/\.html?$/.test(file))
90
+ return undefined;
91
+ const withoutExt = file.replace(/\.html?$/, '');
92
+ const route = withoutExt === 'index' ? '' : withoutExt.replace(/\/index$/, '');
93
+ return `/${route}`;
94
+ }
95
+ const ENCODED_ASSETS = /%2[fF]assets%2[fF]/g;
96
+ const RUN_STOP = /[\s"'`<>&]/;
97
+ /** Each `%XX` escape of an ASCII character, decoded on its own; anything else stays as written. */
98
+ function decodeAsciiEscapes(text) {
99
+ return text.replace(/%([0-7][0-9a-fA-F])/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
100
+ }
101
+ /** One malformed or non-UTF-8 escape elsewhere in the run must not hide the URL inside it. */
102
+ function decodeOrKeep(run) {
103
+ try {
104
+ return decodeURIComponent(run);
105
+ }
106
+ catch {
107
+ return decodeAsciiEscapes(run);
108
+ }
109
+ }
110
+ /**
111
+ * Decode, whole, each percent-encoded run holding an encoded `/assets/` (an image optimizer's
112
+ * `?url=`), so an encoded `%20` or `%3F` after the URL ends it just as a literal one would. Runs are
113
+ * found by expanding outward from each match, so the scan stays linear on long text with no stop
114
+ * character.
115
+ */
116
+ function decodeEncodedAssetRuns(text) {
117
+ let out = '';
118
+ let last = 0;
119
+ for (const match of text.matchAll(ENCODED_ASSETS)) {
120
+ if (match.index < last)
121
+ continue;
122
+ let start = match.index;
123
+ while (start > last && !RUN_STOP.test(text[start - 1]))
124
+ start--;
125
+ let end = match.index + match[0].length;
126
+ while (end < text.length && !RUN_STOP.test(text[end]))
127
+ end++;
128
+ out += text.slice(last, start) + decodeOrKeep(text.slice(start, end));
129
+ last = end;
130
+ }
131
+ return out + text.slice(last);
132
+ }
133
+ /** JSON and JS may escape `/` as a backslash-slash or a `u002F` unicode escape. */
134
+ function decodeUrlEscapes(text) {
135
+ return decodeEncodedAssetRuns(text.replace(/\\u002[fF]/g, '/').replace(/\\\//g, '/'));
136
+ }
137
+ /**
138
+ * A slug and ext end in `[a-z0-9]`, so punctuation after a URL in prose is not part of it. Trimmed
139
+ * wherever it occurs: an RSC payload carries prose as JSON strings, where a sentence-final URL is
140
+ * followed by a quote exactly as an attribute value is.
141
+ */
142
+ function trimTrailingPunctuation(url, filename) {
143
+ const trimmed = filename.replace(/[.:;!?]+$/, '');
144
+ return [url.slice(0, url.length - (filename.length - trimmed.length)), trimmed];
145
+ }
146
+ /** A NUL in the first 8 KiB marks a file binary, close to git's own test; only that block is read. */
147
+ async function startsBinary(filePath) {
148
+ const handle = await fs.open(filePath, 'r');
149
+ try {
150
+ const block = Buffer.alloc(8192);
151
+ const { bytesRead } = await handle.read(block, 0, block.length, 0);
152
+ return block.subarray(0, bytesRead).includes(0);
153
+ }
154
+ finally {
155
+ await handle.close();
156
+ }
157
+ }
158
+ class RefCollector {
159
+ constructor() {
160
+ this.entries = new Map();
161
+ }
162
+ add(key, file) {
163
+ const entry = this.entries.get(key) ?? { routes: new Set(), files: new Set() };
164
+ entry.files.add(file);
165
+ const route = routeForFile(file);
166
+ if (route !== undefined)
167
+ entry.routes.add(route);
168
+ this.entries.set(key, entry);
169
+ }
170
+ toEntries() {
171
+ return [...this.entries.keys()].sort().map((key) => {
172
+ const entry = this.entries.get(key);
173
+ return {
174
+ key,
175
+ routes: [...(entry?.routes ?? [])].sort(),
176
+ files: [...(entry?.files ?? [])].sort(),
177
+ };
178
+ });
179
+ }
180
+ }
181
+ /**
182
+ * Scan `outDir` for `/assets/t/...` transform URLs and `/assets/{hash32}/...` static URLs and
183
+ * write their stored keys to `outDir/canopy-asset-refs.json`, sorted so the same output always
184
+ * yields the same file. Throws `AssetRefsError`, writing nothing, if any transform URL is not in
185
+ * canonical form or any static URL is malformed: only canonical keys are stored, and the public
186
+ * path is to compute nothing on a miss.
187
+ */
188
+ export async function collectAssetRefs(outDir) {
189
+ const root = path.resolve(outDir);
190
+ const files = await listScannedFiles(root);
191
+ const transforms = new RefCollector();
192
+ const statics = new RefCollector();
193
+ const problems = [];
194
+ let scannedFiles = 0;
195
+ for (const file of files) {
196
+ const filePath = path.join(root, file);
197
+ if (path.extname(file) === '' && (await startsBinary(filePath)))
198
+ continue;
199
+ scannedFiles++;
200
+ const text = decodeUrlEscapes(await fs.readFile(filePath, 'utf-8'));
201
+ for (const match of text.matchAll(TRANSFORM_URL_RE)) {
202
+ const [url, filename] = trimTrailingPunctuation(match[0], match[3]);
203
+ const [, encodedDirectives, hash32] = match;
204
+ // A loader that encodes the directive segment alone is still requesting this key.
205
+ const directives = decodeAsciiEscapes(encodedDirectives);
206
+ const canonical = canonicalizeTransformPath([directives, hash32, filename], 'any');
207
+ if (!canonical.ok) {
208
+ problems.push({ file, url, error: canonical.error });
209
+ }
210
+ else if (!canonical.isCanonical) {
211
+ problems.push({
212
+ file,
213
+ url,
214
+ error: `Not canonical; assetUrl would write /${ASSET_PREFIXES.transform}/${canonical.canonicalPath}`,
215
+ });
216
+ }
217
+ else {
218
+ transforms.add(`${ASSET_PREFIXES.transform}/${canonical.canonicalPath}`, file);
219
+ }
220
+ }
221
+ for (const match of text.matchAll(STATIC_URL_RE)) {
222
+ const [url, filename] = trimTrailingPunctuation(match[0], match[2]);
223
+ const hash32 = match[1];
224
+ if (!HASH32_RE.test(hash32) || !STATIC_FILENAME_RE.test(filename)) {
225
+ problems.push({ file, url, error: 'Not a stored static asset path' });
226
+ }
227
+ else {
228
+ statics.add(`${ASSET_PREFIXES.public}/${hash32}/${filename}`, file);
229
+ }
230
+ }
231
+ }
232
+ if (problems.length > 0)
233
+ throw new AssetRefsError(problems);
234
+ const refs = {
235
+ version: 1,
236
+ transforms: transforms.toEntries(),
237
+ statics: statics.toEntries(),
238
+ };
239
+ const filePath = path.join(root, ASSET_REFS_FILENAME);
240
+ await atomicWriteFile(filePath, `${JSON.stringify(refs, null, 2)}\n`);
241
+ return { refs, filePath, scannedFiles };
242
+ }
243
+ /** Read and validate a refs file `collectAssetRefs` wrote. */
244
+ export async function readAssetRefsFile(filePath) {
245
+ const raw = JSON.parse(await fs.readFile(filePath, 'utf-8'));
246
+ const parsed = assetRefsFileSchema.safeParse(raw);
247
+ if (!parsed.success) {
248
+ throw new Error(`${filePath} is not a valid asset refs file: ${parsed.error.message}`);
249
+ }
250
+ return parsed.data;
251
+ }
@@ -0,0 +1,3 @@
1
+ import type { BuildIdentity } from './types.js';
2
+ export type { BuildIdentity };
3
+ export declare function getBuildIdentity(env?: NodeJS.ProcessEnv): BuildIdentity;
@@ -0,0 +1,13 @@
1
+ import { CANOPYCMS_VERSION } from './version.js';
2
+ /**
3
+ * Set by the image build (Dockerfile.cms `ARG`), not by the infrastructure: a Lambda env var
4
+ * would name the infrastructure's commit and hide an image/infrastructure skew.
5
+ */
6
+ const SOURCE_REVISION_ENV = 'CANOPY_SOURCE_SHA';
7
+ export function getBuildIdentity(env = process.env) {
8
+ // The Dockerfile sets the variable to the empty string when the build arg is not passed.
9
+ const sourceRevision = env[SOURCE_REVISION_ENV]?.trim();
10
+ return sourceRevision
11
+ ? { canopycmsVersion: CANOPYCMS_VERSION, sourceRevision }
12
+ : { canopycmsVersion: CANOPYCMS_VERSION };
13
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * CLI commands: `canopycms collect-asset-refs <outDir>` and
3
+ * `canopycms materialize-assets --refs <file>`, the two release steps that make every image URL a
4
+ * build references exist in the store before that build is served. The logic lives in
5
+ * `build/asset-refs.ts` and `assets/materialize.ts`; this file picks the store and reports.
6
+ */
7
+ import type { AssetStore } from '../assets/types.js';
8
+ /** @returns the process exit code. */
9
+ export declare function collectAssetRefsCLI(options: {
10
+ outDir: string | undefined;
11
+ }): Promise<number>;
12
+ /**
13
+ * @internal Exported for tests. `materialize-assets` exit codes: a release gates on them, so `1`
14
+ * means the run did not finish, `2` that it finished and some keys cannot be produced, and `3`
15
+ * that the store refused or kept failing, so nothing is known about the keys it could not check.
16
+ */
17
+ export declare const MATERIALIZE_EXIT_CODES: {
18
+ readonly ok: 0;
19
+ /** Bad flags, an unreadable refs file, a config or sharp failure, any uncaught throw. */
20
+ readonly error: 1;
21
+ /** Content failures only; `--allow-failures` turns this into `ok`. */
22
+ readonly contentFailures: 2;
23
+ /** Any store failure, whatever `--allow-failures` says. */
24
+ readonly storeFailures: 3;
25
+ };
26
+ export interface MaterializeAssetsCLIOptions {
27
+ /** The project root; read only when the store comes from `canopycms.config.ts`. */
28
+ projectDir?: string;
29
+ refsPath: string | undefined;
30
+ reportPath?: string;
31
+ allowFailures: boolean;
32
+ concurrency?: string;
33
+ transformConcurrency?: string;
34
+ /** With `region`, builds the S3 store directly and never loads the site config. */
35
+ bucket?: string;
36
+ region?: string;
37
+ /** Accept a non-S3 store resolved from the config. */
38
+ allowLocal?: boolean;
39
+ /** `MaterializeOptions.outputPrefix`; an invalid one exits 1 before anything is read. */
40
+ outputPrefix?: string;
41
+ /** @internal Test seam: skips resolving the store. */
42
+ store?: AssetStore;
43
+ }
44
+ /**
45
+ * `--allow-failures` tolerates content failures only — a reference to an asset the store can no
46
+ * longer produce — so one deleted image does not block a release. A store failure (access
47
+ * denied, an outage that outlasted the retries) still fails the run, since it says nothing about
48
+ * which images are actually fine.
49
+ *
50
+ * @returns a `MATERIALIZE_EXIT_CODES` value; a throw is caught and reported as `error`.
51
+ */
52
+ export declare function materializeAssetsCLI(options: MaterializeAssetsCLIOptions): Promise<number>;
@@ -0,0 +1,194 @@
1
+ /**
2
+ * CLI commands: `canopycms collect-asset-refs <outDir>` and
3
+ * `canopycms materialize-assets --refs <file>`, the two release steps that make every image URL a
4
+ * build references exist in the store before that build is served. The logic lives in
5
+ * `build/asset-refs.ts` and `assets/materialize.ts`; this file picks the store and reports.
6
+ */
7
+ import fs from 'node:fs/promises';
8
+ import path from 'node:path';
9
+ import { assertValidOutputPrefix, materializeAssets, } from '../assets/materialize.js';
10
+ import { S3AssetStore } from '../assets/store-s3.js';
11
+ import { AssetRefsError, collectAssetRefs, readAssetRefsFile } from '../build/asset-refs.js';
12
+ import { atomicWriteFile } from '../utils/atomic-write.js';
13
+ import { getErrorMessage, isNotFoundError } from '../utils/error.js';
14
+ /** @returns the process exit code. */
15
+ export async function collectAssetRefsCLI(options) {
16
+ if (!options.outDir) {
17
+ console.error('Usage: canopycms collect-asset-refs <outDir>');
18
+ return 1;
19
+ }
20
+ const outDir = path.resolve(options.outDir);
21
+ try {
22
+ if (!(await fs.stat(outDir)).isDirectory()) {
23
+ console.error(`Not a directory: ${outDir}`);
24
+ return 1;
25
+ }
26
+ }
27
+ catch (err) {
28
+ if (!isNotFoundError(err))
29
+ throw err;
30
+ console.error(`No build output at ${outDir}. Run this after the static build.`);
31
+ return 1;
32
+ }
33
+ try {
34
+ const { refs, filePath, scannedFiles } = await collectAssetRefs(outDir);
35
+ console.log(`canopycms collect-asset-refs: ${refs.transforms.length} transform and ` +
36
+ `${refs.statics.length} static asset reference(s) in ${scannedFiles} file(s), ` +
37
+ `written to ${filePath}`);
38
+ return 0;
39
+ }
40
+ catch (err) {
41
+ if (!(err instanceof AssetRefsError))
42
+ throw err;
43
+ console.error(`canopycms collect-asset-refs: ${err.message}`);
44
+ console.error('Build these URLs with assetUrl/assetSrcSet, which always write the canonical form; ' +
45
+ 'a hand-written or edited URL has no stored object behind it.');
46
+ return 1;
47
+ }
48
+ }
49
+ /**
50
+ * @internal Exported for tests. `materialize-assets` exit codes: a release gates on them, so `1`
51
+ * means the run did not finish, `2` that it finished and some keys cannot be produced, and `3`
52
+ * that the store refused or kept failing, so nothing is known about the keys it could not check.
53
+ */
54
+ export const MATERIALIZE_EXIT_CODES = {
55
+ ok: 0,
56
+ /** Bad flags, an unreadable refs file, a config or sharp failure, any uncaught throw. */
57
+ error: 1,
58
+ /** Content failures only; `--allow-failures` turns this into `ok`. */
59
+ contentFailures: 2,
60
+ /** Any store failure, whatever `--allow-failures` says. */
61
+ storeFailures: 3,
62
+ };
63
+ function describeReferences(result) {
64
+ if (result.routes.length > 0)
65
+ return `pages: ${result.routes.join(', ')}`;
66
+ return `files: ${result.files.join(', ')}`;
67
+ }
68
+ function printReport(report) {
69
+ const { summary } = report;
70
+ const under = report.outputPrefix === undefined ? '' : ` under ${report.outputPrefix}`;
71
+ console.log(`canopycms materialize-assets: ${summary.total} key(s)${under}: ${summary.existed} existed, ` +
72
+ `${summary.created} created, ${summary.copied} copied, ${summary.failed} failed`);
73
+ for (const result of report.results) {
74
+ if (result.status !== 'failed')
75
+ continue;
76
+ console.error(` FAILED (${result.failure}) /${result.key}`);
77
+ console.error(` ${result.error}`);
78
+ console.error(` ${describeReferences(result)}`);
79
+ }
80
+ }
81
+ /** `undefined` when the flag is absent; `null`, after printing why, when it is not a positive integer. */
82
+ function parsePositiveInteger(flag, value) {
83
+ if (value === undefined)
84
+ return undefined;
85
+ const parsed = Number(value);
86
+ if (Number.isInteger(parsed) && parsed >= 1)
87
+ return parsed;
88
+ console.error(`${flag} must be a positive integer, got "${value}"`);
89
+ return null;
90
+ }
91
+ /** The store to materialize into, or `null`, after printing why, when the run cannot start. */
92
+ async function resolveStore(options) {
93
+ const { bucket, region } = options;
94
+ if ((bucket === undefined) !== (region === undefined)) {
95
+ return refuse('--bucket and --region go together');
96
+ }
97
+ if (bucket === '' || region === '')
98
+ return refuse('--bucket and --region need a value');
99
+ if (bucket !== undefined && options.allowLocal) {
100
+ return refuse('--allow-local does not apply with --bucket');
101
+ }
102
+ if (options.store)
103
+ return options.store;
104
+ if (bucket !== undefined && region !== undefined)
105
+ return new S3AssetStore({ bucket, region });
106
+ if (!options.projectDir) {
107
+ return refuse('no project root to read canopycms.config.ts from; pass --bucket/--region');
108
+ }
109
+ const { loadConfiguredAssetStore } = await import('./configured-asset-store.js');
110
+ const store = await loadConfiguredAssetStore(options.projectDir);
111
+ if (!(store instanceof S3AssetStore) && !options.allowLocal) {
112
+ return refuse('canopycms.config.ts resolved to a local asset store, which a release job would fill ' +
113
+ "instead of the bucket. Likely causes: `media` is unset or `adapter: 'local'`, or " +
114
+ "`mode: 'dev'` where the release expected CANOPY_MODE=prod and an s3 `media`. " +
115
+ 'Fix the config, pass --bucket and --region, or pass --allow-local to use it.');
116
+ }
117
+ return store;
118
+ }
119
+ function refuse(message) {
120
+ console.error(`canopycms materialize-assets: ${message}`);
121
+ return null;
122
+ }
123
+ /**
124
+ * `--allow-failures` tolerates content failures only — a reference to an asset the store can no
125
+ * longer produce — so one deleted image does not block a release. A store failure (access
126
+ * denied, an outage that outlasted the retries) still fails the run, since it says nothing about
127
+ * which images are actually fine.
128
+ *
129
+ * @returns a `MATERIALIZE_EXIT_CODES` value; a throw is caught and reported as `error`.
130
+ */
131
+ export async function materializeAssetsCLI(options) {
132
+ try {
133
+ return await runMaterialize(options);
134
+ }
135
+ catch (err) {
136
+ console.error(`canopycms materialize-assets: ${getErrorMessage(err)}`);
137
+ return MATERIALIZE_EXIT_CODES.error;
138
+ }
139
+ }
140
+ async function runMaterialize(options) {
141
+ if (!options.refsPath) {
142
+ console.error('Usage: canopycms materialize-assets --refs <file> [--report <file>] [--bucket <name> --region <region>]');
143
+ return MATERIALIZE_EXIT_CODES.error;
144
+ }
145
+ if (options.reportPath === '') {
146
+ console.error('canopycms materialize-assets: --report needs a file');
147
+ return MATERIALIZE_EXIT_CODES.error;
148
+ }
149
+ if (options.reportPath && path.resolve(options.reportPath) === path.resolve(options.refsPath)) {
150
+ console.error('canopycms materialize-assets: --report must not be the --refs file');
151
+ return MATERIALIZE_EXIT_CODES.error;
152
+ }
153
+ // A gate reading the report must never find an earlier run's after this run exits early.
154
+ if (options.reportPath)
155
+ await fs.rm(path.resolve(options.reportPath), { force: true });
156
+ const concurrency = parsePositiveInteger('--concurrency', options.concurrency);
157
+ const transformConcurrency = parsePositiveInteger('--transform-concurrency', options.transformConcurrency);
158
+ if (concurrency === null || transformConcurrency === null)
159
+ return MATERIALIZE_EXIT_CODES.error;
160
+ // An `InvalidOutputPrefixError` reaches `materializeAssetsCLI`'s catch: exit 1.
161
+ if (options.outputPrefix !== undefined)
162
+ assertValidOutputPrefix(options.outputPrefix);
163
+ const refs = await readAssetRefsFile(path.resolve(options.refsPath));
164
+ const store = await resolveStore(options);
165
+ if (!store)
166
+ return MATERIALIZE_EXIT_CODES.error;
167
+ // A `SharpUnavailableError` reaches `materializeAssetsCLI`'s catch: exit 1.
168
+ const report = await materializeAssets({
169
+ store,
170
+ targets: refs.transforms,
171
+ statics: refs.statics,
172
+ concurrency,
173
+ transformConcurrency,
174
+ outputPrefix: options.outputPrefix,
175
+ });
176
+ printReport(report);
177
+ if (options.reportPath) {
178
+ await atomicWriteFile(path.resolve(options.reportPath), `${JSON.stringify(report, null, 2)}\n`);
179
+ }
180
+ const { contentFailures, storeFailures } = report.summary;
181
+ if (storeFailures > 0) {
182
+ if (options.allowFailures) {
183
+ console.error('--allow-failures does not cover store failures; fix access or rerun.');
184
+ }
185
+ return MATERIALIZE_EXIT_CODES.storeFailures;
186
+ }
187
+ if (contentFailures === 0)
188
+ return MATERIALIZE_EXIT_CODES.ok;
189
+ if (!options.allowFailures)
190
+ return MATERIALIZE_EXIT_CODES.contentFailures;
191
+ console.warn(`\nWARNING: --allow-failures is set. ${contentFailures} image URL(s) above will be MISSING ` +
192
+ `from the released site. Fix the pages listed and rerun.\n`);
193
+ return MATERIALIZE_EXIT_CODES.ok;
194
+ }
package/dist/cli/cli.d.ts CHANGED
@@ -3,7 +3,8 @@
3
3
  * CanopyCMS CLI entrypoint.
4
4
  *
5
5
  * Routes commands to their implementations:
6
- * init, init-deploy, init-github-app, worker, generate-ai-content, sync, migrate
6
+ * init, init-deploy, init-github-app, worker, generate-ai-content, collect-asset-refs,
7
+ * materialize-assets, sync, migrate
7
8
  *
8
9
  * Command implementations live in separate files (init.ts, sync.ts, etc.)
9
10
  * and are dynamically imported to keep startup fast.
@@ -23,6 +24,19 @@ export declare function parseArgs(rawArgs: string[]): {
23
24
  * passthrough" and "empty passthrough" alike. Exported for testing.
24
25
  */
25
26
  export declare function passthroughArgs(argv: Record<string, unknown>): string[];
27
+ /**
28
+ * The first flag `materialize-assets` does not take, or `undefined`. Refused rather than ignored: a
29
+ * misspelled `--output-prefix` would send a preview build's writes to production's prefixes.
30
+ * minimist sets every declared boolean, passed or not, so a `false` one (absent, or `--no-x`)
31
+ * changes nothing and is let through. Exported for testing.
32
+ */
33
+ export declare function findUnknownMaterializeFlag(argv: Record<string, unknown>): string | undefined;
34
+ /**
35
+ * The first of `materialize-assets`'s value flags that was repeated or negated. minimist makes a
36
+ * repeated flag an array and `--no-x` false; either would otherwise read as absent, sending
37
+ * `--bucket a --bucket b` down the config path to a bucket neither named. Exported for testing.
38
+ */
39
+ export declare function findMultiValuedMaterializeFlag(argv: Record<string, unknown>): string | undefined;
26
40
  /**
27
41
  * Validates --auth for `init`. Undefined means "not passed" (caller falls
28
42
  * through to prompt/default); throws if a value is given but unrecognized.