@taprootio/site-authoring 0.14.0 → 0.15.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 (39) hide show
  1. package/README.md +62 -10
  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 +59 -1
  6. package/src/cli.js +147 -22
  7. package/src/constants.js +6 -0
  8. package/src/content/components.js +64 -6
  9. package/src/content/free-form-sections.js +21 -4
  10. package/src/content/markdown.js +161 -26
  11. package/src/content/tiptap-prosemirror.ts +66 -8
  12. package/src/content/validate-document.js +30 -6
  13. package/src/content/vocabulary.js +26 -0
  14. package/src/errors.js +22 -0
  15. package/src/espalier-shared.js +11 -0
  16. package/src/font-catalog.js +87 -0
  17. package/src/footer-contract.js +1 -1
  18. package/src/output.js +19 -1
  19. package/src/presentation-reference.js +21 -7
  20. package/src/problems.js +116 -0
  21. package/src/reference-help.js +100 -34
  22. package/src/session.js +13 -0
  23. package/src/theme-proposals.js +393 -0
  24. package/src/theme-validation.js +31 -10
  25. package/src/typed-page-reference.js +11 -3
  26. package/src/typed-pages.js +31 -9
  27. package/src/verbs/deploy.js +1 -0
  28. package/src/verbs/index.js +13 -0
  29. package/src/verbs/media-upload.js +127 -30
  30. package/src/verbs/nav-push.js +7 -0
  31. package/src/verbs/pages-push.js +406 -199
  32. package/src/verbs/places.js +61 -0
  33. package/src/verbs/plan.js +585 -0
  34. package/src/verbs/theme-push.js +68 -36
  35. package/src/verbs/validate.js +264 -217
  36. package/src/video-prepare.js +48 -12
  37. package/src/workspace-check.js +199 -0
  38. package/src/workspace-ledger.js +173 -0
  39. package/src/workspace.js +47 -4
@@ -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
+ }
package/src/workspace.js CHANGED
@@ -5,6 +5,17 @@ import path from "node:path";
5
5
 
6
6
  import { atomicWriteFile } from "./atomic-file.js";
7
7
  import { hasAsciiControl, sanitizeDiagnostic, SiteAuthoringError } from "./errors.js";
8
+ import {
9
+ noteEntry,
10
+ noteExists,
11
+ noteFileDeleted,
12
+ noteFileMissing,
13
+ noteFileRead,
14
+ noteFileWritten,
15
+ noteStreamMissing,
16
+ noteStreamOpened,
17
+ noteWalk,
18
+ } from "./workspace-ledger.js";
8
19
 
9
20
  /**
10
21
  * The local workspace: what `pull` writes, what the push verbs read back, and
@@ -713,6 +724,7 @@ export async function ensureWorkspaceDirectory(workspaceDir, relativeDirectory =
713
724
  export async function readWorkspaceFile(workspaceDir, relativePath, maximumBytes) {
714
725
  const filePath = resolveWorkspacePath(workspaceDir, relativePath);
715
726
  if (!await requireRealDirectoryChain(workspaceDir, relativePath)) {
727
+ noteFileMissing(relativePath);
716
728
  throw new SiteAuthoringError(
717
729
  "workspace.file_missing",
718
730
  `Workspace file '${relativePath}' does not exist.`,
@@ -744,10 +756,13 @@ export async function readWorkspaceFile(workspaceDir, relativePath, maximumBytes
744
756
  if (bytesRead === 0) break;
745
757
  total += bytesRead;
746
758
  }
747
- return buffer.subarray(0, total);
759
+ const contents = buffer.subarray(0, total);
760
+ noteFileRead(relativePath, contents);
761
+ return contents;
748
762
  } catch (error) {
749
763
  if (error instanceof SiteAuthoringError) throw error;
750
764
  if (error && typeof error === "object" && error.code === "ENOENT") {
765
+ noteFileMissing(relativePath);
751
766
  throw new SiteAuthoringError(
752
767
  "workspace.file_missing",
753
768
  `Workspace file '${relativePath}' does not exist.`,
@@ -778,6 +793,7 @@ const STREAM_HEADER_BYTES = 4096;
778
793
  export async function openWorkspaceFileStream(workspaceDir, relativePath, maximumBytes) {
779
794
  const filePath = resolveWorkspacePath(workspaceDir, relativePath);
780
795
  if (!await requireRealDirectoryChain(workspaceDir, relativePath)) {
796
+ noteStreamMissing(relativePath);
781
797
  throw new SiteAuthoringError(
782
798
  "workspace.file_missing",
783
799
  `Workspace file '${relativePath}' does not exist.`,
@@ -812,6 +828,7 @@ export async function openWorkspaceFileStream(workspaceDir, relativePath, maximu
812
828
  total += bytesRead;
813
829
  }
814
830
  const opened = handle;
831
+ noteStreamOpened(relativePath, byteLength, modifiedMilliseconds, header.subarray(0, total));
815
832
  return {
816
833
  byteLength,
817
834
  modifiedMilliseconds,
@@ -850,6 +867,7 @@ export async function openWorkspaceFileStream(workspaceDir, relativePath, maximu
850
867
  await handle?.close().catch(() => {});
851
868
  if (error instanceof SiteAuthoringError) throw error;
852
869
  if (error && typeof error === "object" && error.code === "ENOENT") {
870
+ noteStreamMissing(relativePath);
853
871
  throw new SiteAuthoringError(
854
872
  "workspace.file_missing",
855
873
  `Workspace file '${relativePath}' does not exist.`,
@@ -885,6 +903,12 @@ export async function readWorkspaceJson(workspaceDir, relativePath, maximumBytes
885
903
  * read outside the workspace.
886
904
  */
