@taprootio/site-authoring 0.22.4 → 0.23.1

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.
@@ -15,6 +15,7 @@ import {
15
15
  VERB_LOGOUT,
16
16
  VERB_MEDIA_UPLOAD,
17
17
  VERB_NAV_PUSH,
18
+ VERB_PAGES_DELETE,
18
19
  VERB_PAGES_META_SET,
19
20
  VERB_PAGES_PUSH,
20
21
  VERB_PLACES_CATEGORY_CLEAR,
@@ -33,6 +34,7 @@ import {
33
34
  VERB_STAGING_REVIEW,
34
35
  VERB_STATUS,
35
36
  VERB_THEME_PUSH,
37
+ VERB_THEME_RESOLVE,
36
38
  VERB_USE,
37
39
  VERB_VALIDATE,
38
40
  VERB_WHOAMI,
@@ -51,6 +53,7 @@ import { login } from "./login.js";
51
53
  import { logout } from "./logout.js";
52
54
  import { mediaUpload } from "./media-upload.js";
53
55
  import { navPush } from "./nav-push.js";
56
+ import { pagesDelete } from "./pages-delete.js";
54
57
  import { pagesMetaSet } from "./pages-meta-set.js";
55
58
  import { pagesPush } from "./pages-push.js";
56
59
  import { placesCategoryClear, placesCategoryList, placesCategorySet, placesSearch, placesSelect } from "./places.js";
@@ -65,6 +68,7 @@ import { sites } from "./sites.js";
65
68
  import { stagingReview } from "./staging-review.js";
66
69
  import { status } from "./status.js";
67
70
  import { themePush } from "./theme-push.js";
71
+ import { themeResolve } from "./theme-resolve.js";
68
72
  import { use } from "./use.js";
69
73
  import { validateFixture } from "./validate.js";
70
74
  import { whoami } from "./whoami.js";
@@ -116,6 +120,7 @@ import { whoami } from "./whoami.js";
116
120
  * pagePaths, // approve: narrow to these page paths; with none, every
117
121
  * // draft the workspace manifest tracks is staged
118
122
  * authorHandle, // authors add: the handle of the site author to create
123
+ * deletePagePath, // pages delete: the one tracked page to delete
119
124
  * pageId, // preview page: the one persisted draft UUID to render
120
125
  * previewIds, // preview revoke: [page UUID, snapshot UUID]
121
126
  * }
@@ -149,6 +154,7 @@ export const VERB_HANDLERS = Object.freeze({
149
154
  [VERB_PULL]: pull,
150
155
  [VERB_PAGES_PUSH]: pagesPush,
151
156
  [VERB_PAGES_META_SET]: pagesMetaSet,
157
+ [VERB_PAGES_DELETE]: pagesDelete,
152
158
  [VERB_AUTHORS_LIST]: authorsList,
153
159
  [VERB_AUTHORS_ADD]: authorsAdd,
154
160
  [VERB_NAV_PUSH]: navPush,
@@ -163,6 +169,7 @@ export const VERB_HANDLERS = Object.freeze({
163
169
  [VERB_REDIRECTS_PULL]: redirectsPull,
164
170
  [VERB_REDIRECTS_PUSH]: redirectsPush,
165
171
  [VERB_THEME_PUSH]: themePush,
172
+ [VERB_THEME_RESOLVE]: themeResolve,
166
173
  [VERB_FOOTER_PUSH]: footerPush,
167
174
  [VERB_FORMS_PULL]: formsPull,
168
175
  [VERB_FORMS_PUSH]: formsPush,
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { Readable } from "node:stream";
2
3
 
3
4
  import {
@@ -624,6 +625,7 @@ export async function mediaUpload(invocation) {
624
625
  contentType,
625
626
  fileSize: bytes.byteLength,
626
627
  contentHash: hash,
628
+ operationKey: randomUUID(),
627
629
  width,
628
630
  height,
629
631
  ownershipScope: IMAGE_OWNERSHIP_SCOPE_SITE,
@@ -16,10 +16,17 @@ import {
16
16
  } from "../contact-url.js";
17
17
  import { SiteAuthoringError } from "../errors.js";
18
18
  import { stableJson } from "../footer-draft-hash.js";
19
+ import {
20
+ advanceNavigationManifest,
21
+ navigationMovedRefusal,
22
+ requireNavigationBaseline,
23
+ } from "../navigation-workspace.js";
19
24
  import { planStale } from "../problems.js";
20
25
  import { boundedList, openSession, successResult, warnIfExternalWritesPaused } from "../session.js";
26
+ import { ApiError } from "../transport.js";
21
27
  import {
22
28
  NAVIGATION_FILE_NAME,
29
+ readManifest,
23
30
  readWorkspaceJson,
24
31
  workspaceFileExists,
25
32
  WORKSPACE_LIMITS,
@@ -29,14 +36,14 @@ import {
29
36
  /**
30
37
  * `nav push` — replace the whole navigation tree.
31
38
  *
32
- * `SaveSiteNavigation` is a whole-tree PUT with no concurrency token, so it is
33
- * last-write-wins against a browser session editing the same tree. Two things
34
- * follow, and the body does both:
39
+ * `SaveSiteNavigation` is a whole-tree PUT fenced by a revision (TR01231): the
40
+ * pull records the revision the tree was read at, and the save presents it. A
41
+ * tree edited in the app since is refused with `nav.concurrent_modification`
42
+ * and nothing is written. Two more things follow, and the body does both:
35
43
  *
36
- * 1. The tree is re-read immediately before the write, so the window in which
37
- * someone else's edit can be lost is as small as the contract allows, and
38
- * what the replace is about to remove is *named* rather than discovered
39
- * afterwards.
44
+ * 1. The tree is re-read immediately before the write, so a moved tree is
45
+ * refused before anything is sent, and what the replace is about to remove
46
+ * is *named* rather than discovered afterwards.
40
47
  * 2. Local validation is exhaustive and refuses by item path. The server stores
41
48
  * what it is given; a `PAGE` item carrying `pageId` instead of `resourceId`,
42
49
  * or a fourth level of nesting, is accepted and then renders wrong. And the
@@ -178,9 +185,10 @@ function validateItem(item, itemPath, depth, seenIds, counters) {
178
185
  return {
179
186
  id: item.id,
180
187
  kind: item.kind,
181
- title: item.title,
188
+ // Trimmed as the site stores them, so a padded value still equals the stored tree when a lost push is settled.
189
+ title: item.title.trim(),
182
190
  resourceId: item.kind === NAV_ITEM_KIND_PAGE ? item.resourceId : "",
183
- externalUrl: item.kind === NAV_ITEM_KIND_EXTERNAL_URL ? item.externalUrl : "",
191
+ externalUrl: item.kind === NAV_ITEM_KIND_EXTERNAL_URL ? item.externalUrl.trim() : "",
184
192
  children: validated,
185
193
  };
186
194
  }
@@ -271,7 +279,7 @@ async function requireLivePageTargets(client, siteId, pageReferences, onProgress
271
279
  /** The complete local validation phase shared by live push and offline fixtures. */
272
280
  export function validateNavigationWorkspaceDocument(parsed, siteId, knownPages) {
273
281
  // The pulled shape, and only the pulled shape. A bare array carries no site,
274
- // and this verb replaces a whole navigation tree in one unversioned write.
282
+ // and this verb replaces a whole navigation tree in one write, fenced by the pulled revision.
275
283
  if (parsed === null || Array.isArray(parsed) || typeof parsed !== "object") {
276
284
  throw new SiteAuthoringError(
277
285
  "nav.file_invalid",
@@ -328,22 +336,31 @@ export async function navPush(invocation) {
328
336
  const validated = validateNavigationWorkspaceDocument(parsed, siteId);
329
337
  const { navItems, pageReferences } = validated;
330
338
  onProgress(`Validated ${validated.items} navigation items to depth ${NAVIGATION_MAXIMUM_DEPTH}.`);
339
+ // The manifest's baseline, never a value from the file: nav.json is hand-edited, and its revision would be a
340
+ // claim rather than a record of a read. Refused before any request when the workspace has none.
341
+ const expectedRevision = requireNavigationBaseline(await readManifest(config.workspaceDir, siteId));
331
342
 
332
343
  return await withRefusalGuidance(onProgress, "navigation push", async () => {
333
344
  await requireLivePageTargets(client, siteId, pageReferences, onProgress);
334
345
 
335
- // Re-read immediately before the replace. There is no concurrency token to
336
- // check, so this is the narrowest window the contract permits — and the
337
- // only way to say what the replace removes. It happens *after* the target
338
- // check so the window between reading the tree and replacing it stays as
339
- // small as the contract allows.
346
+ // Re-read immediately before the replace: a tree that moved since the pull is refused here, before anything
347
+ // is sent, and it is the only way to say what the replace removes. It happens *after* the target check so
348
+ // the window between reading the tree and replacing it stays as small as possible; the site's own fence
349
+ // closes what is left.
340
350
  onProgress("Re-reading the live navigation tree immediately before replacing it.");
341
- const liveItems = await getNavigation(client, siteId);
351
+ const live = await getNavigation(client, siteId);
352
+ const liveItems = live.navItems;
342
353
  // Under apply, the tree replaced must be the one its plan read: a change
343
354
  // made since was never reviewed, and this write would erase it.
344
355
  if (invocation.plannedLiveNavigation !== undefined && stableJson(liveItems) !== invocation.plannedLiveNavigation) {
345
356
  throw planStale("The site's navigation", NAVIGATION_FILE_NAME);
346
357
  }
358
+ // A moved tree that already equals the file is a push whose answer was lost after it committed (the
359
+ // transport never replays a write): the retry settles the workspace rather than refusing a tree that is
360
+ // exactly what the file says.
361
+ const alreadyApplied = live.revision !== expectedRevision
362
+ && stableJson(projectNavigationForWorkspace(liveItems)) === stableJson(projectNavigationForWorkspace(navItems));
363
+ if (live.revision !== expectedRevision && !alreadyApplied) throw navigationMovedRefusal();
347
364
  const liveIds = collectIds(liveItems);
348
365
  const pushedIds = collectIds(navItems);
349
366
  const removed = [...liveIds].filter((id) => !pushedIds.has(id)).sort();
@@ -356,11 +373,25 @@ export async function navPush(invocation) {
356
373
  );
357
374
  }
358
375
 
359
- const saved = await saveNavigation(client, siteId, navItems);
360
- onProgress(`Replaced the navigation tree with ${validated.items} items.`);
376
+ let saved;
377
+ if (alreadyApplied) {
378
+ onProgress("The site already holds this navigation tree (an earlier push whose answer was lost); nothing is sent.");
379
+ saved = { navItems: liveItems, revision: live.revision };
380
+ } else {
381
+ try {
382
+ saved = await saveNavigation(client, siteId, navItems, expectedRevision);
383
+ } catch (error) {
384
+ // The tree moved between the read above and this write.
385
+ if (error instanceof ApiError && error.hasField("ExpectedRevision")) throw navigationMovedRefusal();
386
+ throw error;
387
+ }
388
+ onProgress(`Replaced the navigation tree with ${validated.items} items.`);
389
+ }
361
390
  try {
362
391
  // The file now holds what the site stored, in the form pull writes, so the next pull is byte-identical.
363
- const stored = saved.length > 0 || navItems.length === 0 ? saved : navItems;
392
+ // Always the answer's tree, even an empty one: the recorded revision describes that response, and another
393
+ // editor may have changed the tree between this save and the site's read of it.
394
+ const stored = saved.navItems;
364
395
  // siteId and navItems first, in pull's order; any other key the author kept follows.
365
396
  const { siteId: _siteId, navItems: _navItems, ...extras } = parsed;
366
397
  await writeWorkspaceJson(config.workspaceDir, NAVIGATION_FILE_NAME, {
@@ -368,6 +399,9 @@ export async function navPush(invocation) {
368
399
  navItems: projectNavigationForWorkspace(stored),
369
400
  ...extras,
370
401
  });
402
+ // After the document, as redirects push orders them: if this write fails, the old baseline refuses the
403
+ // next push rather than letting it overwrite the tree just saved.
404
+ await advanceNavigationManifest(config.workspaceDir, siteId, { navItems: stored, revision: saved.revision });
371
405
  } catch (error) {
372
406
  // The site already holds the new tree; only the local file is stale, so say the tree was replaced.
373
407
  if (error instanceof SiteAuthoringError) throw error.withCompletedWrites(["navigationTree"]);
@@ -381,14 +415,13 @@ export async function navPush(invocation) {
381
415
  file: NAVIGATION_FILE_NAME,
382
416
  items: validated.items,
383
417
  rootItems: navItems.length,
384
- savedRootItems: saved.length,
418
+ savedRootItems: saved.navItems.length,
385
419
  added: addedReport.items,
386
420
  removed: removedReport.items,
387
421
  ...(addedReport.truncated || removedReport.truncated ? { diffTruncated: true } : {}),
422
+ revision: saved.revision,
423
+ ...(alreadyApplied ? { alreadyApplied: true } : {}),
388
424
  },
389
- // Said plainly, because it is the risk this verb carries: there is no
390
- // concurrency token on the navigation contract.
391
- lastWriteWins: true,
392
425
  });
393
426
  });
394
427
  }
@@ -0,0 +1,380 @@
1
+ import {
2
+ deletePage,
3
+ getNavigation,
4
+ getSiteRedirectMap,
5
+ isGonePage,
6
+ listSitePages,
7
+ NAV_ITEM_KIND_PAGE,
8
+ PAGE_STATUS_PUBLISHED,
9
+ withRefusalGuidance,
10
+ } from "../api.js";
11
+ import { VERB_PAGES_DELETE } from "../constants.js";
12
+ import { SiteAuthoringError } from "../errors.js";
13
+ import { FOOTER_SETTINGS_FILE } from "../footer-workspace.js";
14
+ import { afterSubmitPage, FORM_FILE_EXTENSION, FORMS_DIRECTORY, normalizePagePath as normalizeFormPagePath } from "../forms-contract.js";
15
+ import { normalizeRedirectPath } from "../site-checks.js";
16
+ import { boundedList, openSession, successResult, warnIfExternalWritesPaused } from "../session.js";
17
+ import { isAuthorableTemplateType, isGeneratedTemplateType } from "../typed-pages.js";
18
+ import {
19
+ deleteWorkspaceFile,
20
+ displayPagePath,
21
+ isDiscoverablePageSource,
22
+ internalPageBaselineFile,
23
+ internalPageObservedRevisionFile,
24
+ normalizePagePath,
25
+ pageSourceFormat,
26
+ pageSourceRegistry,
27
+ readManifest,
28
+ readObservedPageRecord,
29
+ readWorkspaceJson,
30
+ requireManifestSourceRegistry,
31
+ SYSTEM_PAGE_NOT_FOUND_PATH,
32
+ walkWorkspaceFiles,
33
+ WORKSPACE_LIMITS,
34
+ workspaceFileExists,
35
+ writeManifest,
36
+ } from "../workspace.js";
37
+ import { outcomeUnknown, requireReconciledRevision } from "./pages-push.js";
38
+
39
+ /**
40
+ * `pages delete` — remove one page this workspace tracks (TR01226).
41
+ *
42
+ * Deleting goes through the page delete the app uses, so the rules are the
43
+ * site's: a page that was never published is removed outright and its path is
44
+ * free again. A page the live site serves is not taken off it by the delete: the
45
+ * site stages the removal and the page stays live until a deployment promotes it
46
+ * (TR01278), so the CLI reports `removalStaged` and the staging loop finishes
47
+ * the job.
48
+ *
49
+ * The same fence as `pages push` runs first: a page edited on the site since
50
+ * this workspace last reconciled with it is refused with `pages.push_conflict`.
51
+ * Then the references a delete would leave dangling are checked: a navigation
52
+ * item, a footer link or a form's after-submit page refuses the delete (the
53
+ * site would drop the navigation item silently, which would also move the
54
+ * revision `nav push` is fenced by), while a redirect that lands on the path
55
+ * is only reported, because a redirect to a missing page is a broken link
56
+ * rather than a broken site.
57
+ *
58
+ * `--dry-run` runs every check and sends nothing. A page the site already
59
+ * deleted, or already staged for removal, is the retry of an interrupted run:
60
+ * only the workspace is cleaned.
61
+ */
62
+
63
+ const MAXIMUM_REPORTED = 50;
64
+
65
+ function refusal(code, message, field, alternatives) {
66
+ return new SiteAuthoringError(code, message, { field, ...(alternatives === undefined ? {} : { alternatives }) });
67
+ }
68
+
69
+ /** Every `pageResourceId` in a footer settings document that names the page. */
70
+ function footerLinksTo(value, resourceId, found = []) {
71
+ if (Array.isArray(value)) {
72
+ for (const item of value) footerLinksTo(item, resourceId, found);
73
+ } else if (value !== null && typeof value === "object") {
74
+ if (value.pageResourceId === resourceId) found.push(value.label ?? value.id ?? "a footer link");
75
+ for (const item of Object.values(value)) footerLinksTo(item, resourceId, found);
76
+ }
77
+ return found;
78
+ }
79
+
80
+ function navigationItemsTo(items, resourceId, trail = "navItems", found = []) {
81
+ items.forEach((item, index) => {
82
+ const at = `${trail}[${index}]`;
83
+ if (item?.kind === NAV_ITEM_KIND_PAGE && item.resourceId === resourceId) {
84
+ found.push({ at, title: typeof item.title === "string" ? item.title : "" });
85
+ }
86
+ if (Array.isArray(item?.children)) navigationItemsTo(item.children, resourceId, `${at}.children`, found);
87
+ });
88
+ return found;
89
+ }
90
+
91
+ /**
92
+ * Reads workspace files that may name the page, failing closed: a file that cannot be read could be the one that
93
+ * does, so its error refuses the delete rather than letting it pass unchecked.
94
+ */
95
+ async function readReferenceSource(read, what, shown) {
96
+ try {
97
+ return await read();
98
+ } catch (error) {
99
+ if (!(error instanceof SiteAuthoringError)) throw error;
100
+ throw refusal(
101
+ "pages.delete_references_unreadable",
102
+ `Could not read ${what} to check whether it uses '${shown}': ${error.message} Nothing was deleted. Fix or `
103
+ + "remove it, then try again.",
104
+ error.field ?? shown,
105
+ );
106
+ }
107
+ }
108
+
109
+ /** The workspace's form files whose after-submit page is this path or resource. */
110
+ async function formsSendingTo(workspaceDir, pagePath, resourceId) {
111
+ const shown = displayPagePath(pagePath);
112
+ const files = await readReferenceSource(
113
+ async () => await walkWorkspaceFiles(workspaceDir, FORMS_DIRECTORY, [FORM_FILE_EXTENSION]),
114
+ `the ${FORMS_DIRECTORY}/ folder`,
115
+ shown,
116
+ );
117
+ const found = [];
118
+ for (const file of files) {
119
+ const document_ = await readReferenceSource(
120
+ async () => await readWorkspaceJson(workspaceDir, file, WORKSPACE_LIMITS.documentBytes),
121
+ file,
122
+ shown,
123
+ );
124
+ const after = afterSubmitPage(document_?.definition);
125
+ if (after === undefined) continue;
126
+ // A form names its page by path, or by resource id when the page was not in the path index at pull.
127
+ const byPath = typeof after.page_path === "string"
128
+ && normalizeFormPagePath(after.page_path) === normalizeFormPagePath(`/${pagePath}`);
129
+ if (byPath || after.page_resource_id === resourceId) found.push(file);
130
+ }
131
+ return found;
132
+ }
133
+
134
+ async function footerLinks(workspaceDir, resourceId, shown) {
135
+ if (!await workspaceFileExists(workspaceDir, FOOTER_SETTINGS_FILE)) return [];
136
+ return footerLinksTo(
137
+ await readReferenceSource(
138
+ async () => await readWorkspaceJson(workspaceDir, FOOTER_SETTINGS_FILE, WORKSPACE_LIMITS.documentBytes),
139
+ FOOTER_SETTINGS_FILE,
140
+ shown,
141
+ ),
142
+ resourceId,
143
+ );
144
+ }
145
+
146
+ function redirectsLandingOn(entries, pagePath) {
147
+ const target = normalizeRedirectPath(`/${pagePath}`);
148
+ return entries
149
+ .filter((entry) => typeof entry.target === "string" && normalizeRedirectPath(entry.target) === target)
150
+ .map((entry) => entry.path);
151
+ }
152
+
153
+ /** The live site serves this page, so the site stages its removal instead of deleting it. */
154
+ function servedLive(summary) {
155
+ return summary !== undefined && (summary.status === PAGE_STATUS_PUBLISHED || summary.datePublished !== "");
156
+ }
157
+
158
+ function trackedEntry(manifest, pagePath) {
159
+ return manifest.pages.find((entry) => entry !== null && normalizePagePath(entry.path) === pagePath);
160
+ }
161
+
162
+ function requireDeletable(manifest, entry, pagePath) {
163
+ const shown = displayPagePath(pagePath);
164
+ if (entry === undefined) {
165
+ throw refusal(
166
+ "pages.delete_page_unknown",
167
+ `No page this workspace tracks is at '${shown}'. Run 'taproot-site pull' if it was created elsewhere.`,
168
+ shown,
169
+ );
170
+ }
171
+ // The entry names a file this verb unlinks and a page it deletes, so a hand-edited manifest is refused first.
172
+ requireManifestSourceRegistry(pageSourceRegistry(manifest));
173
+ if (typeof entry.pageId !== "string" || entry.pageId === "") {
174
+ throw refusal(
175
+ "pages.delete_manifest_invalid",
176
+ `The manifest entry for '${shown}' records no page id. Run 'taproot-site pull' to rebuild it.`,
177
+ shown,
178
+ );
179
+ }
180
+ if (entry.file !== undefined && (typeof entry.file !== "string" || !isDiscoverablePageSource(entry.file) || pageSourceFormat(entry.file) === undefined)) {
181
+ throw refusal(
182
+ "pages.delete_manifest_invalid",
183
+ `The manifest entry for '${shown}' names a file that is not a page source. Run 'taproot-site pull' to rebuild it.`,
184
+ shown,
185
+ );
186
+ }
187
+ if (pagePath === "" || pagePath.toLowerCase() === SYSTEM_PAGE_NOT_FOUND_PATH) {
188
+ throw refusal("pages.delete_system", `'${shown}' is a system page; the site cannot be without it.`, shown);
189
+ }
190
+ if (isGeneratedTemplateType(entry.templateType) || entry.isGenerated === true) {
191
+ throw refusal(
192
+ "pages.delete_generated",
193
+ `'${shown}' is a page Taproot generates from the site's content; remove the content that produces it.`,
194
+ shown,
195
+ );
196
+ }
197
+ if (!isAuthorableTemplateType(entry.templateType)) {
198
+ throw refusal(
199
+ "pages.delete_not_authorable",
200
+ `'${shown}' is a ${entry.templateType ?? "system-managed"} page, which this CLI does not author; delete it in the app.`,
201
+ shown,
202
+ );
203
+ }
204
+ }
205
+
206
+ export async function pagesDelete(invocation) {
207
+ const session = await openSession(invocation);
208
+ const { client, config, siteId, onProgress } = session;
209
+ const dryRun = invocation.dryRun === true;
210
+ if (!dryRun) warnIfExternalWritesPaused(session, VERB_PAGES_DELETE);
211
+ const pagePath = normalizePagePath(invocation.deletePagePath);
212
+ if (pagePath === undefined) {
213
+ throw new SiteAuthoringError("pages.delete_path_invalid", "The page path is not usable.", {
214
+ field: "pagePath",
215
+ exitCode: 2,
216
+ });
217
+ }
218
+ const shown = displayPagePath(pagePath);
219
+ const manifest = await readManifest(config.workspaceDir, siteId);
220
+ const entry = trackedEntry(manifest, pagePath);
221
+ requireDeletable(manifest, entry, pagePath);
222
+ // A link or directory where a workspace file should be would fail the cleanup after the site delete, and
223
+ // every retry would meet it again, so it is refused before anything is sent.
224
+ const cleanupFiles = [
225
+ ...(typeof entry.file === "string" ? [entry.file] : []),
226
+ ...[internalPageBaselineFile(entry.pageId), internalPageObservedRevisionFile(entry.pageId)].filter(
227
+ (internal) => internal !== undefined,
228
+ ),
229
+ ];
230
+ for (const file of cleanupFiles) await workspaceFileExists(config.workspaceDir, file);
231
+
232
+ return await withRefusalGuidance(onProgress, "page delete", async () => {
233
+ onProgress(`Reading the site's pages to check '${shown}'.`);
234
+ const { pages, truncated } = await listSitePages(client, siteId, { onProgress });
235
+ const live = pages.find((summary) => summary.pageId === entry.pageId);
236
+ if (truncated && live === undefined) {
237
+ throw refusal(
238
+ "pages.delete_list_incomplete",
239
+ `The site has more pages than this CLI could list, so it cannot tell whether '${shown}' still exists. `
240
+ + "Nothing was deleted.",
241
+ shown,
242
+ );
243
+ }
244
+ const alreadyGone = live === undefined || isGonePage(live);
245
+ if (!alreadyGone) {
246
+ // The fence `pages push` uses, against the revision this workspace last reconciled with.
247
+ requireReconciledRevision({
248
+ file: entry.file ?? shown,
249
+ pagePath,
250
+ target: live,
251
+ entry,
252
+ observedRevision: (await readObservedPageRecord(config.workspaceDir, live.pageId))?.revision,
253
+ deleting: true,
254
+ });
255
+ }
256
+
257
+ const references = { navigation: [], footer: [], forms: [] };
258
+ let redirects = [];
259
+ if (!alreadyGone) {
260
+ onProgress("Checking what refers to the page.");
261
+ references.navigation = navigationItemsTo((await getNavigation(client, siteId)).navItems, live.resourceId);
262
+ references.footer = await footerLinks(config.workspaceDir, live.resourceId, shown);
263
+ references.forms = await formsSendingTo(config.workspaceDir, pagePath, live.resourceId);
264
+ if (references.navigation.length + references.footer.length + references.forms.length > 0) {
265
+ throw refusal(
266
+ "pages.delete_referenced",
267
+ `'${shown}' is still used by ${
268
+ [
269
+ references.navigation.length > 0 && `${references.navigation.length} navigation item(s)`,
270
+ references.footer.length > 0 && `${references.footer.length} footer link(s)`,
271
+ references.forms.length > 0 && `${references.forms.length} form(s)`,
272
+ ].filter(Boolean).join(", ")
273
+ }. Remove those first (edit nav.json and run 'nav push'; edit the footer and run 'footer push'; point `
274
+ + "the form's after_submit elsewhere and run 'forms push'), then delete the page. Nothing was deleted.",
275
+ shown,
276
+ [
277
+ ...references.navigation.map(({ at, title }) => `${at}${title === "" ? "" : ` (${title})`}`),
278
+ ...references.footer.map((label) => `footer: ${label}`),
279
+ ...references.forms,
280
+ ],
281
+ );
282
+ }
283
+ redirects = redirectsLandingOn((await getSiteRedirectMap(client, siteId)).entries, pagePath);
284
+ }
285
+
286
+ const report = {
287
+ page: {
288
+ path: shown,
289
+ ...(entry.file === undefined ? {} : { file: entry.file }),
290
+ pageId: entry.pageId,
291
+ title: entry.title,
292
+ },
293
+ // A page the live site serves stays live until a deployment removes it; the dry run predicts that from
294
+ // the status, and a real delete reads it from the site's answer.
295
+ // A page published and being edited looks the same as a restored draft that is not live, so only a
296
+ // PUBLISHED status predicts the staged removal; the ambiguous case names no effect until the site answers.
297
+ ...(alreadyGone
298
+ ? { effect: live?.removalStaged === true ? "alreadyRemovalStaged" : "alreadyDeleted" }
299
+ : live.status === PAGE_STATUS_PUBLISHED
300
+ ? { effect: "removalStaged" }
301
+ : servedLive(live)
302
+ ? {}
303
+ : { effect: "removed" }),
304
+ ...(live?.removalStaged === true ? { removalStaged: true } : {}),
305
+ ...(redirects.length === 0
306
+ ? {}
307
+ : {
308
+ redirectsToPage: boundedList(redirects, MAXIMUM_REPORTED).items,
309
+ redirectsToPageCount: redirects.length,
310
+ }),
311
+ };
312
+ if (redirects.length > 0) {
313
+ onProgress(
314
+ `${redirects.length} redirect(s) land on '${shown}' and will point at a missing page. `
315
+ + "Edit them with 'redirects pull' and 'redirects push', or leave them.",
316
+ );
317
+ }
318
+ if (dryRun) {
319
+ onProgress(
320
+ alreadyGone
321
+ ? `Dry run: '${shown}' is already ${
322
+ live?.removalStaged === true ? "staged for removal" : "gone from the site"
323
+ }; a delete would only clean the workspace.`
324
+ : live.status === PAGE_STATUS_PUBLISHED
325
+ ? `Dry run: '${shown}' is live, so deleting it stages its removal and it stays live until you deploy. `
326
+ + "Nothing was sent."
327
+ : servedLive(live)
328
+ ? `Dry run: '${shown}' has been published. If the site serves it, the delete stages its removal and it `
329
+ + "stays live until you deploy; otherwise it is deleted at once. Nothing was sent."
330
+ : `Dry run: '${shown}' would be deleted. Nothing was sent.`,
331
+ );
332
+ return successResult(VERB_PAGES_DELETE, siteId, { dryRun: true, ...report });
333
+ }
334
+
335
+ let removalStaged = false;
336
+ if (!alreadyGone) {
337
+ onProgress(`Deleting '${shown}'.`);
338
+ try {
339
+ removalStaged = (await deletePage(client, entry.pageId)).removalStaged;
340
+ } catch (error) {
341
+ // A reply that never arrived may still have deleted the page; the retry meets it already gone.
342
+ // A reply that arrived but cannot be read (api.page_contract) follows a delete the site may have run.
343
+ if (error instanceof SiteAuthoringError && (outcomeUnknown(error) || error.code === "api.page_contract")) {
344
+ throw error.withCompletedWrites([`page ${shown}: unknown`]);
345
+ }
346
+ throw error;
347
+ }
348
+ }
349
+
350
+ // The source goes with the page: left behind, a whole-workspace push would create it again.
351
+ try {
352
+ for (const file of cleanupFiles) await deleteWorkspaceFile(config.workspaceDir, file);
353
+ manifest.pages = manifest.pages.filter((candidate) => candidate !== entry);
354
+ await writeManifest(config.workspaceDir, manifest);
355
+ } catch (error) {
356
+ if (error instanceof SiteAuthoringError) {
357
+ throw error.withCompletedWrites([`page ${shown}: ${removalStaged ? "removal staged" : "deleted"}`]);
358
+ }
359
+ throw error;
360
+ }
361
+ onProgress(
362
+ removalStaged
363
+ ? `Removal staged for '${shown}': it stays live until you deploy. It is removed from the workspace.`
364
+ : `Deleted '${shown}' and removed it from the workspace.`,
365
+ );
366
+ return successResult(VERB_PAGES_DELETE, siteId, {
367
+ ...report,
368
+ // The effect the site reported, which a dry run could only predict.
369
+ ...(alreadyGone ? {} : { effect: removalStaged ? "removalStaged" : "removed" }),
370
+ // True only when the page is gone from the site now; a staged removal is still live, so read `effect`.
371
+ deleted: !alreadyGone && !removalStaged,
372
+ ...(removalStaged ? { removalStaged: true } : {}),
373
+ // Site writes only: a retry that finds the page already gone sends nothing.
374
+ ...(alreadyGone ? {} : { written: { items: [`page ${shown}`], count: 1 } }),
375
+ // A staged removal reaches the live site only through a deployment; a page deleted at once may have
376
+ // been in the staging candidate, which a new staging deploy refreshes.
377
+ nextStep: "deploy --staging",
378
+ });
379
+ });
380
+ }
@@ -1,10 +1,10 @@
1
1
  import {
2
2
  createPage,
3
3
  importVideoEmbedPoster,
4
+ isGonePage,
4
5
  listSitePages,
5
6
  listVideos,
6
7
  IMAGE_PROCESSING_STATE_COMPLETE,
7
- PAGE_STATUS_DELETED,
8
8
  listSiteAuthors,
9
9
  posterFromImage,
10
10
  translateAuthorRefusal,
@@ -146,7 +146,7 @@ const OUTCOME_UNKNOWN_CODES = new Set([
146
146
  "api.page_contract",
147
147
  ]);
148
148
 
149
- function outcomeUnknown(error) {
149
+ export function outcomeUnknown(error) {
150
150
  // A cancelled retry of a write, and a server error on a write the transport
151
151
  // does not replay, leave the same question open.
152
152
  return OUTCOME_UNKNOWN_CODES.has(error.code) || error.ambiguousMutation === true || error.httpStatus >= 500;
@@ -613,7 +613,7 @@ export async function readWorkspacePageSource({ workspaceDir, file, manifestEntr
613
613
  * Both leave this exactly where TR00622 left it, which is where the caller
614
614
  * already is.
615
615
  */
616
- function requireReconciledRevision({ file, pagePath, target, entry, observedRevision }) {
616
+ export function requireReconciledRevision({ file, pagePath, target, entry, observedRevision, deleting = false }) {
617
617
  // What the operator was shown by a refused pull outranks what the manifest
618
618
  // last reconciled with. Without that, this guard would also refuse the pull
619
619
  // conflict's own documented recovery, and the only way out of a conflict
@@ -622,12 +622,16 @@ function requireReconciledRevision({ file, pagePath, target, entry, observedRevi
622
622
  const live = target.bodyRevision;
623
623
  if (recorded === undefined || live === undefined || recorded === live) return;
624
624
  const selector = pagePath || "/";
625
+ // `pages delete` shares the fence (TR01226): the same code, with the way out that fits a delete.
626
+ const outcome = deleting
627
+ ? `No page was deleted. Run 'taproot-site pull' to see what differs and to adopt the site's version, then `
628
+ + "decide again."
629
+ : `No page was pushed. Run 'taproot-site pull' to see what differs and to adopt the site's version, or delete `
630
+ + `'${file}' and pull again to take the site's document as this page's source.`;
625
631
  throw new SiteAuthoringError(
626
632
  "pages.push_conflict",
627
633
  `Page '${selector}' changed on the site since this workspace last reconciled with it: it now reports revision `
628
- + `${live}, and '${file}' was written against ${recorded}. No page was pushed. Run `
629
- + `'taproot-site pull' to see what differs and to adopt the site's version, or delete '${file}' and pull `
630
- + "again to take the site's document as this page's source.",
634
+ + `${live}, and '${file}' was written against ${recorded}. ${outcome}`,
631
635
  { field: file, alternatives: [recorded, live] },
632
636
  );
633
637
  }
@@ -1110,7 +1114,7 @@ export async function planPages({
1110
1114
  if (problems.length > 0) {
1111
1115
  return { targeted, sources: [], selected: [], planned: [], unresolved: [], staleGenerated, problems };
1112
1116
  }
1113
- const live = livePages.filter((summary) => summary.status !== PAGE_STATUS_DELETED);
1117
+ const live = livePages.filter((summary) => !isGonePage(summary));
1114
1118
  const liveById = new Map(live.map((summary) => [summary.pageId, summary]));
1115
1119
  const liveByPath = new Map(live.map((summary) => [normalizePagePath(summary.path) ?? summary.path, summary]));
1116
1120
  const manifestByFile = new Map(