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.
- package/README.md +24 -17
- 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 +118 -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 +19 -1
- package/dist/api/github-sync.js +57 -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/finalize.js +3 -0
- package/dist/assets/index.d.ts +1 -1
- package/dist/assets/materialize.d.ts +153 -0
- package/dist/assets/materialize.js +402 -0
- package/dist/assets/store-local.d.ts +19 -5
- package/dist/assets/store-local.js +69 -10
- package/dist/assets/store-s3.d.ts +38 -7
- package/dist/assets/store-s3.js +128 -22
- 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 +31 -4
- 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 +52 -0
- package/dist/cli/asset-refs.js +194 -0
- package/dist/cli/cli.d.ts +15 -1
- package/dist/cli/cli.js +5928 -2195
- package/dist/cli/configured-asset-store.d.ts +12 -0
- package/dist/cli/configured-asset-store.js +46 -0
- package/dist/cli/generate-ai-content.js +2185 -587
- 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/client.d.ts +1 -1
- package/dist/client.js +1 -1
- 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 +32 -22
- package/dist/editor/FormRenderer.js +34 -18
- package/dist/editor/PreviewFrame.d.ts +24 -0
- package/dist/editor/PreviewFrame.js +140 -0
- 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 +21 -0
- package/dist/editor/hooks/useBranchManager.js +204 -37
- 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 +9 -21
- package/dist/editor/preview-bridge.js +19 -139
- 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 +15 -0
- package/dist/github-service.js +19 -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/preview.d.ts +8 -0
- package/dist/preview.js +7 -0
- 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 +64 -9
- package/dist/server.js +48 -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-serialize.d.ts +5 -2
- package/dist/utils/content-serialize.js +98 -24
- 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/utils/yaml-source-splice.d.ts +58 -0
- package/dist/utils/yaml-source-splice.js +515 -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 +99 -14
- package/dist/worker/worker-context.d.ts +6 -1
- package/package.json +8 -2
|
@@ -3,6 +3,12 @@ import path from 'node:path';
|
|
|
3
3
|
import lockfile from 'proper-lockfile';
|
|
4
4
|
import { getErrorMessage, isNodeError } from './error.js';
|
|
5
5
|
import { canopyLogWarn } from './logger.js';
|
|
6
|
+
/**
|
|
7
|
+
* When an acquirer may take a provisioning marker over. Staleness is read from the marker's mtime,
|
|
8
|
+
* which EFS serves from the NFS attribute cache for up to 60s, so a live holder's 15s refreshes
|
|
9
|
+
* can look 60s late; one threshold above that for every acquirer means none reaps a live hold.
|
|
10
|
+
*/
|
|
11
|
+
export const PROVISIONING_LOCK_STALE_MS = 90_000;
|
|
6
12
|
/**
|
|
7
13
|
* Shared option set for both provisioning-lock variants.
|
|
8
14
|
*
|
|
@@ -16,12 +22,14 @@ import { canopyLogWarn } from './logger.js';
|
|
|
16
22
|
* share one. `realpath: false` because that anchor path does not exist before we create it
|
|
17
23
|
* (realpath would ENOENT). See docs/concurrency.md ("Anchor path matters").
|
|
18
24
|
*/
|
|
19
|
-
function provisioningLockOptions(lockPath, retries, onCompromised) {
|
|
25
|
+
function provisioningLockOptions(lockPath, retries, onCompromised, staleMs) {
|
|
20
26
|
return {
|
|
21
27
|
lockfilePath: lockPath,
|
|
22
28
|
realpath: false,
|
|
23
29
|
retries,
|
|
24
|
-
stale:
|
|
30
|
+
stale: staleMs,
|
|
31
|
+
// Fixed, not stale/2: every holder refreshes at 15s whatever threshold it judges others by.
|
|
32
|
+
update: 15_000,
|
|
25
33
|
// proper-lockfile invokes this from inside its refresh timer, so ANY throw escaping here is
|
|
26
34
|
// an uncaught exception that kills the process. Call sites are told not to throw (see
|
|
27
35
|
// OnLockCompromised); this makes it structural rather than a convention, and also covers the
|
|
@@ -76,6 +84,15 @@ function releaseIgnoringAlreadyReleased(release, lockPath) {
|
|
|
76
84
|
}
|
|
77
85
|
};
|
|
78
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* The provisioning lock's name for the branch workspace directory `dirName`, held in that
|
|
89
|
+
* directory's parent. Every party to a branch workspace's provisioning names it through this:
|
|
90
|
+
* the Lambda while it clones, the admin purge, branch-health's freshness rail, and the worker
|
|
91
|
+
* while it refreshes or rebases.
|
|
92
|
+
*/
|
|
93
|
+
export function branchProvisioningLockName(dirName) {
|
|
94
|
+
return `.${dirName}.init.lock`;
|
|
95
|
+
}
|
|
79
96
|
/**
|
|
80
97
|
* Acquire a cross-process filesystem lock for content provisioning. Returns a release function —
|
|
81
98
|
* always call it in a `finally`.
|
|
@@ -94,9 +111,9 @@ export async function acquireProvisioningLock(lockTargetDir, lockName, onComprom
|
|
|
94
111
|
// Generous, jittered retries: several processes may contend for one workspace (Lambda
|
|
95
112
|
// containers cold-starting together against one EFS root), and the holder can take seconds to
|
|
96
113
|
// init plus clone/push. `randomize` de-syncs the herd so a waiter is not perpetually colliding
|
|
97
|
-
// on the same tick.
|
|
98
|
-
//
|
|
99
|
-
const release = await lockfile.lock(lockPath, provisioningLockOptions(lockPath, { retries: 600, factor: 1, minTimeout: 300, maxTimeout: 800, randomize: true }, onCompromised));
|
|
114
|
+
// on the same tick. A live holder's lock never goes stale, because proper-lockfile refreshes it;
|
|
115
|
+
// it expires only when a process actually dies.
|
|
116
|
+
const release = await lockfile.lock(lockPath, provisioningLockOptions(lockPath, { retries: 600, factor: 1, minTimeout: 300, maxTimeout: 800, randomize: true }, onCompromised, PROVISIONING_LOCK_STALE_MS));
|
|
100
117
|
return releaseIgnoringAlreadyReleased(release, lockPath);
|
|
101
118
|
}
|
|
102
119
|
/**
|
|
@@ -104,17 +121,61 @@ export async function acquireProvisioningLock(lockTargetDir, lockName, onComprom
|
|
|
104
121
|
* request/response cycle (a Lambda-backed API handler). `acquireProvisioningLock`'s ~600-retry
|
|
105
122
|
* budget waits minutes for a live provisioner; an admin request must fail fast instead.
|
|
106
123
|
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
124
|
+
* Staleness recovery is unchanged from the patient variant: a genuinely stale lock is still taken
|
|
125
|
+
* over normally. Only the RETRY loop for live contention is removed, not the staleness recovery a
|
|
126
|
+
* caller depends on -- see branch-health.ts's [H1] freshness rail, which reads this lock's mtime
|
|
127
|
+
* before an admin purge/repair proceeds.
|
|
111
128
|
*
|
|
112
129
|
* Throws with `err.code === 'ELOCKED'` on contention (a live, non-stale holder) -- callers
|
|
113
130
|
* translate that into a 409.
|
|
131
|
+
*
|
|
132
|
+
* @param staleMs how old the marker must look before this caller takes it over; defaults to
|
|
133
|
+
* {@link PROVISIONING_LOCK_STALE_MS}
|
|
134
|
+
* @param createParents whether a missing parent of `lockTargetDir` is created. A lock inside a
|
|
135
|
+
* branch passes false: a waiter must never recreate a branch root a delete removed, so a missing
|
|
136
|
+
* parent fails with ENOENT instead.
|
|
114
137
|
*/
|
|
115
|
-
export async function tryAcquireProvisioningLock(lockTargetDir, lockName, onCompromised) {
|
|
116
|
-
|
|
138
|
+
export async function tryAcquireProvisioningLock(lockTargetDir, lockName, onCompromised, staleMs = PROVISIONING_LOCK_STALE_MS, createParents = true) {
|
|
139
|
+
if (createParents) {
|
|
140
|
+
await fs.mkdir(lockTargetDir, { recursive: true });
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
await fs.mkdir(lockTargetDir).catch((err) => {
|
|
144
|
+
if (!isNodeError(err) || err.code !== 'EEXIST')
|
|
145
|
+
throw err;
|
|
146
|
+
});
|
|
147
|
+
}
|
|
117
148
|
const lockPath = path.join(lockTargetDir, lockName);
|
|
118
|
-
const release = await lockfile.lock(lockPath, provisioningLockOptions(lockPath, 0, onCompromised));
|
|
149
|
+
const release = await lockfile.lock(lockPath, provisioningLockOptions(lockPath, 0, onCompromised, staleMs));
|
|
119
150
|
return releaseIgnoringAlreadyReleased(release, lockPath);
|
|
120
151
|
}
|
|
152
|
+
const sleep = (ms) => new Promise((resolve) => {
|
|
153
|
+
setTimeout(resolve, ms);
|
|
154
|
+
});
|
|
155
|
+
/**
|
|
156
|
+
* {@link tryAcquireProvisioningLock} retried for at most `waitMs`, for a caller whose own hold is
|
|
157
|
+
* milliseconds long (publishing a staged branch workspace). A dead holder's marker stays live for
|
|
158
|
+
* {@link PROVISIONING_LOCK_STALE_MS}, so waiting it out from a request would spend the request's
|
|
159
|
+
* whole timeout; this gives up instead.
|
|
160
|
+
*
|
|
161
|
+
* Retries only on `ELOCKED`, like the content-write lock's bounded wait.
|
|
162
|
+
*
|
|
163
|
+
* @throws the last `ELOCKED` error once the budget is spent
|
|
164
|
+
*/
|
|
165
|
+
export async function acquireProvisioningLockWithin(lockTargetDir, lockName, waitMs, onCompromised) {
|
|
166
|
+
const startedAt = Date.now();
|
|
167
|
+
for (let attempt = 0;; attempt++) {
|
|
168
|
+
try {
|
|
169
|
+
return await tryAcquireProvisioningLock(lockTargetDir, lockName, onCompromised);
|
|
170
|
+
}
|
|
171
|
+
catch (err) {
|
|
172
|
+
if (!isNodeError(err) || err.code !== 'ELOCKED')
|
|
173
|
+
throw err;
|
|
174
|
+
const remaining = waitMs - (Date.now() - startedAt);
|
|
175
|
+
if (remaining <= 0)
|
|
176
|
+
throw err;
|
|
177
|
+
const base = Math.min(50 * 2 ** attempt, 500);
|
|
178
|
+
await sleep(Math.min(base * (0.5 + Math.random()), remaining));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
interface PhaseTotal {
|
|
2
|
+
count: number;
|
|
3
|
+
ms: number;
|
|
4
|
+
}
|
|
5
|
+
/** Record `fn` as `phase` in the active request scope; outside one, just run it. */
|
|
6
|
+
export declare function timeRequestPhase<T>(phase: string, fn: () => Promise<T>): Promise<T>;
|
|
7
|
+
/**
|
|
8
|
+
* Whether a request scope is open here.
|
|
9
|
+
* @internal Exported for tests.
|
|
10
|
+
*/
|
|
11
|
+
export declare function isRequestTimingScopeActive(): boolean;
|
|
12
|
+
/** Name the matched route (a pattern, never raw path segments) for the summary line. */
|
|
13
|
+
export declare function setRequestTimingRoute(route: string): void;
|
|
14
|
+
/**
|
|
15
|
+
* The summary line's message, after the logger's timestamp/category prefix:
|
|
16
|
+
* `GET :branch/entries 200 1172ms | context=0 auth=12 route=1110 route>settingsRoot=1080(x2) untimed=50`.
|
|
17
|
+
* @internal Exported for tests.
|
|
18
|
+
*/
|
|
19
|
+
export declare function formatRequestTimingSummary(method: string, route: string, status: number | 'error', totalMs: number, phases: ReadonlyMap<string, PhaseTotal>): string;
|
|
20
|
+
/**
|
|
21
|
+
* Run one API request inside a timing scope and log its summary line when it settles,
|
|
22
|
+
* thrown or not. `statusOf` reads the status from the result.
|
|
23
|
+
*/
|
|
24
|
+
export declare function runWithRequestTiming<T>(method: string, fn: () => Promise<T>, statusOf: (result: T) => number): Promise<T>;
|
|
25
|
+
export {};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-request phase timing for the API handler, enabled by `CANOPYCMS_DEBUG=true`.
|
|
3
|
+
*
|
|
4
|
+
* `runWithRequestTiming` opens a request scope in AsyncLocalStorage and, when the request
|
|
5
|
+
* finishes, emits ONE summary line: method, route pattern, status, total, and every phase
|
|
6
|
+
* recorded inside the scope. `timeRequestPhase` records a phase into whichever scope is
|
|
7
|
+
* active. Scopes are per async context, so concurrent requests in one process never share
|
|
8
|
+
* or overwrite a span, and a phase reached outside any request (the worker, a build, a
|
|
9
|
+
* page render) just runs its function.
|
|
10
|
+
*
|
|
11
|
+
* Disabled, `runWithRequestTiming` calls straight through without opening a scope, so each
|
|
12
|
+
* `timeRequestPhase` costs one `getStore()` that returns undefined.
|
|
13
|
+
*
|
|
14
|
+
* A phase started inside another records under the joined name (`route>settingsRoot`), so
|
|
15
|
+
* nesting stays visible and nested time is never added to its parent's siblings. A phase
|
|
16
|
+
* run several times in one request accumulates, and the line shows the count. `untimed` is
|
|
17
|
+
* the total minus the top-level phases, which run sequentially in the handler.
|
|
18
|
+
*
|
|
19
|
+
* Server-only: `node:async_hooks`.
|
|
20
|
+
*/
|
|
21
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
22
|
+
import { createDebugLogger } from './debug.js';
|
|
23
|
+
const storage = new AsyncLocalStorage();
|
|
24
|
+
const log = createDebugLogger({ prefix: 'CanopyCMS' });
|
|
25
|
+
/** Read per call, like DebugLogger, so toggling the env var needs no restart. */
|
|
26
|
+
function isRequestTimingEnabled() {
|
|
27
|
+
return process.env.CANOPYCMS_DEBUG === 'true';
|
|
28
|
+
}
|
|
29
|
+
/** Record `fn` as `phase` in the active request scope; outside one, just run it. */
|
|
30
|
+
export async function timeRequestPhase(phase, fn) {
|
|
31
|
+
const parent = storage.getStore();
|
|
32
|
+
if (!parent)
|
|
33
|
+
return fn();
|
|
34
|
+
const key = parent.path ? `${parent.path}>${phase}` : phase;
|
|
35
|
+
// Created at START so the summary lists phases in the order they began.
|
|
36
|
+
let total = parent.phases.get(key);
|
|
37
|
+
if (!total) {
|
|
38
|
+
total = { count: 0, ms: 0 };
|
|
39
|
+
parent.phases.set(key, total);
|
|
40
|
+
}
|
|
41
|
+
const start = performance.now();
|
|
42
|
+
try {
|
|
43
|
+
return await storage.run({ ...parent, path: key }, fn);
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
total.count += 1;
|
|
47
|
+
total.ms += performance.now() - start;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Whether a request scope is open here.
|
|
52
|
+
* @internal Exported for tests.
|
|
53
|
+
*/
|
|
54
|
+
export function isRequestTimingScopeActive() {
|
|
55
|
+
return storage.getStore() !== undefined;
|
|
56
|
+
}
|
|
57
|
+
/** Name the matched route (a pattern, never raw path segments) for the summary line. */
|
|
58
|
+
export function setRequestTimingRoute(route) {
|
|
59
|
+
const scope = storage.getStore();
|
|
60
|
+
if (scope)
|
|
61
|
+
scope.request.route = route;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The summary line's message, after the logger's timestamp/category prefix:
|
|
65
|
+
* `GET :branch/entries 200 1172ms | context=0 auth=12 route=1110 route>settingsRoot=1080(x2) untimed=50`.
|
|
66
|
+
* @internal Exported for tests.
|
|
67
|
+
*/
|
|
68
|
+
export function formatRequestTimingSummary(method, route, status, totalMs, phases) {
|
|
69
|
+
let topLevelMs = 0;
|
|
70
|
+
const parts = [];
|
|
71
|
+
for (const [name, { count, ms }] of phases) {
|
|
72
|
+
if (!name.includes('>'))
|
|
73
|
+
topLevelMs += ms;
|
|
74
|
+
parts.push(`${name}=${Math.round(ms)}${count > 1 ? `(x${count})` : ''}`);
|
|
75
|
+
}
|
|
76
|
+
parts.push(`untimed=${Math.max(0, Math.round(totalMs - topLevelMs))}`);
|
|
77
|
+
return `${method} ${route} ${status} ${Math.round(totalMs)}ms | ${parts.join(' ')}`;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Run one API request inside a timing scope and log its summary line when it settles,
|
|
81
|
+
* thrown or not. `statusOf` reads the status from the result.
|
|
82
|
+
*/
|
|
83
|
+
export async function runWithRequestTiming(method, fn, statusOf) {
|
|
84
|
+
if (!isRequestTimingEnabled())
|
|
85
|
+
return fn();
|
|
86
|
+
const scope = {
|
|
87
|
+
phases: new Map(),
|
|
88
|
+
path: '',
|
|
89
|
+
request: { route: '(unmatched)' },
|
|
90
|
+
};
|
|
91
|
+
const start = performance.now();
|
|
92
|
+
let status = 'error';
|
|
93
|
+
try {
|
|
94
|
+
const result = await storage.run(scope, fn);
|
|
95
|
+
status = statusOf(result);
|
|
96
|
+
return result;
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
log.debug('timing', formatRequestTimingSummary(method, scope.request.route, status, performance.now() - start, scope.phases));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -55,36 +55,22 @@ export declare function neutralizeImplicitOffOrigin(url: string): string;
|
|
|
55
55
|
* this file either way, so the two surfaces cannot drift into disagreeing about which schemes
|
|
56
56
|
* are acceptable (that drift is exactly why `utils/url-prefix.ts` exists — see its header).
|
|
57
57
|
*
|
|
58
|
-
* `
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
* a working configuration. A bad value costs a broken `<img>`.
|
|
65
|
-
* - A WRITE-side endpoint (`media.uploadUrl`, where the browser POSTs a presigned upload) must
|
|
66
|
-
* not be protocol-relative even when spelled literally, because such a URL is AMBIGUOUS: it
|
|
67
|
-
* resolves to http or https depending on the scheme of whichever editor page happens to
|
|
68
|
-
* issue the upload, so the stored config does not determine where a live presigned credential
|
|
69
|
-
* and the user's file bytes are sent. That is the same principle every other rule here
|
|
70
|
-
* enforces — a config value must describe what actually happens.
|
|
71
|
-
*
|
|
72
|
-
* Note this is NOT an argument that http is forbidden on the write side; bare `http://` is
|
|
73
|
-
* accepted, because a loopback or in-cluster S3-compatible endpoint is a real need and a
|
|
74
|
-
* browser on an https editor blocks the mixed-content request anyway, so it fails closed. The
|
|
75
|
-
* objection to `//host` is that it is undetermined, not that it might be insecure.
|
|
58
|
+
* A protocol-relative `//host` is refused even when spelled literally, because it is AMBIGUOUS:
|
|
59
|
+
* it resolves to http or https depending on the page issuing the request, so the stored config
|
|
60
|
+
* does not determine where, for `media.uploadUrl`, a live presigned credential and the user's
|
|
61
|
+
* file bytes are sent. Bare `http://` is accepted: a loopback or in-cluster S3-compatible
|
|
62
|
+
* endpoint is a real need, and a browser on an https editor blocks the mixed-content request
|
|
63
|
+
* anyway, so it fails closed.
|
|
76
64
|
*
|
|
77
65
|
* Every spelling that READS as site-relative while redefining the authority — `/\host`,
|
|
78
|
-
* `\\host`, `\/host`, `///host` — is rejected
|
|
66
|
+
* `\\host`, `\/host`, `///host` — is rejected too. Those look site-relative
|
|
79
67
|
* to a human and to a naive `startsWith('/')` check, but WHATWG URL resolves each to a
|
|
80
68
|
* different authority (measured: `///x` resolves to host `x`, not to pathname `/x`), so they
|
|
81
69
|
* are never what an adopter meant. A scheme-qualified absolute URL is a separate case and is
|
|
82
70
|
* accepted — `https:///cdn.example.com/` is unusual but unambiguous, resolving identically
|
|
83
71
|
* standalone and against any base.
|
|
84
72
|
*/
|
|
85
|
-
export declare function isHttpUrlOrSameOriginPath(value: string
|
|
86
|
-
allowProtocolRelative?: boolean;
|
|
87
|
-
}): boolean;
|
|
73
|
+
export declare function isHttpUrlOrSameOriginPath(value: string): boolean;
|
|
88
74
|
/**
|
|
89
75
|
* Sanitize an untrusted URL for use in `href` attributes.
|
|
90
76
|
*
|
|
@@ -90,34 +90,22 @@ export function neutralizeImplicitOffOrigin(url) {
|
|
|
90
90
|
* this file either way, so the two surfaces cannot drift into disagreeing about which schemes
|
|
91
91
|
* are acceptable (that drift is exactly why `utils/url-prefix.ts` exists — see its header).
|
|
92
92
|
*
|
|
93
|
-
* `
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
* a working configuration. A bad value costs a broken `<img>`.
|
|
100
|
-
* - A WRITE-side endpoint (`media.uploadUrl`, where the browser POSTs a presigned upload) must
|
|
101
|
-
* not be protocol-relative even when spelled literally, because such a URL is AMBIGUOUS: it
|
|
102
|
-
* resolves to http or https depending on the scheme of whichever editor page happens to
|
|
103
|
-
* issue the upload, so the stored config does not determine where a live presigned credential
|
|
104
|
-
* and the user's file bytes are sent. That is the same principle every other rule here
|
|
105
|
-
* enforces — a config value must describe what actually happens.
|
|
106
|
-
*
|
|
107
|
-
* Note this is NOT an argument that http is forbidden on the write side; bare `http://` is
|
|
108
|
-
* accepted, because a loopback or in-cluster S3-compatible endpoint is a real need and a
|
|
109
|
-
* browser on an https editor blocks the mixed-content request anyway, so it fails closed. The
|
|
110
|
-
* objection to `//host` is that it is undetermined, not that it might be insecure.
|
|
93
|
+
* A protocol-relative `//host` is refused even when spelled literally, because it is AMBIGUOUS:
|
|
94
|
+
* it resolves to http or https depending on the page issuing the request, so the stored config
|
|
95
|
+
* does not determine where, for `media.uploadUrl`, a live presigned credential and the user's
|
|
96
|
+
* file bytes are sent. Bare `http://` is accepted: a loopback or in-cluster S3-compatible
|
|
97
|
+
* endpoint is a real need, and a browser on an https editor blocks the mixed-content request
|
|
98
|
+
* anyway, so it fails closed.
|
|
111
99
|
*
|
|
112
100
|
* Every spelling that READS as site-relative while redefining the authority — `/\host`,
|
|
113
|
-
* `\\host`, `\/host`, `///host` — is rejected
|
|
101
|
+
* `\\host`, `\/host`, `///host` — is rejected too. Those look site-relative
|
|
114
102
|
* to a human and to a naive `startsWith('/')` check, but WHATWG URL resolves each to a
|
|
115
103
|
* different authority (measured: `///x` resolves to host `x`, not to pathname `/x`), so they
|
|
116
104
|
* are never what an adopter meant. A scheme-qualified absolute URL is a separate case and is
|
|
117
105
|
* accepted — `https:///cdn.example.com/` is unusual but unambiguous, resolving identically
|
|
118
106
|
* standalone and against any base.
|
|
119
107
|
*/
|
|
120
|
-
export function isHttpUrlOrSameOriginPath(value
|
|
108
|
+
export function isHttpUrlOrSameOriginPath(value) {
|
|
121
109
|
if (value === '')
|
|
122
110
|
return false;
|
|
123
111
|
// Reject ASCII control characters and spaces ANYWHERE in the value, not only at the edges.
|
|
@@ -132,7 +120,7 @@ export function isHttpUrlOrSameOriginPath(value, opts = {}) {
|
|
|
132
120
|
}
|
|
133
121
|
// A query or fragment is meaningless on both sides this predicate serves. S3's POST Object
|
|
134
122
|
// takes no query parameters and a browser never transmits a fragment; and on the read side a
|
|
135
|
-
// prefix carrying either produces `https://cdn.example.com/?x=1
|
|
123
|
+
// prefix carrying either produces `https://cdn.example.com/?x=1/…` once joined.
|
|
136
124
|
if (value.includes('?') || value.includes('#'))
|
|
137
125
|
return false;
|
|
138
126
|
// A backslash is never legitimate in a configured URL, and it is not merely cosmetic: WHATWG
|
|
@@ -169,13 +157,8 @@ export function isHttpUrlOrSameOriginPath(value, opts = {}) {
|
|
|
169
157
|
return false;
|
|
170
158
|
}
|
|
171
159
|
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
// off-origin" and also start with `//`, but resolve to a host named `x` rather than to a
|
|
175
|
-
// path — so they must not ride in on the opt-in.
|
|
176
|
-
if (isImplicitlyOffOrigin(value)) {
|
|
177
|
-
return opts.allowProtocolRelative === true && value.startsWith('//') && value[2] !== '/';
|
|
178
|
-
}
|
|
160
|
+
if (isImplicitlyOffOrigin(value))
|
|
161
|
+
return false;
|
|
179
162
|
// Site-relative, with exactly one leading slash. The second clause also covers `//`, which
|
|
180
163
|
// `isImplicitlyOffOrigin` reports as false only because `new URL('//', base)` throws.
|
|
181
164
|
return value.startsWith('/') && value[1] !== '/';
|
|
@@ -119,3 +119,33 @@ export declare function sanitizeUnprefixedPath(url: string): string;
|
|
|
119
119
|
* neutralizing it would silently rewrite a legitimate protocol-relative CDN base into a path.
|
|
120
120
|
*/
|
|
121
121
|
export declare function joinUrlPrefix(prefix: string | undefined, path: string): string;
|
|
122
|
+
/**
|
|
123
|
+
* Append a trailing slash to a site-relative path, matching a site that serves `/contact/`.
|
|
124
|
+
*
|
|
125
|
+
* Leaves the root (`/`) and file-like paths (a last segment containing a dot, e.g.
|
|
126
|
+
* `/blog/rss.xml`) alone, and never doubles an existing slash. So it never produces a URL that
|
|
127
|
+
* Next's `trailingSlash: true` redirects (`next/dist/lib/load-custom-routes.js:489,502` in
|
|
128
|
+
* 15.5.21): Next adds a slash only to a last segment with no dot, and strips one from a segment
|
|
129
|
+
* ending `.ext`.
|
|
130
|
+
*
|
|
131
|
+
* A query string and/or fragment (`?page=2`, `#section`) is split off BEFORE the slash decision
|
|
132
|
+
* and placement, then reattached after — so `/blog?page=2` becomes `/blog/?page=2`, never
|
|
133
|
+
* `/blog?page=2/` (a literal trailing slash inside the query string, which is not what "serve
|
|
134
|
+
* with a trailing slash" means and breaks the URL).
|
|
135
|
+
*/
|
|
136
|
+
export declare function withTrailingSlash(path: string): string;
|
|
137
|
+
/**
|
|
138
|
+
* Give a URL's path the trailing-slash form a Next host serves, so loading it draws no 308:
|
|
139
|
+
* `withTrailingSlash`'s rule when `trailingSlash` is true, else no trailing slash on any path
|
|
140
|
+
* but the root (Next also redirects `<basePath>/` to `<basePath>`, `load-custom-routes.js:528`). An
|
|
141
|
+
* absolute URL's origin, and any query or fragment, are kept as they are.
|
|
142
|
+
*/
|
|
143
|
+
export declare function matchTrailingSlash(url: string, trailingSlash: boolean): string;
|
|
144
|
+
/**
|
|
145
|
+
* Whether the host is built with Next's `trailingSlash: true`. `withCanopy` sets
|
|
146
|
+
* `CANOPY_TRAILING_SLASH` in Next's `env` config, which Next substitutes for this literal member
|
|
147
|
+
* expression in server and browser bundles (`getNextConfigEnv`, `next/dist/build/define-env.js:54`).
|
|
148
|
+
* The try/catch covers a host whose bundler neither substitutes it nor shims `process` in the
|
|
149
|
+
* browser.
|
|
150
|
+
*/
|
|
151
|
+
export declare function readTrailingSlashEnv(): boolean;
|
package/dist/utils/url-prefix.js
CHANGED
|
@@ -156,3 +156,64 @@ export function joinUrlPrefix(prefix, path) {
|
|
|
156
156
|
: `/${trimmedPrefix}`;
|
|
157
157
|
return `${normalizedPrefix}${normalizedPath}`;
|
|
158
158
|
}
|
|
159
|
+
/**
|
|
160
|
+
* Append a trailing slash to a site-relative path, matching a site that serves `/contact/`.
|
|
161
|
+
*
|
|
162
|
+
* Leaves the root (`/`) and file-like paths (a last segment containing a dot, e.g.
|
|
163
|
+
* `/blog/rss.xml`) alone, and never doubles an existing slash. So it never produces a URL that
|
|
164
|
+
* Next's `trailingSlash: true` redirects (`next/dist/lib/load-custom-routes.js:489,502` in
|
|
165
|
+
* 15.5.21): Next adds a slash only to a last segment with no dot, and strips one from a segment
|
|
166
|
+
* ending `.ext`.
|
|
167
|
+
*
|
|
168
|
+
* A query string and/or fragment (`?page=2`, `#section`) is split off BEFORE the slash decision
|
|
169
|
+
* and placement, then reattached after — so `/blog?page=2` becomes `/blog/?page=2`, never
|
|
170
|
+
* `/blog?page=2/` (a literal trailing slash inside the query string, which is not what "serve
|
|
171
|
+
* with a trailing slash" means and breaks the URL).
|
|
172
|
+
*/
|
|
173
|
+
export function withTrailingSlash(path) {
|
|
174
|
+
const splitIndex = path.search(/[?#]/);
|
|
175
|
+
const base = splitIndex === -1 ? path : path.slice(0, splitIndex);
|
|
176
|
+
const suffix = splitIndex === -1 ? '' : path.slice(splitIndex);
|
|
177
|
+
const withLeading = base.startsWith('/') ? base : `/${base}`;
|
|
178
|
+
if (withLeading === '/' || withLeading.endsWith('/'))
|
|
179
|
+
return withLeading + suffix;
|
|
180
|
+
const lastSegment = withLeading.slice(withLeading.lastIndexOf('/') + 1);
|
|
181
|
+
if (lastSegment.includes('.'))
|
|
182
|
+
return withLeading + suffix;
|
|
183
|
+
return `${withLeading}/${suffix}`;
|
|
184
|
+
}
|
|
185
|
+
// Linear: `[^/?#]*` cannot overlap the literal `//` before it, so there is one way to match.
|
|
186
|
+
// eslint-disable-next-line security/detect-unsafe-regex
|
|
187
|
+
const URL_ORIGIN = /^(?:[a-z][a-z0-9+.-]*:)?\/\/[^/?#]*/i;
|
|
188
|
+
/**
|
|
189
|
+
* Give a URL's path the trailing-slash form a Next host serves, so loading it draws no 308:
|
|
190
|
+
* `withTrailingSlash`'s rule when `trailingSlash` is true, else no trailing slash on any path
|
|
191
|
+
* but the root (Next also redirects `<basePath>/` to `<basePath>`, `load-custom-routes.js:528`). An
|
|
192
|
+
* absolute URL's origin, and any query or fragment, are kept as they are.
|
|
193
|
+
*/
|
|
194
|
+
export function matchTrailingSlash(url, trailingSlash) {
|
|
195
|
+
const origin = isAbsoluteUrl(url) ? (URL_ORIGIN.exec(url)?.[0] ?? '') : '';
|
|
196
|
+
const rest = url.slice(origin.length);
|
|
197
|
+
if (trailingSlash)
|
|
198
|
+
return origin + withTrailingSlash(rest);
|
|
199
|
+
const splitIndex = rest.search(/[?#]/);
|
|
200
|
+
const base = splitIndex === -1 ? rest : rest.slice(0, splitIndex);
|
|
201
|
+
const suffix = splitIndex === -1 ? '' : rest.slice(splitIndex);
|
|
202
|
+
const trimmed = stripTrailingSlashes(base);
|
|
203
|
+
return origin + (trimmed || (origin ? '' : '/')) + suffix;
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Whether the host is built with Next's `trailingSlash: true`. `withCanopy` sets
|
|
207
|
+
* `CANOPY_TRAILING_SLASH` in Next's `env` config, which Next substitutes for this literal member
|
|
208
|
+
* expression in server and browser bundles (`getNextConfigEnv`, `next/dist/build/define-env.js:54`).
|
|
209
|
+
* The try/catch covers a host whose bundler neither substitutes it nor shims `process` in the
|
|
210
|
+
* browser.
|
|
211
|
+
*/
|
|
212
|
+
export function readTrailingSlashEnv() {
|
|
213
|
+
try {
|
|
214
|
+
return process.env.CANOPY_TRAILING_SLASH === 'true';
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Print a reconciled YAML document by editing the source text it was parsed from, so the bytes
|
|
3
|
+
* the reconciler did not change stay as the author wrote them.
|
|
4
|
+
*
|
|
5
|
+
* `Document.toString()` re-emits every scalar from its VALUE: folded and long plain scalars are
|
|
6
|
+
* re-wrapped at the library's line width, so the first CMS save of a hand-written file rewrites
|
|
7
|
+
* lines nobody edited. Here a clean subtree is copied verbatim from the source, and only a slot
|
|
8
|
+
* the reconciler touched is re-rendered, at the column it already occupies:
|
|
9
|
+
*
|
|
10
|
+
* - **Map**: the reconciler keeps retained pairs in source order and appends new ones, so a map
|
|
11
|
+
* becomes a list of local edits — delete a dropped pair's lines, re-render a pair whose value
|
|
12
|
+
* was replaced, recurse into a value edited in place, insert new pairs after the last item.
|
|
13
|
+
* - **Sequence**: items can be reordered, so the item region is rebuilt as a concatenation of
|
|
14
|
+
* per-item chunks, each one either an original item's source (recursively patched) or a freshly
|
|
15
|
+
* rendered item.
|
|
16
|
+
*
|
|
17
|
+
* A chunk is an item's lines plus the comment and blank lines above it, which is where `yaml`
|
|
18
|
+
* attaches those comments (the item's `commentBefore`), so a chunk moves and dies with exactly
|
|
19
|
+
* the comments the reconciler moves and drops. Above a nested collection's FIRST item they
|
|
20
|
+
* belong to the collection instead, and stay put; above the root map's first key they are the
|
|
21
|
+
* key's (`ownLeadingLines`).
|
|
22
|
+
*
|
|
23
|
+
* Anything this does not handle returns `undefined` from that collection, and the parent
|
|
24
|
+
* re-renders the whole slot instead; only at the root does that reach the caller, which falls
|
|
25
|
+
* back to the reconciled document as `yaml` prints it. Every splice is also re-parsed and
|
|
26
|
+
* checked ({@link printsAs}) before it is returned, so a wrong splice costs formatting, never
|
|
27
|
+
* data.
|
|
28
|
+
*/
|
|
29
|
+
import { Document, YAMLMap, YAMLSeq } from 'yaml';
|
|
30
|
+
type Collection = YAMLMap<unknown, unknown> | YAMLSeq<unknown>;
|
|
31
|
+
/** One parsed item: a pair for a map, the item node for a sequence. */
|
|
32
|
+
interface SnapshotItem {
|
|
33
|
+
readonly item: unknown;
|
|
34
|
+
/** A map pair's value as parsed; the reconciler reassigns `pair.value`. */
|
|
35
|
+
readonly value: unknown;
|
|
36
|
+
}
|
|
37
|
+
/** The parsed document's shape, taken before the reconciler runs. */
|
|
38
|
+
export interface SourceSnapshot {
|
|
39
|
+
/** Every collection's items exactly as parsed. */
|
|
40
|
+
readonly items: ReadonlyMap<Collection, readonly SnapshotItem[]>;
|
|
41
|
+
/** The source uses anchors or aliases; an edit through an alias is not local to one slot. */
|
|
42
|
+
readonly anchored: boolean;
|
|
43
|
+
}
|
|
44
|
+
export declare function snapshotDocument(doc: Document): SourceSnapshot;
|
|
45
|
+
/**
|
|
46
|
+
* `printed` (a `toString()`) written with `raw`'s line endings: CRLF when `raw` is consistently
|
|
47
|
+
* CRLF, else LF. Either way without the stray `\r` that comments parsed from CRLF keep, so a
|
|
48
|
+
* whole-file re-print does not leave the file with mixed line endings.
|
|
49
|
+
*/
|
|
50
|
+
export declare function withSourceLineEndings(printed: string, raw: string): string;
|
|
51
|
+
/**
|
|
52
|
+
* The source text of `raw` with only the reconciler's changes applied, or undefined when the
|
|
53
|
+
* document holds a construct this does not splice or the splice fails its self-check.
|
|
54
|
+
* `replaced` maps each node the reconciler created for an existing slot back to the node it
|
|
55
|
+
* replaced; `printed` is the reconciled document's `toString()`.
|
|
56
|
+
*/
|
|
57
|
+
export declare function spliceSource(raw: string, doc: Document, snapshot: SourceSnapshot, replaced: WeakMap<object, unknown>, printed: string): string | undefined;
|
|
58
|
+
export {};
|