887
905
  export async function inspectWorkspaceEntry(workspaceDir, relativePath) {
906
+ const kind = await classifyWorkspaceEntry(workspaceDir, relativePath);
907
+ noteEntry(relativePath, kind);
908
+ return kind;
909
+ }
910
+
911
+ async function classifyWorkspaceEntry(workspaceDir, relativePath) {
888
912
  const target = resolveWorkspacePath(workspaceDir, relativePath);
889
913
  // The leaf `lstat` below refuses a linked final component; this refuses a
890
914
  // linked one anywhere above it, which is how `media/link/sub` would otherwise
@@ -929,6 +953,12 @@ export async function inspectWorkspaceEntry(workspaceDir, relativePath) {
929
953
  * missing.
930
954
  */
931
955
  export async function workspaceFileExists(workspaceDir, relativePath) {
956
+ const exists = await regularFileExists(workspaceDir, relativePath);
957
+ noteExists(relativePath, exists);
958
+ return exists;
959
+ }
960
+
961
+ async function regularFileExists(workspaceDir, relativePath) {
932
962
  const filePath = resolveWorkspacePath(workspaceDir, relativePath);
933
963
  if (!await requireRealDirectoryChain(workspaceDir, relativePath)) return false;
934
964
  let stats;
@@ -982,6 +1012,7 @@ export async function writeWorkspaceFile(workspaceDir, relativePath, contents, {
982
1012
  ),
983
1013
  },
984
1014
  });
1015
+ noteFileWritten(relativePath, contents);
985
1016
  return relativePath;
986
1017
  }
987
1018
 
@@ -998,7 +1029,7 @@ export async function writeWorkspaceJson(workspaceDir, relativePath, value) {
998
1029
  * exists to prevent.
999
1030
  */
1000
1031
  export async function deleteWorkspaceFile(workspaceDir, relativePath) {
1001
- if (!await workspaceFileExists(workspaceDir, relativePath)) return false;
1032
+ if (!await regularFileExists(workspaceDir, relativePath)) return false;
1002
1033
  try {
1003
1034
  await unlink(resolveWorkspacePath(workspaceDir, relativePath));
1004
1035
  } catch (error) {
@@ -1009,6 +1040,7 @@ export async function deleteWorkspaceFile(workspaceDir, relativePath) {
1009
1040
  { field: relativePath },
1010
1041
  );
1011
1042
  }
1043
+ noteFileDeleted(relativePath);
1012
1044
  return true;
1013
1045
  }
1014
1046
 
@@ -1040,8 +1072,12 @@ export async function walkWorkspaceFiles(
1040
1072
  extensions,
1041
1073
  { segmentPattern = SAFE_SEGMENT, segmentDescription = "letters, digits, '.', '_', and '-'" } = {},
1042
1074
  ) {
1075
+ const filter = `${extensions.join(",")}|${segmentPattern}`;
1043
1076
  const rootPath = resolveWorkspacePath(workspaceDir, relativeRoot);
1044
- if (!await requireRealDirectoryChain(workspaceDir, relativeRoot, { includeLeaf: true })) return [];
1077
+ if (!await requireRealDirectoryChain(workspaceDir, relativeRoot, { includeLeaf: true })) {
1078
+ noteWalk(relativeRoot, filter, []);
1079
+ return [];
1080
+ }
1045
1081
  const collected = [];
1046
1082
  const queue = [{ directory: rootPath, relative: relativeRoot, depth: 0 }];
1047
1083
  while (queue.length > 0) {
@@ -1088,7 +1124,9 @@ export async function walkWorkspaceFiles(
1088
1124
  }
1089
1125
  }
1090
1126
  }
1091
- return collected.sort();
1127
+ collected.sort();
1128
+ noteWalk(relativeRoot, filter, collected);
1129
+ return collected;
1092
1130
  }
1093
1131
 
1094
1132
  /**
@@ -1324,6 +1362,11 @@ export async function readMediaManifest(workspaceDir, expectedSiteId) {
1324
1362
  };
1325
1363
  }
1326
1364
 
1365
+ /** The alt text a media record keeps when its file is uploaded again: the author's own, or none. */
1366
+ export function recordedAlt(entry) {
1367
+ return typeof entry?.alt === "string" ? entry.alt : "";
1368
+ }
1369
+
1327
1370
  export async function writeMediaManifest(workspaceDir, mediaManifest) {
1328
1371
  return await writeWorkspaceJson(workspaceDir, MEDIA_MANIFEST_FILE_NAME, mediaManifest);
1329
1372
  }