@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.
Files changed (49) hide show
  1. package/README.md +181 -16
  2. package/examples/riverbend-wellness/.taproot-site-media.json +23 -0
  3. package/examples/riverbend-wellness/pages/index.md +1 -17
  4. package/package.json +2 -2
  5. package/src/api.js +123 -1
  6. package/src/authors-contract.js +170 -0
  7. package/src/cli.js +355 -30
  8. package/src/constants.js +9 -0
  9. package/src/content/components.js +64 -6
  10. package/src/content/free-form-sections.js +21 -4
  11. package/src/content/link-warnings.js +109 -0
  12. package/src/content/markdown.js +161 -26
  13. package/src/content/tiptap-prosemirror.ts +66 -8
  14. package/src/content/validate-document.js +30 -6
  15. package/src/content/vocabulary.js +26 -0
  16. package/src/errors.js +22 -0
  17. package/src/espalier-shared.js +11 -0
  18. package/src/fixture-init.js +55 -3
  19. package/src/font-catalog.js +87 -0
  20. package/src/footer-contract.js +1 -1
  21. package/src/output.js +19 -1
  22. package/src/page-metadata.js +92 -0
  23. package/src/presentation-reference.js +21 -7
  24. package/src/problems.js +116 -0
  25. package/src/reference-help.js +157 -41
  26. package/src/session.js +13 -0
  27. package/src/staging-check.js +62 -0
  28. package/src/theme-proposals.js +393 -0
  29. package/src/theme-validation.js +31 -10
  30. package/src/typed-page-reference.js +97 -21
  31. package/src/typed-pages.js +196 -13
  32. package/src/verbs/approve.js +63 -14
  33. package/src/verbs/authors.js +167 -0
  34. package/src/verbs/deploy.js +5 -28
  35. package/src/verbs/index.js +22 -0
  36. package/src/verbs/media-upload.js +127 -30
  37. package/src/verbs/nav-push.js +7 -0
  38. package/src/verbs/pages-meta-set.js +303 -0
  39. package/src/verbs/pages-push.js +814 -224
  40. package/src/verbs/places.js +61 -0
  41. package/src/verbs/plan.js +595 -0
  42. package/src/verbs/pull.js +255 -20
  43. package/src/verbs/redirects-check.js +4 -10
  44. package/src/verbs/theme-push.js +68 -36
  45. package/src/verbs/validate.js +309 -217
  46. package/src/video-prepare.js +48 -12
  47. package/src/workspace-check.js +199 -0
  48. package/src/workspace-ledger.js +173 -0
  49. package/src/workspace.js +105 -10
@@ -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 video = await input.getPrimaryVideoTrack();
66
- const audio = await input.getPrimaryAudioTrack();
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
+ }