@taprootio/site-authoring 0.14.1 → 0.16.0
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 +181 -16
- package/examples/riverbend-wellness/.taproot-site-media.json +23 -0
- package/examples/riverbend-wellness/pages/index.md +1 -17
- package/package.json +2 -2
- package/src/api.js +123 -1
- package/src/authors-contract.js +170 -0
- package/src/cli.js +355 -30
- package/src/constants.js +9 -0
- package/src/content/components.js +64 -6
- package/src/content/free-form-sections.js +21 -4
- package/src/content/link-warnings.js +109 -0
- package/src/content/markdown.js +161 -26
- package/src/content/tiptap-prosemirror.ts +66 -8
- package/src/content/validate-document.js +30 -6
- package/src/content/vocabulary.js +26 -0
- package/src/errors.js +22 -0
- package/src/espalier-shared.js +11 -0
- package/src/fixture-init.js +55 -3
- package/src/font-catalog.js +87 -0
- package/src/footer-contract.js +1 -1
- package/src/output.js +19 -1
- package/src/page-metadata.js +92 -0
- package/src/presentation-reference.js +21 -7
- package/src/problems.js +116 -0
- package/src/reference-help.js +157 -41
- package/src/session.js +13 -0
- package/src/staging-check.js +62 -0
- package/src/theme-proposals.js +393 -0
- package/src/theme-validation.js +31 -10
- package/src/typed-page-reference.js +97 -21
- package/src/typed-pages.js +196 -13
- package/src/verbs/approve.js +63 -14
- package/src/verbs/authors.js +167 -0
- package/src/verbs/deploy.js +5 -28
- package/src/verbs/index.js +22 -0
- package/src/verbs/media-upload.js +127 -30
- package/src/verbs/nav-push.js +7 -0
- package/src/verbs/pages-meta-set.js +303 -0
- package/src/verbs/pages-push.js +814 -224
- package/src/verbs/places.js +61 -0
- package/src/verbs/plan.js +595 -0
- package/src/verbs/pull.js +255 -20
- package/src/verbs/redirects-check.js +4 -10
- package/src/verbs/theme-push.js +68 -36
- package/src/verbs/validate.js +309 -217
- package/src/video-prepare.js +48 -12
- package/src/workspace-check.js +199 -0
- package/src/workspace-ledger.js +173 -0
- package/src/workspace.js +105 -10
package/src/video-prepare.js
CHANGED
|
@@ -62,19 +62,10 @@ export async function prepareVideoFile({ filePath, byteLength, maxRemuxBytes = D
|
|
|
62
62
|
const input = new Input({ source: new FilePathSource(filePath), formats: ALL_FORMATS });
|
|
63
63
|
try {
|
|
64
64
|
const format = await input.getFormat();
|
|
65
|
-
const
|
|
66
|
-
const audio =
|
|
67
|
-
// The server refuses a file if any track is not H.264 or AAC, so the first track that is
|
|
68
|
-
// not is the one declared and named. A track whose codec mediabunny cannot name is
|
|
69
|
-
// "unknown", not absent: an absent audio track is fine, a silent copy of one that exists is not.
|
|
70
|
-
const videoCodec = await declaredCodec(await input.getVideoTracks(), isH264);
|
|
71
|
-
const audioCodec = await declaredCodec(await input.getAudioTracks(), isAac);
|
|
65
|
+
const tracks = await inspectTracks(input);
|
|
66
|
+
const { video, audio, videoCodec, audioCodec, primaryVideo, primaryAudio } = tracks;
|
|
72
67
|
const described = { videoCodec, audioCodec, remuxed: null };
|
|
73
|
-
|
|
74
|
-
// A remux keeps only the primary tracks, so they are what must be accepted for it to help.
|
|
75
|
-
const primaryVideo = video ? await codecOf(video) : "";
|
|
76
|
-
const primaryAudio = audio ? await codecOf(audio) : "";
|
|
77
|
-
if (!isH264(primaryVideo) || !isAac(primaryAudio)) return described;
|
|
68
|
+
if (!primariesAccepted(tracks)) return described;
|
|
78
69
|
|
|
79
70
|
const everyTrackAccepted = isH264(videoCodec) && isAac(audioCodec);
|
|
80
71
|
if (everyTrackAccepted && format.mimeType === "video/mp4" && await indexComesFirst(filePath)) return described;
|
|
@@ -97,6 +88,51 @@ export async function prepareVideoFile({ filePath, byteLength, maxRemuxBytes = D
|
|
|
97
88
|
}
|
|
98
89
|
}
|
|
99
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Whether the upload would send this file in a form the server accepts (TR00823): H.264
|
|
93
|
+
* and AAC as it is, or primaries a remux can keep within the ceiling. Read only; `plan`
|
|
94
|
+
* uses it so an unsupported video blocks before anything is uploaded. A remux that fails
|
|
95
|
+
* at upload time is still refused then.
|
|
96
|
+
*/
|
|
97
|
+
export async function videoAcceptance({ filePath, byteLength, maxRemuxBytes = DEFAULT_MAX_REMUX_BYTES }) {
|
|
98
|
+
const input = new Input({ source: new FilePathSource(filePath), formats: ALL_FORMATS });
|
|
99
|
+
try {
|
|
100
|
+
const tracks = await inspectTracks(input);
|
|
101
|
+
const { videoCodec, audioCodec } = tracks;
|
|
102
|
+
const asItIs = isH264(videoCodec) && isAac(audioCodec);
|
|
103
|
+
const remuxable = primariesAccepted(tracks) && byteLength <= maxRemuxBytes;
|
|
104
|
+
return { accepted: asItIs || remuxable, videoCodec, audioCodec };
|
|
105
|
+
} catch {
|
|
106
|
+
return { accepted: false, videoCodec: "", audioCodec: "" };
|
|
107
|
+
} finally {
|
|
108
|
+
input.dispose();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The server refuses a file if any track is not H.264 or AAC, so the first track that is
|
|
114
|
+
* not is the one declared and named. A track whose codec mediabunny cannot name is
|
|
115
|
+
* "unknown", not absent: an absent audio track is fine, a silent copy of one that exists is
|
|
116
|
+
* not. A remux keeps only the primary tracks, so they are what must be accepted for it to help.
|
|
117
|
+
*/
|
|
118
|
+
async function inspectTracks(input) {
|
|
119
|
+
const video = await input.getPrimaryVideoTrack();
|
|
120
|
+
const audio = await input.getPrimaryAudioTrack();
|
|
121
|
+
return {
|
|
122
|
+
video,
|
|
123
|
+
audio,
|
|
124
|
+
videoCodec: await declaredCodec(await input.getVideoTracks(), isH264),
|
|
125
|
+
audioCodec: await declaredCodec(await input.getAudioTracks(), isAac),
|
|
126
|
+
primaryVideo: video ? await codecOf(video) : "",
|
|
127
|
+
primaryAudio: audio ? await codecOf(audio) : "",
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Whether a remux, which keeps only the primary tracks, would produce a file the server accepts. */
|
|
132
|
+
function primariesAccepted({ primaryVideo, primaryAudio }) {
|
|
133
|
+
return isH264(primaryVideo) && isAac(primaryAudio);
|
|
134
|
+
}
|
|
135
|
+
|
|
100
136
|
async function codecOf(track) {
|
|
101
137
|
return (await track.getCodecParameterString()) ?? "unknown";
|
|
102
138
|
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { sharedThemeContextNames } from "./content/free-form-sections.js";
|
|
2
|
+
import { readAppearanceWorkspaceContext, FOOTER_SETTINGS_FILE } from "./footer-workspace.js";
|
|
3
|
+
import { readWorkspaceForms } from "./forms-workspace.js";
|
|
4
|
+
import { CHECK_AREA, collectProblems, problemsOf } from "./problems.js";
|
|
5
|
+
import { normalizeRedirectPath, REDIRECTS_FILE_NAME, validateRedirectsDocument } from "./redirects-contract.js";
|
|
6
|
+
import { SETTINGS_TYPE_SITE_PUBLISHING_PREFERENCES } from "./settings-catalog.js";
|
|
7
|
+
import { validateFooterWorkspaceDocument } from "./verbs/footer-push.js";
|
|
8
|
+
import { validateNavigationWorkspaceDocument } from "./verbs/nav-push.js";
|
|
9
|
+
import { planPages } from "./verbs/pages-push.js";
|
|
10
|
+
import { validateThemeWorkspace } from "./verbs/theme-push.js";
|
|
11
|
+
import {
|
|
12
|
+
NAVIGATION_FILE_NAME,
|
|
13
|
+
normalizePagePath,
|
|
14
|
+
PAGE_SOURCE_EXTENSIONS,
|
|
15
|
+
PAGES_DIRECTORY,
|
|
16
|
+
readWorkspaceJson,
|
|
17
|
+
walkWorkspaceFiles,
|
|
18
|
+
WORKSPACE_LIMITS,
|
|
19
|
+
workspaceFileExists,
|
|
20
|
+
} from "./workspace.js";
|
|
21
|
+
import { SiteAuthoringError } from "./errors.js";
|
|
22
|
+
|
|
23
|
+
const ALL_AREAS = new Set(Object.values(CHECK_AREA));
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The one validator for a whole authoring workspace (TR01002, TR00823).
|
|
27
|
+
*
|
|
28
|
+
* `validate` runs it over a pulled workspace or an offline fixture, and `plan`
|
|
29
|
+
* runs it before reading the site. Each area is checked by the function its
|
|
30
|
+
* push uses — `planPages` for pages, `validateThemeWorkspace` for the theme —
|
|
31
|
+
* so a workspace that passes here is not refused for its shape by a push.
|
|
32
|
+
*
|
|
33
|
+
* Every area is checked, and every page, whatever an earlier one found: the
|
|
34
|
+
* result lists all the problems, and no area's refusal hides another's.
|
|
35
|
+
*
|
|
36
|
+
* `binding` says what the workspace's references may point at. A pulled
|
|
37
|
+
* workspace binds the images and pages its manifests record (or, for `plan`,
|
|
38
|
+
* the site's live pages); a fixture binds the identities its own manifest
|
|
39
|
+
* declares.
|
|
40
|
+
*/
|
|
41
|
+
export async function checkWorkspace({
|
|
42
|
+
workspaceDir,
|
|
43
|
+
siteId,
|
|
44
|
+
manifest,
|
|
45
|
+
mediaManifest,
|
|
46
|
+
binding = {},
|
|
47
|
+
content,
|
|
48
|
+
areas = ALL_AREAS,
|
|
49
|
+
onProgress = () => {},
|
|
50
|
+
}) {
|
|
51
|
+
const problems = [];
|
|
52
|
+
// A managed Docs site's workspace holds its settings only (TR00790).
|
|
53
|
+
const settingsOnly = manifest.authoringSurface !== undefined;
|
|
54
|
+
const pageIds = binding.pageIds
|
|
55
|
+
?? new Set(manifest.pages.map((entry) => entry?.pageId).filter((value) => typeof value === "string"));
|
|
56
|
+
const resourceIds = binding.resourceIds
|
|
57
|
+
?? new Set(manifest.pages.map((entry) => entry?.resourceId).filter((value) => typeof value === "string"));
|
|
58
|
+
// Navigation may be judged against other pages than the footer: `plan`
|
|
59
|
+
// checks it against the site's live pages, which is what nav push does,
|
|
60
|
+
// while footer push resolves its targets from the pull manifest.
|
|
61
|
+
const navigationPages = binding.navigationPages ?? { pageIds, resourceIds };
|
|
62
|
+
const knownImageIds = binding.knownImageIds
|
|
63
|
+
?? await collectProblems(problems, { area: CHECK_AREA.presentation }, async () =>
|
|
64
|
+
(await readAppearanceWorkspaceContext(workspaceDir, siteId)).knownImageIds);
|
|
65
|
+
|
|
66
|
+
onProgress("Validating the complete light/dark theme, appearance, header, brand, and footer colors.");
|
|
67
|
+
const presentation = knownImageIds === undefined
|
|
68
|
+
? undefined
|
|
69
|
+
: await collectProblems(problems, { area: CHECK_AREA.presentation }, async () =>
|
|
70
|
+
await validateThemeWorkspace(workspaceDir, siteId, knownImageIds, binding.settingsEntityIds));
|
|
71
|
+
|
|
72
|
+
onProgress("Validating the closed footer document and its page and image targets.");
|
|
73
|
+
const footer = knownImageIds === undefined
|
|
74
|
+
? undefined
|
|
75
|
+
: await collectProblems(problems, { area: CHECK_AREA.footer, file: FOOTER_SETTINGS_FILE }, async () =>
|
|
76
|
+
validateFooterWorkspaceDocument(
|
|
77
|
+
await readWorkspaceJson(workspaceDir, FOOTER_SETTINGS_FILE, WORKSPACE_LIMITS.settingsBytes),
|
|
78
|
+
siteId,
|
|
79
|
+
{
|
|
80
|
+
expectedEntityId: binding.settingsEntityIds?.get(SETTINGS_TYPE_SITE_PUBLISHING_PREFERENCES),
|
|
81
|
+
knownPageResourceIds: resourceIds,
|
|
82
|
+
knownImageIds,
|
|
83
|
+
},
|
|
84
|
+
));
|
|
85
|
+
|
|
86
|
+
if (settingsOnly) return { problems, settingsOnly, presentation, footer };
|
|
87
|
+
|
|
88
|
+
onProgress("Validating navigation shape and its page targets.");
|
|
89
|
+
const navigation = await collectProblems(problems, { area: CHECK_AREA.navigation, file: NAVIGATION_FILE_NAME }, async () =>
|
|
90
|
+
validateNavigationWorkspaceDocument(
|
|
91
|
+
await readWorkspaceJson(workspaceDir, NAVIGATION_FILE_NAME, WORKSPACE_LIMITS.navigationBytes),
|
|
92
|
+
siteId,
|
|
93
|
+
navigationPages,
|
|
94
|
+
));
|
|
95
|
+
|
|
96
|
+
// `plan` checks pages against the live site instead (TR00823).
|
|
97
|
+
const checkPages = areas.has(CHECK_AREA.pages);
|
|
98
|
+
if (checkPages) onProgress("Validating every page source.");
|
|
99
|
+
const pages = !checkPages ? undefined : await collectProblems(problems, { area: CHECK_AREA.pages }, async () =>
|
|
100
|
+
await planPages({
|
|
101
|
+
workspaceDir,
|
|
102
|
+
siteId,
|
|
103
|
+
manifest,
|
|
104
|
+
mediaManifest,
|
|
105
|
+
content,
|
|
106
|
+
files: await walkWorkspaceFiles(workspaceDir, PAGES_DIRECTORY, PAGE_SOURCE_EXTENSIONS),
|
|
107
|
+
// The pages the manifest records are what the site held when it was
|
|
108
|
+
// pulled, which is what an offline check can know.
|
|
109
|
+
livePages: manifest.pages.filter((entry) => typeof entry?.pageId === "string"),
|
|
110
|
+
online: false,
|
|
111
|
+
// The theme was validated above, so its contexts come from it; reading
|
|
112
|
+
// the styles file again would re-check a binding a fixture spells
|
|
113
|
+
// differently, and repeat a theme problem once per page.
|
|
114
|
+
getSharedThemeContexts: async () =>
|
|
115
|
+
presentation === undefined
|
|
116
|
+
? undefined
|
|
117
|
+
: sharedThemeContextNames(presentation.style.lightTheme, presentation.style.darkTheme),
|
|
118
|
+
onProgress,
|
|
119
|
+
}));
|
|
120
|
+
if (pages !== undefined) problems.push(...pages.problems);
|
|
121
|
+
|
|
122
|
+
// `plan` leaves redirects and forms to their own pushes.
|
|
123
|
+
if (areas.has(CHECK_AREA.redirects)) {
|
|
124
|
+
onProgress("Validating the redirect map: paths, targets, statuses, chains, loops, and page paths.");
|
|
125
|
+
}
|
|
126
|
+
const redirects = !areas.has(CHECK_AREA.redirects)
|
|
127
|
+
? undefined
|
|
128
|
+
: await collectProblems(problems, { area: CHECK_AREA.redirects, file: REDIRECTS_FILE_NAME }, async () =>
|
|
129
|
+
await checkRedirects(workspaceDir, siteId, manifest, pages?.sources));
|
|
130
|
+
|
|
131
|
+
const forms = !areas.has(CHECK_AREA.forms)
|
|
132
|
+
? undefined
|
|
133
|
+
: await collectProblems(problems, { area: CHECK_AREA.forms }, async () => await readWorkspaceForms(workspaceDir));
|
|
134
|
+
if (forms?.length > 0) {
|
|
135
|
+
onProgress(`Validated ${forms.length} form file${forms.length === 1 ? "" : "s"} against the shared field schema.`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return { problems, settingsOnly, presentation, footer, navigation, pages, redirects, forms };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The redirect map's own rules, and the one rule the workspace can check
|
|
143
|
+
* against itself: a source a workspace page occupies is refused by the site.
|
|
144
|
+
* Paths compare case-insensitively, as the site's citext path column does,
|
|
145
|
+
* and a refusal names the entry where the author wrote it: the validated map
|
|
146
|
+
* is sorted by path, so its index is not the document's.
|
|
147
|
+
*/
|
|
148
|
+
async function checkRedirects(workspaceDir, siteId, manifest, sources) {
|
|
149
|
+
if (!await workspaceFileExists(workspaceDir, REDIRECTS_FILE_NAME)) {
|
|
150
|
+
// A workspace pulled before redirects existed holds no map, and nothing in
|
|
151
|
+
// it asks for one.
|
|
152
|
+
if (manifest.redirects === undefined) return undefined;
|
|
153
|
+
throw new SiteAuthoringError(
|
|
154
|
+
"redirects.file_missing",
|
|
155
|
+
`No ${REDIRECTS_FILE_NAME} was found, but the pull manifest records a redirect baseline. Run 'taproot-site `
|
|
156
|
+
+ "redirects pull' again.",
|
|
157
|
+
{ field: REDIRECTS_FILE_NAME },
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
const document_ = await readWorkspaceJson(workspaceDir, REDIRECTS_FILE_NAME, WORKSPACE_LIMITS.redirectsBytes);
|
|
161
|
+
const redirects = validateRedirectsDocument(document_, siteId);
|
|
162
|
+
// The pages the next push leaves on the site: each workspace source at the
|
|
163
|
+
// path it declares, and every recorded page no source here claims. A page a
|
|
164
|
+
// source renames frees its old path for a redirect.
|
|
165
|
+
const claimed = new Set((sources ?? []).map((source) => source.file));
|
|
166
|
+
const occupied = [
|
|
167
|
+
...(sources ?? []).map((source) => source.pagePath),
|
|
168
|
+
...manifest.pages
|
|
169
|
+
.filter((entry) => typeof entry?.path === "string" && !claimed.has(entry.file))
|
|
170
|
+
.map((entry) => normalizePagePath(entry.path)),
|
|
171
|
+
];
|
|
172
|
+
const pagePaths = new Set(
|
|
173
|
+
occupied.filter((pagePath) => pagePath !== undefined)
|
|
174
|
+
.map((pagePath) => (`/${pagePath}`.replace(/\/+$/u, "") || "/").toLowerCase()),
|
|
175
|
+
);
|
|
176
|
+
const authoredIndexByPath = new Map();
|
|
177
|
+
for (const [index, entry] of document_.entries.entries()) {
|
|
178
|
+
const path = normalizeRedirectPath(entry?.path);
|
|
179
|
+
if (path !== undefined && !authoredIndexByPath.has(path.toLowerCase())) {
|
|
180
|
+
authoredIndexByPath.set(path.toLowerCase(), index);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
const problems = [];
|
|
184
|
+
for (const entry of redirects.entries) {
|
|
185
|
+
const key = entry.path.toLowerCase();
|
|
186
|
+
if (!pagePaths.has(key)) continue;
|
|
187
|
+
problems.push(...problemsOf(new SiteAuthoringError(
|
|
188
|
+
"redirects.path_occupied",
|
|
189
|
+
`Redirect source '${entry.path}' is a page in this workspace, so it cannot also be a redirect source.`,
|
|
190
|
+
{ field: `entries[${authoredIndexByPath.get(key) ?? 0}].path` },
|
|
191
|
+
), { area: CHECK_AREA.redirects, file: REDIRECTS_FILE_NAME }));
|
|
192
|
+
}
|
|
193
|
+
if (problems.length > 0) {
|
|
194
|
+
const [first] = problems;
|
|
195
|
+
const error = new SiteAuthoringError(first.code, first.message, { field: first.field });
|
|
196
|
+
throw problems.length === 1 ? error : error.withProblems(problems);
|
|
197
|
+
}
|
|
198
|
+
return redirects;
|
|
199
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
|
|
4
|
+
import { SiteAuthoringError } from "./errors.js";
|
|
5
|
+
import { planStale } from "./problems.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* What `plan` read from the workspace, so `apply` can send nothing else
|
|
9
|
+
* (TR00823).
|
|
10
|
+
*
|
|
11
|
+
* Every workspace read goes through `workspace.js`, which reports it here.
|
|
12
|
+
* While `plan` (or `apply`'s re-plan) runs, each read is recorded: a file's
|
|
13
|
+
* digest, a stream's size and modification time, whether a path exists, what a
|
|
14
|
+
* directory walk listed. While `apply`'s steps run, each read is compared with
|
|
15
|
+
* that record. A read the plan never made, or one whose answer changed, stops
|
|
16
|
+
* the step before it writes, because whatever it was about to send was never
|
|
17
|
+
* reviewed. A file that `apply` itself writes, such as the media manifest the
|
|
18
|
+
* upload step records, replaces its own entry, so the next step reads it as
|
|
19
|
+
* expected.
|
|
20
|
+
*
|
|
21
|
+
* The ledger guards the workspace only. What lives on the site (page
|
|
22
|
+
* revisions, the navigation tree) is checked by the steps against the plan.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const scope = new AsyncLocalStorage();
|
|
26
|
+
|
|
27
|
+
const MISSING = "missing";
|
|
28
|
+
|
|
29
|
+
export function digest(bytes) {
|
|
30
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Runs `action` while recording every workspace read; returns its result and the ledger. */
|
|
34
|
+
export async function recordWorkspaceReads(action) {
|
|
35
|
+
const ledger = { mode: "record", entries: new Map() };
|
|
36
|
+
const result = await scope.run(ledger, action);
|
|
37
|
+
return { result, ledger: Object.freeze({ entries: ledger.entries }) };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Runs `action` refusing any workspace read the ledger does not hold. */
|
|
41
|
+
export async function enforceWorkspaceReads(recorded, action) {
|
|
42
|
+
const ledger = { mode: "enforce", entries: new Map(recorded.entries), written: new Set(), deleted: new Set() };
|
|
43
|
+
return await scope.run(ledger, action);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A stable digest of everything the ledger recorded, for the plan hash. */
|
|
47
|
+
export function ledgerDigest(recorded) {
|
|
48
|
+
const entries = [...recorded.entries.entries()].sort(([left], [right]) => (left < right ? -1 : 1));
|
|
49
|
+
return digest(JSON.stringify(entries));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function stale(path, mode) {
|
|
53
|
+
// While planning, the plan itself is what moved; while applying, the step.
|
|
54
|
+
return mode === "record"
|
|
55
|
+
? new SiteAuthoringError(
|
|
56
|
+
"plan.workspace_changed",
|
|
57
|
+
`'${path}' changed while the plan was reading the workspace. Wait for whatever is writing it to finish, then `
|
|
58
|
+
+ "run 'taproot-site plan' again.",
|
|
59
|
+
{ field: path },
|
|
60
|
+
)
|
|
61
|
+
: planStale(`'${path}'`, path);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function key(kind, path) {
|
|
65
|
+
return `${kind}:${path}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* One read's answer. While recording, the first answer for a key is kept; a
|
|
70
|
+
* different later answer means the workspace moved while it was being planned.
|
|
71
|
+
* While enforcing, the answer must match what was recorded.
|
|
72
|
+
*/
|
|
73
|
+
function noteRead(kind, path, value) {
|
|
74
|
+
const ledger = scope.getStore();
|
|
75
|
+
if (ledger === undefined) return;
|
|
76
|
+
const entry = key(kind, path);
|
|
77
|
+
const recorded = ledger.entries.get(entry);
|
|
78
|
+
if (ledger.mode === "record") {
|
|
79
|
+
if (recorded === undefined) ledger.entries.set(entry, value);
|
|
80
|
+
else if (recorded !== value) throw stale(path, "record");
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (recorded === value) return;
|
|
84
|
+
if (recorded === undefined && impliedByRecordedRead(ledger, kind, path, value)) return;
|
|
85
|
+
throw stale(path, "enforce");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Whether the plan already answered this question another way. A file the plan
|
|
90
|
+
* read was a regular file, and one it found missing did not exist; a step that
|
|
91
|
+
* asks only whether it exists, or what kind of entry it is, learns nothing the
|
|
92
|
+
* plan did not see. The bytes themselves are still compared when the step reads
|
|
93
|
+
* them.
|
|
94
|
+
*/
|
|
95
|
+
function impliedByRecordedRead(ledger, kind, path, value) {
|
|
96
|
+
if (kind !== "exists" && kind !== "entry") return false;
|
|
97
|
+
const read = ledger.entries.get(key("file", path)) ?? ledger.entries.get(key("stream", path));
|
|
98
|
+
if (read === undefined) return false;
|
|
99
|
+
const present = read !== MISSING;
|
|
100
|
+
return kind === "exists" ? value === present : value === (present ? "file" : MISSING);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// The digests are computed only inside a ledger: every other verb reads
|
|
104
|
+
// without paying for them.
|
|
105
|
+
export function noteFileRead(path, bytes) {
|
|
106
|
+
if (scope.getStore() !== undefined) noteRead("file", path, digest(bytes));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function noteFileMissing(path) {
|
|
110
|
+
noteRead("file", path, MISSING);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function noteStreamOpened(path, byteLength, modifiedMilliseconds, header) {
|
|
114
|
+
if (scope.getStore() !== undefined) noteRead("stream", path, `${byteLength}:${modifiedMilliseconds}:${digest(header)}`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function noteStreamMissing(path) {
|
|
118
|
+
noteRead("stream", path, MISSING);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function noteExists(path, exists) {
|
|
122
|
+
noteRead("exists", path, exists);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function noteEntry(path, kind) {
|
|
126
|
+
noteRead("entry", path, kind);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* A directory walk. While enforcing, the listing must be the recorded one,
|
|
131
|
+
* plus the files this apply wrote under that root and minus those it removed.
|
|
132
|
+
*/
|
|
133
|
+
export function noteWalk(root, filter, files) {
|
|
134
|
+
const ledger = scope.getStore();
|
|
135
|
+
if (ledger === undefined) return;
|
|
136
|
+
const entry = key("walk", `${root}|${filter}`);
|
|
137
|
+
if (ledger.mode === "record") {
|
|
138
|
+
noteRead("walk", `${root}|${filter}`, JSON.stringify(files));
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const recorded = ledger.entries.get(entry);
|
|
142
|
+
if (recorded === undefined) throw stale(root, "enforce");
|
|
143
|
+
const expected = new Set(JSON.parse(recorded));
|
|
144
|
+
const prefix = `${root}/`;
|
|
145
|
+
for (const path of ledger.written) if (path.startsWith(prefix) && files.includes(path)) expected.add(path);
|
|
146
|
+
for (const path of ledger.deleted) if (path.startsWith(prefix)) expected.delete(path);
|
|
147
|
+
const listed = new Set(files);
|
|
148
|
+
// Name the first file that appeared or went missing, not just the directory.
|
|
149
|
+
const changed = files.find((path) => !expected.has(path)) ?? [...expected].sort().find((path) => !listed.has(path));
|
|
150
|
+
if (changed !== undefined) throw stale(changed, "enforce");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** A file this apply wrote: later reads of it must find exactly these bytes. */
|
|
154
|
+
export function noteFileWritten(path, contents) {
|
|
155
|
+
const ledger = scope.getStore();
|
|
156
|
+
if (ledger === undefined || ledger.mode !== "enforce") return;
|
|
157
|
+
ledger.entries.set(key("file", path), digest(contents));
|
|
158
|
+
ledger.entries.set(key("exists", path), true);
|
|
159
|
+
ledger.entries.set(key("entry", path), "file");
|
|
160
|
+
ledger.written.add(path);
|
|
161
|
+
ledger.deleted.delete(path);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** A file this apply removed. */
|
|
165
|
+
export function noteFileDeleted(path) {
|
|
166
|
+
const ledger = scope.getStore();
|
|
167
|
+
if (ledger === undefined || ledger.mode !== "enforce") return;
|
|
168
|
+
ledger.entries.set(key("file", path), MISSING);
|
|
169
|
+
ledger.entries.set(key("exists", path), false);
|
|
170
|
+
ledger.entries.set(key("entry", path), MISSING);
|
|
171
|
+
ledger.deleted.add(path);
|
|
172
|
+
ledger.written.delete(path);
|
|
173
|
+
}
|