@taprootio/site-authoring 0.14.1 → 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.
- package/README.md +62 -10
- 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 +58 -1
- package/src/cli.js +147 -22
- package/src/constants.js +6 -0
- package/src/content/components.js +64 -6
- package/src/content/free-form-sections.js +21 -4
- 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/font-catalog.js +87 -0
- package/src/footer-contract.js +1 -1
- package/src/output.js +19 -1
- package/src/presentation-reference.js +21 -7
- package/src/problems.js +116 -0
- package/src/reference-help.js +100 -34
- package/src/session.js +13 -0
- package/src/theme-proposals.js +393 -0
- package/src/theme-validation.js +31 -10
- package/src/typed-page-reference.js +11 -3
- package/src/typed-pages.js +31 -9
- package/src/verbs/index.js +13 -0
- package/src/verbs/media-upload.js +127 -30
- package/src/verbs/nav-push.js +7 -0
- package/src/verbs/pages-push.js +406 -199
- package/src/verbs/places.js +61 -0
- package/src/verbs/plan.js +585 -0
- package/src/verbs/theme-push.js +68 -36
- package/src/verbs/validate.js +264 -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 +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
|
-
|
|
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
|
|
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 }))
|
|
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
|
-
|
|
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
|
}
|