@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.
@@ -0,0 +1,61 @@
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ import { isGooglePlaceId, searchPlaces, selectPlace, withRefusalGuidance } from "../api.js";
4
+ import { VERB_PLACES_SEARCH, VERB_PLACES_SELECT } from "../constants.js";
5
+ import { SiteAuthoringError } from "../errors.js";
6
+ import { openSession, successResult } from "../session.js";
7
+
8
+ /**
9
+ * `places search` and `places select` — find the Taproot place a place review
10
+ * names (TR01003).
11
+ *
12
+ * A place review needs a Taproot place id, and until now only the editor's
13
+ * place picker could produce one, so an imported review with a rating and an
14
+ * address had to become an article. Search asks Google Places for predictions;
15
+ * select records one as a Taproot place and returns its id for `placeId:` (or
16
+ * `place:` on an article or album). Search mints the billing session token that
17
+ * select must be given, so the pair is one billed autocomplete session.
18
+ */
19
+
20
+ const MAXIMUM_QUERY_LENGTH = 200;
21
+ const SESSION_TOKEN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u;
22
+
23
+ export async function placesSearch(invocation) {
24
+ const searchText = Array.isArray(invocation.placeQuery) ? invocation.placeQuery.join(" ").trim() : "";
25
+ if (searchText === "" || searchText.length > MAXIMUM_QUERY_LENGTH) {
26
+ throw new SiteAuthoringError(
27
+ "places.query_invalid",
28
+ `places search needs one query of at most ${MAXIMUM_QUERY_LENGTH} characters, such as the name and city.`,
29
+ { field: "query", exitCode: 2 },
30
+ );
31
+ }
32
+ const session = await openSession(invocation);
33
+ const { client, siteId, onProgress } = session;
34
+ const sessionToken = randomUUID();
35
+ const predictions = await withRefusalGuidance(onProgress, "place search", async () =>
36
+ await searchPlaces(client, siteId, { text: searchText, sessionToken }));
37
+ onProgress(
38
+ predictions.length === 0
39
+ ? "No place matched; try the name with its city, or import the review as an article."
40
+ : `Run 'taproot-site places select <googlePlaceId> ${sessionToken}' with the match to get its placeId.`,
41
+ );
42
+ return successResult(VERB_PLACES_SEARCH, siteId, { query: searchText, sessionToken, predictions });
43
+ }
44
+
45
+ export async function placesSelect(invocation) {
46
+ const [googlePlaceId, sessionToken, ...extra] = Array.isArray(invocation.placeSelection) ? invocation.placeSelection : [];
47
+ if (!isGooglePlaceId(googlePlaceId) || extra.length > 0
48
+ || typeof sessionToken !== "string" || !SESSION_TOKEN.test(sessionToken)) {
49
+ throw new SiteAuthoringError(
50
+ "places.selection_invalid",
51
+ "places select takes the googlePlaceId from places search, then the sessionToken that search reported.",
52
+ { field: "googlePlaceId", exitCode: 2 },
53
+ );
54
+ }
55
+ const session = await openSession(invocation);
56
+ const { client, siteId, onProgress } = session;
57
+ const place = await withRefusalGuidance(onProgress, "place select", async () =>
58
+ await selectPlace(client, siteId, { googlePlaceId, sessionToken }));
59
+ onProgress(`Selected ${place.name}: use placeId ${place.placeId} in the page's front matter.`);
60
+ return successResult(VERB_PLACES_SELECT, siteId, { place });
61
+ }
@@ -0,0 +1,585 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ import { appearanceImageIds } from "../appearance-contract.js";
4
+ import { footerImageIds } from "../footer-contract.js";
5
+ import { SETTINGS_TYPE_BRAND, SETTINGS_TYPE_SITE_HEADER, SETTINGS_TYPE_TAPROOT_STYLES } from "../settings-catalog.js";
6
+ import {
7
+ getNavigation,
8
+ IMAGE_PROCESSING_STATE_COMPLETE,
9
+ listSiteImages,
10
+ listSitePages,
11
+ listVideos,
12
+ PAGE_STATUS_DELETED,
13
+ withRefusalGuidance,
14
+ } from "../api.js";
15
+ import {
16
+ LIMITS,
17
+ PLAN_HASH,
18
+ VERB_APPLY,
19
+ VERB_FOOTER_PUSH,
20
+ VERB_MEDIA_UPLOAD,
21
+ VERB_NAV_PUSH,
22
+ VERB_PAGES_PUSH,
23
+ VERB_PLAN,
24
+ VERB_THEME_PUSH,
25
+ } from "../constants.js";
26
+ import { sharedThemeContextNames } from "../content/free-form-sections.js";
27
+ import { SiteAuthoringError } from "../errors.js";
28
+ import { computeFooterContentHash, computeFooterDraftHash, stableJson } from "../footer-draft-hash.js";
29
+ import { FOOTER_SETTINGS_FILE } from "../footer-workspace.js";
30
+ import { CHECK_AREA, collectProblems, problemsError, problemsOf, problemsWithinBudget, withinByteBudget } from "../problems.js";
31
+ import { openSession, successResult, warnIfExternalWritesPaused } from "../session.js";
32
+ import { IMAGE_IDENTIFIER, usedImageIds } from "../typed-pages.js";
33
+ import { checkWorkspace } from "../workspace-check.js";
34
+ import { enforceWorkspaceReads, ledgerDigest, recordWorkspaceReads } from "../workspace-ledger.js";
35
+ import {
36
+ PAGE_SOURCE_EXTENSIONS,
37
+ PAGES_DIRECTORY,
38
+ readManifest,
39
+ readMediaManifest,
40
+ walkWorkspaceFiles,
41
+ } from "../workspace.js";
42
+ import { footerPush } from "./footer-push.js";
43
+ import { mediaUpload, pendingMediaFiles } from "./media-upload.js";
44
+ import { navPush, validateNavigationWorkspaceDocument } from "./nav-push.js";
45
+ import { pagesPush, placedVideoProblems, planPages, plannedPageItem } from "./pages-push.js";
46
+ import { comparePresentation, presentationMovedRefusal, presentationPullRequired, themePush } from "./theme-push.js";
47
+
48
+ /**
49
+ * `plan` and `apply` — the whole workspace at once (TR00823).
50
+ *
51
+ * `plan` checks everything a push would check, offline and then against the
52
+ * live site, and orders what is left to send: media the pages reference, then
53
+ * pages, then the footer (theme push refuses while footer content is unsaved),
54
+ * then the theme, then navigation. It writes nothing. `apply --plan <hash>`
55
+ * runs that plan's steps through the ordinary verbs, refusing when the
56
+ * workspace or the site moved since the plan was made.
57
+ *
58
+ * The workspace side of that is the read ledger: everything the plan read is
59
+ * recorded and hashed into the plan, and while the steps run, any workspace
60
+ * read that differs from the plan's, or that the plan never made, stops the
61
+ * step before it writes. What lives on the site and has no server-side fence
62
+ * (page revisions, the navigation tree) is checked against the plan by the
63
+ * steps themselves; the theme and footer saves are fenced by the server.
64
+ *
65
+ * Nothing here is atomic across steps. Each verb keeps its own guards and its
66
+ * own recovery — a page's manifest entry is written as soon as it is sent, a
67
+ * theme save whose answer was lost is replayed — so after a failure the next
68
+ * `plan` shows what is left and finished steps read as nothing to do. Approval,
69
+ * staging, and production stay separate commands.
70
+ */
71
+
72
+ export const PLAN_STEP = Object.freeze({
73
+ media: VERB_MEDIA_UPLOAD,
74
+ pages: VERB_PAGES_PUSH,
75
+ footer: VERB_FOOTER_PUSH,
76
+ theme: VERB_THEME_PUSH,
77
+ navigation: VERB_NAV_PUSH,
78
+ });
79
+ const STEP_AREAS = Object.freeze({
80
+ [PLAN_STEP.media]: CHECK_AREA.media,
81
+ [PLAN_STEP.pages]: CHECK_AREA.pages,
82
+ [PLAN_STEP.footer]: CHECK_AREA.footer,
83
+ [PLAN_STEP.theme]: CHECK_AREA.presentation,
84
+ [PLAN_STEP.navigation]: CHECK_AREA.navigation,
85
+ });
86
+ const STEP_REASONS = Object.freeze({
87
+ [PLAN_STEP.media]: "Pages resolve their media references against the uploads this step records.",
88
+ [PLAN_STEP.pages]: "Navigation and the footer can point only at pages the site already holds.",
89
+ [PLAN_STEP.footer]: "theme push refuses while the footer document carries unsaved content.",
90
+ [PLAN_STEP.theme]: "The theme saves the footer's scheme colors with the rest of the presentation.",
91
+ [PLAN_STEP.navigation]: "Navigation replaces the whole tree, so it goes last, once its targets exist.",
92
+ });
93
+ const READY = "ready";
94
+ const BLOCKED = "blocked";
95
+ const NOTHING_TO_DO = "nothing to do";
96
+ const PLANNED_AREAS = new Set([CHECK_AREA.presentation, CHECK_AREA.footer, CHECK_AREA.navigation]);
97
+ // Stands in for an image `media upload` has not sent yet, so a page that
98
+ // references it is still checked in full. One id per file, so checks that
99
+ // compare images (a cover the page must use) still tell two pending files
100
+ // apart. Never sent: apply uploads first, and pages push resolves against the
101
+ // real record.
102
+ function pendingImageId(file) {
103
+ const hex = createHash("sha256").update(`pending image:${file}`, "utf8").digest("hex");
104
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-8${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
105
+ }
106
+ // What each list in the report may take of the result's 64 KiB, so a plan of
107
+ // any size serializes; counts are always complete and the hash covers the
108
+ // whole plan. The problems get what is left.
109
+ const PAGE_ITEMS_BYTES = 16 * 1024;
110
+ // The most image ids one library read accepts.
111
+ const IMAGE_IDS_PER_READ = 100;
112
+ const MEDIA_ITEMS_BYTES = 8 * 1024;
113
+ const DIFFERENCES_BYTES = 8 * 1024;
114
+ // Room for the envelope successResult adds and the problem-count fields.
115
+ const RESULT_ENVELOPE_BYTES = 4 * 1024;
116
+
117
+ /** What the site holds of each image, read by id in the batches one read accepts. */
118
+ async function readImageStates(client, siteId, ids) {
119
+ const unique = [...new Set(ids)];
120
+ const states = new Map();
121
+ for (let start = 0; start < unique.length; start += IMAGE_IDS_PER_READ) {
122
+ const imageIds = unique.slice(start, start + IMAGE_IDS_PER_READ);
123
+ for (const image of (await listSiteImages(client, siteId, {}, { imageIds })).images) {
124
+ states.set(image.imageId, image.processingState);
125
+ }
126
+ }
127
+ return states;
128
+ }
129
+
130
+ function settingsImageMissing(imageId, where) {
131
+ return new SiteAuthoringError(
132
+ "plan.image_missing",
133
+ `${where} name image ${imageId}, which the site does not hold processed (it was deleted, failed, or belongs `
134
+ + "elsewhere), so the save would be refused. Upload the image again with media upload and use the new id.",
135
+ { field: imageId },
136
+ );
137
+ }
138
+
139
+ function reportedWithin(values, budget) {
140
+ const items = withinByteBudget(values, budget);
141
+ return { items, truncated: items.length < values.length };
142
+ }
143
+
144
+ function differencesWithin(differences) {
145
+ const shown = reportedWithin(differences, DIFFERENCES_BYTES);
146
+ return { differences: shown.items, ...(shown.truncated ? { differencesTruncated: true } : {}) };
147
+ }
148
+
149
+ function step(name, status, detail = {}) {
150
+ return { step: name, status, reason: STEP_REASONS[name], ...detail };
151
+ }
152
+
153
+ function stepStatus(problems, name, changed) {
154
+ if (problems.some((problem) => problem.area === STEP_AREAS[name])) return BLOCKED;
155
+ return changed ? READY : NOTHING_TO_DO;
156
+ }
157
+
158
+ /**
159
+ * Everything `plan` decides, read-only. The hash covers what each step would
160
+ * send and the live state it would be sent against, so `apply` can tell a
161
+ * plan that still describes the workspace and the site from one that does not.
162
+ */
163
+ async function preflight(session, invocation) {
164
+ const { client, config, siteId, onProgress } = session;
165
+ const workspaceDir = config.workspaceDir;
166
+ const manifest = await readManifest(workspaceDir, siteId);
167
+ if (manifest.authoringSurface !== undefined) {
168
+ throw new SiteAuthoringError(
169
+ "plan.surface_unsupported",
170
+ "plan covers a standard site's pages, media, footer, theme, and navigation. A Docs site's workspace holds "
171
+ + "its settings only: run 'taproot-site theme push --dry-run' and 'footer push' instead.",
172
+ { field: "authoringSurface", exitCode: 2 },
173
+ );
174
+ }
175
+ const mediaManifest = await readMediaManifest(workspaceDir, siteId);
176
+ const problems = [];
177
+
178
+ return await withRefusalGuidance(onProgress, "plan", async () => {
179
+ onProgress("Listing the site's pages.");
180
+ const { pages: livePages, truncated } = await listSitePages(client, siteId, { onProgress });
181
+ if (truncated) {
182
+ throw new SiteAuthoringError(
183
+ "pages.live_list_truncated",
184
+ "The site has more pages than this CLI can enumerate, so an update cannot be told from a create.",
185
+ { field: "pages" },
186
+ );
187
+ }
188
+ const live = livePages.filter((summary) => summary.status !== PAGE_STATUS_DELETED);
189
+ const liveResourceIds = new Set(live.map((summary) => summary.resourceId).filter(Boolean));
190
+
191
+ // The shared checks for the presentation, footer, and navigation; pages
192
+ // are checked against the site below, and redirects and forms are left to
193
+ // their own pushes. Navigation targets are judged against the site's
194
+ // pages, as nav push judges them; the footer against the pull's, as
195
+ // footer push does, less any the site has deleted since.
196
+ onProgress("Checking the workspace.");
197
+ const offline = await checkWorkspace({
198
+ workspaceDir,
199
+ siteId,
200
+ manifest,
201
+ mediaManifest,
202
+ binding: {
203
+ resourceIds: new Set(
204
+ manifest.pages.map((entry) => entry?.resourceId).filter((value) => liveResourceIds.has(value)),
205
+ ),
206
+ navigationPages: {
207
+ pageIds: new Set(live.map((summary) => summary.pageId)),
208
+ resourceIds: liveResourceIds,
209
+ },
210
+ },
211
+ content: invocation.content,
212
+ areas: PLANNED_AREAS,
213
+ onProgress,
214
+ });
215
+ problems.push(...offline.problems);
216
+
217
+ // What the site holds of every image this plan depends on, read by id so a
218
+ // large library cannot hide one: the recorded uploads, and the images the
219
+ // appearance and footer settings name.
220
+ onProgress("Reading the images this workspace uses, and finding media that has not been uploaded.");
221
+ const presentation = offline.presentation;
222
+ const appearanceImages = presentation === undefined ? [] : appearanceImageIds({
223
+ [SETTINGS_TYPE_TAPROOT_STYLES]: presentation.style,
224
+ [SETTINGS_TYPE_BRAND]: presentation.brand,
225
+ [SETTINGS_TYPE_SITE_HEADER]: presentation.header,
226
+ });
227
+ const footerImages = offline.footer === undefined ? [] : footerImageIds(offline.footer.footerSettings);
228
+ const imageStates = await readImageStates(client, siteId, [
229
+ // The read refuses an id that is not one; such an entry reads as not held.
230
+ ...Object.values(mediaManifest.media).map((entry) => entry?.imageId)
231
+ .filter((value) => typeof value === "string" && IMAGE_IDENTIFIER.test(value)),
232
+ ...appearanceImages,
233
+ ...footerImages,
234
+ ]);
235
+ const videos = Object.values(mediaManifest.videos ?? {})
236
+ .some((entry) => typeof entry?.videoId === "string" && entry.pendingConfirm !== true)
237
+ ? await listVideos(client, siteId)
238
+ : { videos: [], truncated: false };
239
+ const media = await collectProblems(problems, { area: CHECK_AREA.media }, async () =>
240
+ await pendingMediaFiles(workspaceDir, mediaManifest, { imageStates, videos }));
241
+ problems.push(...(media?.problems ?? []));
242
+ const pendingMedia = media?.files ?? [];
243
+ const plannedMedia = {
244
+ ...mediaManifest,
245
+ media: {
246
+ ...mediaManifest.media,
247
+ // Only an image can be a page's image reference; a video stays unresolved.
248
+ ...Object.fromEntries(pendingMedia.filter(({ kind }) => kind === "image").map(({ file, width, height }) => [file, {
249
+ imageId: pendingImageId(file),
250
+ src: "",
251
+ urls: [],
252
+ width,
253
+ height,
254
+ alt: mediaManifest.media[file]?.alt ?? "",
255
+ }])),
256
+ },
257
+ };
258
+
259
+ const pages = await planPages({
260
+ workspaceDir,
261
+ siteId,
262
+ manifest,
263
+ mediaManifest: plannedMedia,
264
+ content: invocation.content,
265
+ files: await walkWorkspaceFiles(workspaceDir, PAGES_DIRECTORY, PAGE_SOURCE_EXTENSIONS),
266
+ livePages,
267
+ online: true,
268
+ // From the theme validated above, so a broken styles file is one problem, not one per page.
269
+ getSharedThemeContexts: async () =>
270
+ offline.presentation === undefined
271
+ ? undefined
272
+ : sharedThemeContextNames(offline.presentation.style.lightTheme, offline.presentation.style.darkTheme),
273
+ onProgress,
274
+ });
275
+ problems.push(...pages.problems);
276
+ problems.push(...await placedVideoProblems(client, siteId, pages.planned));
277
+ const sending = pages.planned.filter((page) => !page.unchanged);
278
+
279
+ // An image a page names must be one the site holds; an image this plan
280
+ // uploads is the media step's to supply.
281
+ const pendingImageIds = new Set(pendingMedia.filter(({ kind }) => kind === "image").map(({ file }) => pendingImageId(file)));
282
+ const pageImages = sending.flatMap((page) =>
283
+ [...usedImageIds(page.document)]
284
+ .filter((imageId) => !pendingImageIds.has(imageId) && IMAGE_IDENTIFIER.test(imageId))
285
+ .map((imageId) => ({ file: page.file, imageId }))
286
+ );
287
+ const unread = pageImages.map(({ imageId }) => imageId).filter((imageId) => !imageStates.has(imageId));
288
+ const pageImageStates = unread.length === 0
289
+ ? imageStates
290
+ : new Map([...imageStates, ...await readImageStates(client, siteId, unread)]);
291
+ for (const { file, imageId } of pageImages) {
292
+ if (pageImageStates.has(imageId)) continue;
293
+ problems.push(...problemsOf(new SiteAuthoringError(
294
+ "plan.page_image_missing",
295
+ `'${file}' names image ${imageId}, which this site does not hold (it was deleted, or belongs to another `
296
+ + "site). Upload the file with media upload and refer to it by its media path.",
297
+ { field: imageId },
298
+ ), { area: CHECK_AREA.pages, file }));
299
+ }
300
+
301
+ onProgress("Reading the site's presentation.");
302
+ let theme;
303
+ if (offline.presentation !== undefined) {
304
+ theme = await comparePresentation(client, siteId, offline.presentation, manifest);
305
+ // Only a theme step that would run can be refused.
306
+ if (theme.changed && theme.baseline === undefined) {
307
+ problems.push(...problemsOf(presentationPullRequired(), { area: CHECK_AREA.presentation }));
308
+ } else if (theme.changed && theme.stale && !theme.replaying) {
309
+ problems.push(...problemsOf(presentationMovedRefusal(theme), { area: CHECK_AREA.presentation }));
310
+ }
311
+ }
312
+
313
+ // Compared as written, the way theme push's own guard compares it.
314
+ const workspaceFooter = offline.presentation?.publishing.footerSettings;
315
+ const footerChanged = workspaceFooter !== undefined
316
+ && computeFooterContentHash(workspaceFooter) !== manifest.footer?.expectedContentHash;
317
+ const liveFooterDraftHash = theme === undefined
318
+ ? undefined
319
+ : computeFooterDraftHash(theme.current.sitePublishingPreferences?.footerSettings ?? {});
320
+ if (footerChanged && liveFooterDraftHash !== undefined && liveFooterDraftHash !== manifest.footer?.expectedDraftHash) {
321
+ problems.push(...problemsOf(new SiteAuthoringError(
322
+ "footer.concurrent_modification",
323
+ "The footer changed on the site after this workspace was pulled, so footer push would be refused. Pull, "
324
+ + "reconcile the footer document, and plan again.",
325
+ { field: "expectedFooterDraftHash" },
326
+ ), { area: CHECK_AREA.footer, file: FOOTER_SETTINGS_FILE }));
327
+ }
328
+
329
+ // A settings save refuses an image the site no longer holds processed.
330
+ const unheld = (imageIds) => imageIds.filter((imageId) => imageStates.get(imageId) !== IMAGE_PROCESSING_STATE_COMPLETE);
331
+ if (theme?.changed === true) {
332
+ for (const imageId of unheld(appearanceImages)) {
333
+ problems.push(...problemsOf(settingsImageMissing(imageId, "the appearance settings"), { area: CHECK_AREA.presentation }));
334
+ }
335
+ }
336
+ if (footerChanged) {
337
+ for (const imageId of unheld(footerImages)) {
338
+ problems.push(...problemsOf(settingsImageMissing(imageId, "the footer"), {
339
+ area: CHECK_AREA.footer,
340
+ file: FOOTER_SETTINGS_FILE,
341
+ }));
342
+ }
343
+ }
344
+
345
+ onProgress("Reading the site's navigation.");
346
+ let liveNavigation;
347
+ let navigationChanged = false;
348
+ if (offline.navigation !== undefined) {
349
+ liveNavigation = await getNavigation(client, siteId);
350
+ let liveItems;
351
+ try {
352
+ liveItems = validateNavigationWorkspaceDocument({ siteId, navItems: liveNavigation }, siteId).navItems;
353
+ } catch (error) {
354
+ // A live tree this CLI would not write is different from any tree it would.
355
+ if (!(error instanceof SiteAuthoringError)) throw error;
356
+ liveItems = liveNavigation;
357
+ }
358
+ navigationChanged = stableJson(liveItems) !== stableJson(offline.navigation.navItems);
359
+ }
360
+
361
+ const pagesItems = reportedWithin(pages.planned.map(plannedPageItem), PAGE_ITEMS_BYTES);
362
+ const mediaItems = reportedWithin(pendingMedia.map(({ file }) => file), MEDIA_ITEMS_BYTES);
363
+ const steps = [
364
+ step(PLAN_STEP.media, stepStatus(problems, PLAN_STEP.media, pendingMedia.length > 0), {
365
+ files: pendingMedia.length,
366
+ items: mediaItems.items,
367
+ ...(mediaItems.truncated ? { itemsTruncated: true } : {}),
368
+ }),
369
+ step(PLAN_STEP.pages, stepStatus(problems, PLAN_STEP.pages, sending.length > 0), {
370
+ create: sending.filter((page) => page.action === "created").length,
371
+ update: sending.filter((page) => page.action === "updated").length,
372
+ unchanged: pages.planned.length - sending.length,
373
+ items: pagesItems.items,
374
+ ...(pagesItems.truncated ? { itemsTruncated: true } : {}),
375
+ }),
376
+ step(PLAN_STEP.footer, stepStatus(problems, PLAN_STEP.footer, footerChanged)),
377
+ // A save whose answer was lost and that committed leaves nothing to
378
+ // change, but its record is settled only by running theme push again.
379
+ step(PLAN_STEP.theme, stepStatus(problems, PLAN_STEP.theme, theme?.changed === true || theme?.replaying === true), {
380
+ ...(theme === undefined ? {} : {
381
+ ...differencesWithin(theme.differences.filter((entry) => entry.paths.length > 0 || entry.truncated === true)),
382
+ ...(theme.replaying ? { replaysLostSave: true } : {}),
383
+ }),
384
+ }),
385
+ step(PLAN_STEP.navigation, stepStatus(problems, PLAN_STEP.navigation, navigationChanged)),
386
+ ];
387
+ const planHash = `sha256:${createHash("sha256").update(stableJson({
388
+ version: 1,
389
+ siteId,
390
+ media: pendingMedia.map((entry) => [entry.file, entry.identity]),
391
+ pages: pages.planned.map((page) => [page.file, page.action, page.unchanged, page.contentKey, page.pageId ?? "", page.revision ?? ""]),
392
+ footer: [footerChanged, workspaceFooter === undefined ? "" : computeFooterContentHash(workspaceFooter), liveFooterDraftHash ?? ""],
393
+ theme: theme === undefined ? [] : [theme.changeSetHash, theme.current.revision],
394
+ navigation: [stableJson(offline.navigation?.navItems ?? null), stableJson(liveNavigation ?? null)],
395
+ problems: problems.map((problem) => [problem.area ?? "", problem.file ?? "", problem.code, problem.field ?? ""]),
396
+ }), "utf8").digest("hex")}`;
397
+ return {
398
+ steps,
399
+ problems,
400
+ pendingMedia,
401
+ planHash,
402
+ // The live state each step must still find, where the server has no fence.
403
+ plannedPages: new Map(sending.map((page) => [page.file, stableJson([page.pageId ?? "", page.revision ?? "", page.action])])),
404
+ plannedLiveNavigation: stableJson(liveNavigation ?? null),
405
+ };
406
+ });
407
+ }
408
+
409
+ /**
410
+ * The plan, with every workspace read it made recorded. The plan hash covers
411
+ * that record, so a workspace edited between plan and apply changes it.
412
+ */
413
+ async function recordedPlan(session, invocation) {
414
+ const { result: planned, ledger } = await recordWorkspaceReads(() => preflight(session, invocation));
415
+ const planHash = `sha256:${createHash("sha256").update(stableJson([planned.planHash, ledgerDigest(ledger)]), "utf8").digest("hex")}`;
416
+ return { ...planned, planHash, ledger };
417
+ }
418
+
419
+ function planReport(planned) {
420
+ const { steps, problems, planHash } = planned;
421
+ const report = {
422
+ planHash,
423
+ ready: problems.length === 0,
424
+ steps,
425
+ checked: [
426
+ "every page source, against the live site's pages, revisions, and video library",
427
+ "media under media/ that pages reference and media upload has not sent",
428
+ "the theme, appearance, and footer colors, against the site's presentation revision",
429
+ "the footer document, against the site's footer draft and the pages it still holds",
430
+ "every image the theme and footer name, and every recorded upload, against the site's images",
431
+ "navigation shape and page targets, against the live page list and navigation",
432
+ ],
433
+ notChecked: [
434
+ "redirects and forms: run 'redirects push' and 'forms push' on their own",
435
+ "rendering: preview pages before approving them",
436
+ "approval, staging, and production, which stay separate commands",
437
+ ],
438
+ };
439
+ // The problems get whatever the rest of the report leaves of the result's
440
+ // bound, so a long plan still serializes.
441
+ const rest = Buffer.byteLength(JSON.stringify(report), "utf8");
442
+ const shown = problemsWithinBudget(problems, LIMITS.githubOutputBytes - RESULT_ENVELOPE_BYTES - rest);
443
+ return {
444
+ ...report,
445
+ problems: shown,
446
+ problemCount: problems.length,
447
+ ...(shown.length < problems.length ? { problemsTruncated: true } : {}),
448
+ };
449
+ }
450
+
451
+ export async function plan(invocation) {
452
+ const session = await openSession(invocation);
453
+ const planned = await recordedPlan(session, invocation);
454
+ const report = planReport(planned);
455
+ for (const entry of report.steps) onStepProgress(session.onProgress, entry);
456
+ session.onProgress(
457
+ report.ready
458
+ ? `Ready: run 'taproot-site apply --plan ${report.planHash}' to send it. Nothing was written.`
459
+ : `${planned.problems.length} problem(s) block the plan; fix them and plan again. Nothing was written.`,
460
+ );
461
+ return successResult(VERB_PLAN, session.siteId, report);
462
+ }
463
+
464
+ function onStepProgress(onProgress, entry) {
465
+ onProgress(`${entry.step}: ${entry.status}.`);
466
+ }
467
+
468
+ /**
469
+ * The verb each step runs, with the arguments that make it send exactly what
470
+ * the plan listed. The session's client is shared so one credential exchange
471
+ * serves every step.
472
+ */
473
+ const STEP_RUNNERS = Object.freeze({
474
+ [PLAN_STEP.media]: (invocation, planned) =>
475
+ mediaUpload({ ...invocation, verb: PLAN_STEP.media, paths: planned.pendingMedia.map(({ file }) => file) }),
476
+ [PLAN_STEP.pages]: (invocation, planned) => pagesPush({
477
+ ...invocation,
478
+ verb: PLAN_STEP.pages,
479
+ pagePaths: undefined,
480
+ dryRun: false,
481
+ plannedPages: planned.plannedPages,
482
+ }),
483
+ [PLAN_STEP.footer]: (invocation) => footerPush({ ...invocation, verb: PLAN_STEP.footer }),
484
+ [PLAN_STEP.theme]: (invocation) => themePush({ ...invocation, verb: PLAN_STEP.theme, dryRun: false }),
485
+ [PLAN_STEP.navigation]: (invocation, planned) =>
486
+ navPush({ ...invocation, verb: PLAN_STEP.navigation, plannedLiveNavigation: planned.plannedLiveNavigation }),
487
+ });
488
+
489
+ export async function apply(invocation) {
490
+ if (typeof invocation.planHash !== "string" || !PLAN_HASH.test(invocation.planHash)) {
491
+ throw new SiteAuthoringError(
492
+ "apply.plan_required",
493
+ "apply requires --plan with the planHash that 'taproot-site plan' reported.",
494
+ { field: "planHash", exitCode: 2 },
495
+ );
496
+ }
497
+ const session = await openSession(invocation);
498
+ warnIfExternalWritesPaused(session, VERB_APPLY);
499
+ const { siteId, onProgress } = session;
500
+ onProgress("Re-planning to confirm the workspace and the site still match the plan.");
501
+ const planned = await recordedPlan(session, invocation);
502
+ if (planned.planHash !== invocation.planHash) {
503
+ throw new SiteAuthoringError(
504
+ "apply.plan_stale",
505
+ "The workspace or the site changed since that plan was made, so nothing was applied. Run 'taproot-site plan' "
506
+ + "again, read it, and apply the new planHash.",
507
+ { field: "planHash", alternatives: [planned.planHash] },
508
+ );
509
+ }
510
+ if (planned.problems.length > 0) throw problemsError(planned.problems, "apply sent nothing; the plan has problems");
511
+
512
+ const ready = planned.steps.filter((entry) => entry.status === READY);
513
+ const completed = [];
514
+ const stepInvocation = { ...invocation, client: session.client, resolvedConfig: session.config };
515
+ await enforceWorkspaceReads(planned.ledger, () => runSteps(ready, completed, stepInvocation, planned, onProgress));
516
+ onProgress(
517
+ completed.length === 0
518
+ ? "Nothing to apply: every step was already current."
519
+ : "Applied. Pages are drafts until 'taproot-site approve'; nothing was deployed.",
520
+ );
521
+ return successResult(VERB_APPLY, siteId, {
522
+ planHash: planned.planHash,
523
+ applied: completed,
524
+ skipped: planned.steps.filter((entry) => entry.status !== READY).map((entry) => entry.step),
525
+ ...(completed.some((done) => done.step === PLAN_STEP.pages) ? { nextStep: "approve" } : {}),
526
+ });
527
+ }
528
+
529
+ /**
530
+ * The plan's ready steps, in order, inside the read ledger's enforcement. A
531
+ * failure names what completed, what failed and what did not run; an
532
+ * unexpected error is reported the same way, since earlier steps may already
533
+ * have written.
534
+ */
535
+ async function runSteps(ready, completed, stepInvocation, planned, onProgress) {
536
+ for (const [index, entry] of ready.entries()) {
537
+ onProgress(`Applying step ${index + 1} of ${ready.length}: ${entry.step}.`);
538
+ try {
539
+ const result = await STEP_RUNNERS[entry.step](stepInvocation, planned);
540
+ completed.push({ step: entry.step, result: stepSummary(entry.step, result) });
541
+ } catch (thrown) {
542
+ const error = thrown instanceof SiteAuthoringError
543
+ ? thrown
544
+ : new SiteAuthoringError("apply.step_failed", `${entry.step} failed unexpectedly${unexpectedName(thrown)}.`, {
545
+ cause: thrown,
546
+ });
547
+ const remaining = ready.slice(index + 1).map((next) => next.step);
548
+ onProgress(
549
+ `${entry.step} failed; ${completed.length} step(s) completed before it`
550
+ + `${remaining.length === 0 ? "" : ` and ${remaining.join(", ")} did not run`}. Run 'taproot-site plan' `
551
+ + "again: finished work reads as nothing to do, and a step that may have committed is reconciled by its "
552
+ + "own verb rather than repeated blindly.",
553
+ );
554
+ // Step labels first: the list is bounded, and a step's own detail can be long.
555
+ throw error.withCompletedWrites([
556
+ ...completed.map((done) => `${done.step}: completed`),
557
+ `${entry.step}: failed`,
558
+ ...remaining.map((name) => `${name}: not run`),
559
+ ...(error.completedWrites ?? []),
560
+ ]);
561
+ }
562
+ }
563
+ }
564
+
565
+ /** The error's class name or code, never its message, which can carry a path or a credential. */
566
+ function unexpectedName(error) {
567
+ const name = typeof error?.code === "string" ? error.code : error?.name;
568
+ return typeof name === "string" && /^[A-Za-z][A-Za-z0-9_]{0,63}$/u.test(name) ? ` (${name})` : "";
569
+ }
570
+
571
+ /** The few counts worth repeating from each verb's own result. */
572
+ function stepSummary(name, result) {
573
+ switch (name) {
574
+ case PLAN_STEP.media:
575
+ return { images: result.media?.total, videos: result.videos?.total };
576
+ case PLAN_STEP.pages:
577
+ return { created: result.pages?.created, updated: result.pages?.updated, unchanged: result.pages?.unchanged };
578
+ case PLAN_STEP.theme:
579
+ return { applied: result.applied, revision: result.revision };
580
+ case PLAN_STEP.navigation:
581
+ return { items: result.navigation?.items };
582
+ default:
583
+ return {};
584
+ }
585
+ }