@taprootio/site-authoring 0.22.3 → 0.23.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 +52 -3
- package/package.json +1 -1
- package/src/api.js +40 -13
- package/src/changelog.json +31 -0
- package/src/cli.js +157 -12
- package/src/constants.js +17 -0
- package/src/footer-workspace.js +2 -1
- package/src/navigation-workspace.js +73 -0
- package/src/presentation-reference.js +1 -0
- package/src/redirects-contract.js +2 -1
- package/src/redirects-workspace.js +2 -2
- package/src/reference-help.js +20 -6
- package/src/setting-reference.js +9 -0
- package/src/settings-catalog.js +3 -0
- package/src/theme-resolve.js +252 -0
- package/src/transport.js +2 -2
- package/src/verbs/approve.js +3 -3
- package/src/verbs/deploy.js +3 -5
- package/src/verbs/index.js +7 -0
- package/src/verbs/nav-push.js +56 -23
- package/src/verbs/pages-delete.js +380 -0
- package/src/verbs/pages-push.js +11 -7
- package/src/verbs/plan.js +31 -5
- package/src/verbs/pull.js +8 -5
- package/src/verbs/staging-review.js +3 -2
- package/src/verbs/theme-push.js +31 -21
- package/src/verbs/theme-resolve.js +98 -0
- package/src/workspace.js +3 -3
package/src/verbs/nav-push.js
CHANGED
|
@@ -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
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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
|
|
37
|
-
*
|
|
38
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
336
|
-
//
|
|
337
|
-
//
|
|
338
|
-
//
|
|
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
|
|
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
|
-
|
|
360
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/verbs/pages-push.js
CHANGED
|
@@ -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}.
|
|
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
|
|
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(
|