canopycms 0.0.67 → 0.0.68-int.100
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.
- package/README.md +21 -14
- package/dist/ai/handler.js +8 -0
- package/dist/api/admin-branch-health.js +17 -20
- package/dist/api/admin.d.ts +30 -9
- package/dist/api/admin.js +32 -2
- package/dist/api/assets.js +84 -76
- package/dist/api/branch-create-window.d.ts +12 -0
- package/dist/api/branch-create-window.js +13 -0
- package/dist/api/branch-review.d.ts +3 -3
- package/dist/api/branch-review.js +14 -5
- package/dist/api/branch-status.d.ts +5 -5
- package/dist/api/branch-status.js +26 -6
- package/dist/api/branch-withdraw.d.ts +2 -2
- package/dist/api/branch-withdraw.js +7 -2
- package/dist/api/branch.d.ts +15 -2
- package/dist/api/branch.js +111 -92
- package/dist/api/client.d.ts +18 -6
- package/dist/api/client.js +79 -12
- package/dist/api/content.d.ts +6 -5
- package/dist/api/content.js +44 -41
- package/dist/api/entries.js +16 -15
- package/dist/api/github-sync.d.ts +10 -1
- package/dist/api/github-sync.js +20 -10
- package/dist/api/index.d.ts +1 -1
- package/dist/api/reference-options.js +1 -1
- package/dist/api/resolve-references.js +14 -40
- package/dist/api/settings-helpers.d.ts +1 -1
- package/dist/api/settings-helpers.js +1 -4
- package/dist/api/user.d.ts +3 -0
- package/dist/api/user.js +3 -0
- package/dist/assets/asset-url.d.ts +22 -16
- package/dist/assets/asset-url.js +42 -25
- package/dist/assets/factory.d.ts +5 -0
- package/dist/assets/factory.js +5 -0
- package/dist/assets/index.d.ts +1 -1
- package/dist/assets/materialize.d.ts +122 -0
- package/dist/assets/materialize.js +305 -0
- package/dist/assets/store-local.d.ts +4 -0
- package/dist/assets/store-local.js +11 -0
- package/dist/assets/store-s3.d.ts +10 -1
- package/dist/assets/store-s3.js +55 -3
- package/dist/assets/transform-directives.d.ts +52 -13
- package/dist/assets/transform-directives.js +87 -27
- package/dist/assets/transform.d.ts +3 -3
- package/dist/assets/transform.js +13 -8
- package/dist/assets/types.d.ts +14 -1
- package/dist/auth/file-based-auth-cache.js +7 -8
- package/dist/authorization/content.d.ts +5 -4
- package/dist/authorization/content.js +5 -5
- package/dist/authorization/path.d.ts +11 -6
- package/dist/authorization/path.js +11 -6
- package/dist/authorization/types.d.ts +2 -2
- package/dist/branch-health.d.ts +7 -1
- package/dist/branch-health.js +8 -3
- package/dist/branch-metadata.d.ts +5 -5
- package/dist/branch-metadata.js +44 -36
- package/dist/branch-provisioning.d.ts +167 -0
- package/dist/branch-provisioning.js +501 -0
- package/dist/branch-schema-cache.d.ts +5 -3
- package/dist/branch-schema-cache.js +29 -11
- package/dist/branch-sparse.d.ts +23 -0
- package/dist/branch-sparse.js +118 -0
- package/dist/branch-workspace.d.ts +42 -3
- package/dist/branch-workspace.js +240 -94
- package/dist/build/asset-refs.d.ts +93 -0
- package/dist/build/asset-refs.js +251 -0
- package/dist/build-identity.d.ts +3 -0
- package/dist/build-identity.js +13 -0
- package/dist/cli/asset-refs.d.ts +30 -0
- package/dist/cli/asset-refs.js +168 -0
- package/dist/cli/cli.d.ts +2 -1
- package/dist/cli/cli.js +4843 -1870
- package/dist/cli/generate-ai-content.js +1584 -444
- package/dist/cli/github-app-manifest.d.ts +4 -3
- package/dist/cli/github-app-manifest.js +4 -3
- package/dist/cli/init.js +30 -27
- package/dist/cli/migrate.js +12 -8
- package/dist/cli/sync.js +83 -37
- package/dist/cli/template-files/Dockerfile.cms.template +8 -0
- package/dist/cli/template-files/cdk-app.ts.template +4 -0
- package/dist/cli/template-files/cms-stack.ts.template +23 -12
- package/dist/cli/template-files/deploy-cms.yml.template +2 -0
- package/dist/config/helpers.js +1 -2
- package/dist/config/schemas/config.d.ts +19 -32
- package/dist/config/schemas/config.js +9 -11
- package/dist/config/schemas/field.js +1 -0
- package/dist/config/schemas/media.d.ts +4 -13
- package/dist/config/schemas/media.js +5 -8
- package/dist/config/schemas/url.d.ts +4 -20
- package/dist/config/schemas/url.js +6 -22
- package/dist/config/types.d.ts +30 -20
- package/dist/config/validation.d.ts +6 -0
- package/dist/config/validation.js +37 -0
- package/dist/content-listing.d.ts +12 -12
- package/dist/content-listing.js +10 -6
- package/dist/content-reader.d.ts +11 -2
- package/dist/content-reader.js +22 -14
- package/dist/content-store.d.ts +37 -9
- package/dist/content-store.js +70 -43
- package/dist/content-tree.d.ts +1 -1
- package/dist/content-tree.js +2 -2
- package/dist/context.d.ts +27 -9
- package/dist/context.js +154 -70
- package/dist/editor/BranchManager.d.ts +7 -1
- package/dist/editor/BranchManager.js +29 -14
- package/dist/editor/CanopyEditor.d.ts +1 -1
- package/dist/editor/CanopyEditor.js +2 -6
- package/dist/editor/Editor.d.ts +4 -3
- package/dist/editor/Editor.js +24 -20
- package/dist/editor/FormRenderer.js +34 -18
- package/dist/editor/admin/SystemHealthPanel.js +47 -7
- package/dist/editor/components/BranchesDrawer.d.ts +9 -0
- package/dist/editor/components/BranchesDrawer.js +4 -0
- package/dist/editor/components/NoEditPermissionNotice.d.ts +10 -0
- package/dist/editor/components/NoEditPermissionNotice.js +17 -0
- package/dist/editor/context/AssetContext.d.ts +18 -35
- package/dist/editor/context/AssetContext.js +20 -29
- package/dist/editor/context/index.d.ts +1 -1
- package/dist/editor/context/index.js +1 -1
- package/dist/editor/editor-config.d.ts +1 -2
- package/dist/editor/editor-config.js +0 -23
- package/dist/editor/editor-utils.d.ts +24 -21
- package/dist/editor/editor-utils.js +75 -62
- package/dist/editor/fields/BlockField.d.ts +1 -0
- package/dist/editor/fields/BlockField.js +6 -4
- package/dist/editor/fields/CodeField.d.ts +1 -0
- package/dist/editor/fields/CodeField.js +2 -2
- package/dist/editor/fields/DateTimeField.d.ts +1 -0
- package/dist/editor/fields/DateTimeField.js +2 -2
- package/dist/editor/fields/FieldDescription.d.ts +16 -0
- package/dist/editor/fields/FieldDescription.js +11 -0
- package/dist/editor/fields/ImageField.d.ts +1 -0
- package/dist/editor/fields/ImageField.js +3 -2
- package/dist/editor/fields/InlineGroupField.js +4 -1
- package/dist/editor/fields/MarkdownField.d.ts +1 -0
- package/dist/editor/fields/MarkdownField.js +115 -20
- package/dist/editor/fields/NumberField.d.ts +1 -0
- package/dist/editor/fields/NumberField.js +2 -2
- package/dist/editor/fields/NumberListField.d.ts +1 -0
- package/dist/editor/fields/NumberListField.js +2 -2
- package/dist/editor/fields/ObjectField.d.ts +1 -0
- package/dist/editor/fields/ObjectField.js +5 -2
- package/dist/editor/fields/ReferenceField.d.ts +1 -0
- package/dist/editor/fields/ReferenceField.js +5 -4
- package/dist/editor/fields/SelectField.d.ts +1 -0
- package/dist/editor/fields/SelectField.js +2 -2
- package/dist/editor/fields/StringListField.d.ts +1 -0
- package/dist/editor/fields/StringListField.js +2 -2
- package/dist/editor/fields/TextField.d.ts +1 -0
- package/dist/editor/fields/TextField.js +2 -2
- package/dist/editor/fields/ToggleField.d.ts +1 -0
- package/dist/editor/fields/ToggleField.js +7 -4
- package/dist/editor/fields/entry-link/InsertEntryLink.d.ts +1 -1
- package/dist/editor/fields/entry-link/InsertEntryLink.js +3 -2
- package/dist/editor/fields/mdx-jsx-support.d.ts +1 -0
- package/dist/editor/fields/mdx-jsx-support.js +137 -0
- package/dist/editor/hooks/create-branch-request.d.ts +20 -0
- package/dist/editor/hooks/create-branch-request.js +52 -0
- package/dist/editor/hooks/useBranchActions.d.ts +13 -2
- package/dist/editor/hooks/useBranchActions.js +48 -17
- package/dist/editor/hooks/useBranchManager.d.ts +11 -0
- package/dist/editor/hooks/useBranchManager.js +80 -2
- package/dist/editor/hooks/useBranchesData.d.ts +6 -0
- package/dist/editor/hooks/useBranchesData.js +9 -3
- package/dist/editor/hooks/useCommentSystem.js +25 -6
- package/dist/editor/hooks/useDraftManager.d.ts +16 -1
- package/dist/editor/hooks/useDraftManager.js +193 -36
- package/dist/editor/hooks/useEntriesData.js +3 -3
- package/dist/editor/hooks/useEntryManager.d.ts +2 -1
- package/dist/editor/hooks/useEntryManager.js +34 -19
- package/dist/editor/media/AssetCard.d.ts +1 -1
- package/dist/editor/media/crop-math.d.ts +3 -4
- package/dist/editor/media/crop-math.js +11 -22
- package/dist/editor/media/editor-image-src.d.ts +7 -0
- package/dist/editor/media/editor-image-src.js +12 -0
- package/dist/editor/preview-asset-base.d.ts +8 -0
- package/dist/editor/preview-asset-base.js +13 -0
- package/dist/editor/preview-bridge.d.ts +8 -1
- package/dist/editor/preview-bridge.js +15 -2
- package/dist/editor/preview-path.d.ts +10 -0
- package/dist/editor/preview-path.js +20 -0
- package/dist/editor/theme.js +2 -2
- package/dist/entry-schema-registry.d.ts +4 -4
- package/dist/entry-schema-registry.js +10 -6
- package/dist/entry-schema.d.ts +27 -4
- package/dist/entry-schema.js +20 -3
- package/dist/git-manager.d.ts +173 -6
- package/dist/git-manager.js +594 -76
- package/dist/github-service.d.ts +8 -0
- package/dist/github-service.js +5 -1
- package/dist/http/handler.js +37 -13
- package/dist/http/index.d.ts +2 -0
- package/dist/http/index.js +2 -0
- package/dist/http/router.d.ts +2 -0
- package/dist/http/router.js +2 -1
- package/dist/http/types.d.ts +2 -0
- package/dist/http/worker-not-ready.d.ts +10 -0
- package/dist/http/worker-not-ready.js +24 -0
- package/dist/operating-mode/client-unsafe-strategy.js +0 -6
- package/dist/operating-mode/types.d.ts +1 -4
- package/dist/paths/branch-name.d.ts +5 -0
- package/dist/paths/branch-name.js +9 -0
- package/dist/paths/branch.d.ts +6 -2
- package/dist/paths/branch.js +16 -10
- package/dist/paths/index.d.ts +3 -3
- package/dist/paths/index.js +3 -3
- package/dist/paths/normalize.d.ts +7 -0
- package/dist/paths/normalize.js +9 -0
- package/dist/paths/validation.js +0 -2
- package/dist/reference-resolver.d.ts +5 -21
- package/dist/reference-resolver.js +9 -44
- package/dist/resolve-canopy-user.js +2 -1
- package/dist/schema/schema-store.d.ts +54 -31
- package/dist/schema/schema-store.js +77 -31
- package/dist/server.d.ts +24 -9
- package/dist/server.js +22 -8
- package/dist/services.d.ts +18 -6
- package/dist/services.js +85 -70
- package/dist/settings-workspace.d.ts +23 -3
- package/dist/settings-workspace.js +197 -45
- package/dist/static/seo.d.ts +2 -14
- package/dist/static/seo.js +2 -25
- package/dist/submission-attribution.d.ts +73 -0
- package/dist/submission-attribution.js +221 -0
- package/dist/sync-core.d.ts +12 -1
- package/dist/sync-core.js +31 -13
- package/dist/task-queue/worker-status.d.ts +8 -0
- package/dist/task-queue/worker-status.js +17 -0
- package/dist/types.d.ts +31 -2
- package/dist/utils/content-write-lock.d.ts +18 -7
- package/dist/utils/content-write-lock.js +18 -9
- package/dist/utils/debug.d.ts +8 -0
- package/dist/utils/debug.js +10 -2
- package/dist/utils/git.d.ts +32 -0
- package/dist/utils/git.js +42 -0
- package/dist/utils/occ-json-write.js +10 -2
- package/dist/utils/provision-log.d.ts +25 -0
- package/dist/utils/provision-log.js +49 -0
- package/dist/utils/provisioning-lock.d.ts +35 -5
- package/dist/utils/provisioning-lock.js +73 -12
- package/dist/utils/request-timing.d.ts +25 -0
- package/dist/utils/request-timing.js +101 -0
- package/dist/utils/sanitize-href.d.ts +8 -22
- package/dist/utils/sanitize-href.js +11 -28
- package/dist/utils/url-prefix.d.ts +30 -0
- package/dist/utils/url-prefix.js +61 -0
- package/dist/validation/entry-validator.js +9 -3
- package/dist/version.d.ts +1 -0
- package/dist/version.js +3 -0
- package/dist/worker/canopy-state.d.ts +45 -0
- package/dist/worker/canopy-state.js +75 -0
- package/dist/worker/cms-worker.d.ts +8 -3
- package/dist/worker/cms-worker.js +38 -2
- package/dist/worker/git-sync.d.ts +30 -12
- package/dist/worker/git-sync.js +253 -58
- package/dist/worker/history-rewrite.d.ts +1 -1
- package/dist/worker/history-rewrite.js +1 -1
- package/dist/worker/provisioned-workspace.d.ts +35 -0
- package/dist/worker/provisioned-workspace.js +50 -0
- package/dist/worker/rebase.d.ts +1 -1
- package/dist/worker/rebase.js +95 -23
- package/dist/worker/remote-git-maintenance.d.ts +3 -0
- package/dist/worker/remote-git-maintenance.js +11 -0
- package/dist/worker/sparse-cone.d.ts +20 -0
- package/dist/worker/sparse-cone.js +132 -0
- package/dist/worker/task-runner.js +39 -4
- package/dist/worker/worker-context.d.ts +6 -1
- package/package.json +2 -1
|
@@ -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,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,30 @@
|
|
|
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 loads config 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
|
+
export interface MaterializeAssetsCLIOptions {
|
|
13
|
+
projectDir: string;
|
|
14
|
+
refsPath: string | undefined;
|
|
15
|
+
reportPath?: string;
|
|
16
|
+
allowFailures: boolean;
|
|
17
|
+
concurrency?: string;
|
|
18
|
+
transformConcurrency?: string;
|
|
19
|
+
/** @internal Test seam: skips loading the project's config. */
|
|
20
|
+
store?: AssetStore;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Exits non-zero on any failure. `--allow-failures` tolerates content failures only — a
|
|
24
|
+
* reference to an asset the store can no longer produce — so one deleted image does not block a
|
|
25
|
+
* release. A store failure (access denied, an outage that outlasted the retries) still fails the
|
|
26
|
+
* run, since it says nothing about which images are actually fine.
|
|
27
|
+
*
|
|
28
|
+
* @returns the process exit code.
|
|
29
|
+
*/
|
|
30
|
+
export declare function materializeAssetsCLI(options: MaterializeAssetsCLIOptions): Promise<number>;
|
|
@@ -0,0 +1,168 @@
|
|
|
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 loads config and reports.
|
|
6
|
+
*/
|
|
7
|
+
import fs from 'node:fs/promises';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { createJiti } from 'jiti';
|
|
10
|
+
import { z } from 'zod';
|
|
11
|
+
import { createAssetStore } from '../assets/factory.js';
|
|
12
|
+
import { materializeAssets, SharpUnavailableError, } from '../assets/materialize.js';
|
|
13
|
+
import { AssetRefsError, collectAssetRefs, readAssetRefsFile } from '../build/asset-refs.js';
|
|
14
|
+
import { mediaSchema } from '../config/schemas/media.js';
|
|
15
|
+
import { operatingStrategy } from '../operating-mode/index.js';
|
|
16
|
+
import { atomicWriteFile } from '../utils/atomic-write.js';
|
|
17
|
+
import { isNotFoundError } from '../utils/error.js';
|
|
18
|
+
/** @returns the process exit code. */
|
|
19
|
+
export async function collectAssetRefsCLI(options) {
|
|
20
|
+
if (!options.outDir) {
|
|
21
|
+
console.error('Usage: canopycms collect-asset-refs <outDir>');
|
|
22
|
+
return 1;
|
|
23
|
+
}
|
|
24
|
+
const outDir = path.resolve(options.outDir);
|
|
25
|
+
try {
|
|
26
|
+
if (!(await fs.stat(outDir)).isDirectory()) {
|
|
27
|
+
console.error(`Not a directory: ${outDir}`);
|
|
28
|
+
return 1;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
if (!isNotFoundError(err))
|
|
33
|
+
throw err;
|
|
34
|
+
console.error(`No build output at ${outDir}. Run this after the static build.`);
|
|
35
|
+
return 1;
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
const { refs, filePath, scannedFiles } = await collectAssetRefs(outDir);
|
|
39
|
+
console.log(`canopycms collect-asset-refs: ${refs.transforms.length} transform and ` +
|
|
40
|
+
`${refs.statics.length} static asset reference(s) in ${scannedFiles} file(s), ` +
|
|
41
|
+
`written to ${filePath}`);
|
|
42
|
+
return 0;
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
if (!(err instanceof AssetRefsError))
|
|
46
|
+
throw err;
|
|
47
|
+
console.error(`canopycms collect-asset-refs: ${err.message}`);
|
|
48
|
+
console.error('Build these URLs with assetUrl/assetSrcSet, which always write the canonical form; ' +
|
|
49
|
+
'a hand-written or edited URL has no stored object behind it.');
|
|
50
|
+
return 1;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const serverConfigSchema = z.object({
|
|
54
|
+
mode: z.enum(['prod', 'dev']),
|
|
55
|
+
media: mediaSchema.optional(),
|
|
56
|
+
});
|
|
57
|
+
/**
|
|
58
|
+
* The store the site's `media` config names, resolved as the running CMS resolves it when its
|
|
59
|
+
* working directory is the project: dev mode with no `media` falls back to the dev workspace's
|
|
60
|
+
* local store, prod never does.
|
|
61
|
+
*/
|
|
62
|
+
async function loadConfiguredAssetStore(projectDir) {
|
|
63
|
+
const configPath = path.join(projectDir, 'canopycms.config.ts');
|
|
64
|
+
const jiti = createJiti(import.meta.url);
|
|
65
|
+
const mod = (await jiti.import(configPath));
|
|
66
|
+
const configExport = mod.default ?? mod.config ?? mod;
|
|
67
|
+
const server = typeof configExport === 'object' && configExport !== null && 'server' in configExport
|
|
68
|
+
? configExport.server
|
|
69
|
+
: configExport;
|
|
70
|
+
const parsed = serverConfigSchema.safeParse(server);
|
|
71
|
+
if (!parsed.success) {
|
|
72
|
+
throw new Error(`Invalid CanopyCMS config at ${configPath}: ${parsed.error.message}`);
|
|
73
|
+
}
|
|
74
|
+
const { mode, media } = parsed.data;
|
|
75
|
+
const devAssetsDir = mode === 'dev'
|
|
76
|
+
? path.join(operatingStrategy(mode).getWorkspaceRoot(projectDir), 'assets')
|
|
77
|
+
: undefined;
|
|
78
|
+
// A relative `media.directory` is relative to the project, wherever the command was run from.
|
|
79
|
+
const resolvedMedia = media?.adapter === 'local' && media.directory
|
|
80
|
+
? { ...media, directory: path.resolve(projectDir, media.directory) }
|
|
81
|
+
: media;
|
|
82
|
+
const store = createAssetStore(resolvedMedia, { devAssetsDir });
|
|
83
|
+
if (!store) {
|
|
84
|
+
throw new Error(`No asset store is configured in ${configPath}: set \`media\` (an s3 adapter in prod).`);
|
|
85
|
+
}
|
|
86
|
+
return store;
|
|
87
|
+
}
|
|
88
|
+
function describeReferences(result) {
|
|
89
|
+
if (result.routes.length > 0)
|
|
90
|
+
return `pages: ${result.routes.join(', ')}`;
|
|
91
|
+
return `files: ${result.files.join(', ')}`;
|
|
92
|
+
}
|
|
93
|
+
function printReport(report) {
|
|
94
|
+
const { summary } = report;
|
|
95
|
+
console.log(`canopycms materialize-assets: ${summary.total} key(s): ${summary.existed} existed, ` +
|
|
96
|
+
`${summary.created} created, ${summary.failed} failed`);
|
|
97
|
+
for (const result of report.results) {
|
|
98
|
+
if (result.status !== 'failed')
|
|
99
|
+
continue;
|
|
100
|
+
console.error(` FAILED (${result.failure}) /${result.key}`);
|
|
101
|
+
console.error(` ${result.error}`);
|
|
102
|
+
console.error(` ${describeReferences(result)}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/** `undefined` when the flag is absent; `null`, after printing why, when it is not a positive integer. */
|
|
106
|
+
function parsePositiveInteger(flag, value) {
|
|
107
|
+
if (value === undefined)
|
|
108
|
+
return undefined;
|
|
109
|
+
const parsed = Number(value);
|
|
110
|
+
if (Number.isInteger(parsed) && parsed >= 1)
|
|
111
|
+
return parsed;
|
|
112
|
+
console.error(`${flag} must be a positive integer, got "${value}"`);
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Exits non-zero on any failure. `--allow-failures` tolerates content failures only — a
|
|
117
|
+
* reference to an asset the store can no longer produce — so one deleted image does not block a
|
|
118
|
+
* release. A store failure (access denied, an outage that outlasted the retries) still fails the
|
|
119
|
+
* run, since it says nothing about which images are actually fine.
|
|
120
|
+
*
|
|
121
|
+
* @returns the process exit code.
|
|
122
|
+
*/
|
|
123
|
+
export async function materializeAssetsCLI(options) {
|
|
124
|
+
if (!options.refsPath) {
|
|
125
|
+
console.error('Usage: canopycms materialize-assets --refs <file> [--report <file>]');
|
|
126
|
+
return 1;
|
|
127
|
+
}
|
|
128
|
+
const concurrency = parsePositiveInteger('--concurrency', options.concurrency);
|
|
129
|
+
const transformConcurrency = parsePositiveInteger('--transform-concurrency', options.transformConcurrency);
|
|
130
|
+
if (concurrency === null || transformConcurrency === null)
|
|
131
|
+
return 1;
|
|
132
|
+
const refs = await readAssetRefsFile(path.resolve(options.refsPath));
|
|
133
|
+
const store = options.store ?? (await loadConfiguredAssetStore(options.projectDir));
|
|
134
|
+
let report;
|
|
135
|
+
try {
|
|
136
|
+
report = await materializeAssets({
|
|
137
|
+
store,
|
|
138
|
+
targets: refs.transforms,
|
|
139
|
+
statics: refs.statics,
|
|
140
|
+
concurrency,
|
|
141
|
+
transformConcurrency,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
catch (err) {
|
|
145
|
+
if (!(err instanceof SharpUnavailableError))
|
|
146
|
+
throw err;
|
|
147
|
+
console.error(`canopycms materialize-assets: ${err.message}`);
|
|
148
|
+
return 1;
|
|
149
|
+
}
|
|
150
|
+
if (options.reportPath) {
|
|
151
|
+
await atomicWriteFile(path.resolve(options.reportPath), `${JSON.stringify(report, null, 2)}\n`);
|
|
152
|
+
}
|
|
153
|
+
printReport(report);
|
|
154
|
+
const { contentFailures, storeFailures } = report.summary;
|
|
155
|
+
if (storeFailures > 0) {
|
|
156
|
+
if (options.allowFailures) {
|
|
157
|
+
console.error('--allow-failures does not cover store failures; fix access or rerun.');
|
|
158
|
+
}
|
|
159
|
+
return 1;
|
|
160
|
+
}
|
|
161
|
+
if (contentFailures === 0)
|
|
162
|
+
return 0;
|
|
163
|
+
if (!options.allowFailures)
|
|
164
|
+
return 1;
|
|
165
|
+
console.warn(`\nWARNING: --allow-failures is set. ${contentFailures} image URL(s) above will be MISSING ` +
|
|
166
|
+
`from the released site. Fix the pages listed and rerun.\n`);
|
|
167
|
+
return 0;
|
|
168
|
+
}
|
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,
|
|
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.
|