@taprootio/site-authoring 0.15.0 → 0.18.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 +171 -11
- package/package.json +1 -1
- package/src/api.js +194 -11
- package/src/authors-contract.js +170 -0
- package/src/cli.js +252 -12
- package/src/constants.js +19 -0
- package/src/content/link-warnings.js +109 -0
- package/src/fixture-init.js +55 -3
- package/src/page-metadata.js +92 -0
- package/src/reference-help.js +58 -8
- package/src/staging-check.js +62 -0
- package/src/transport.js +73 -12
- package/src/typed-page-reference.js +89 -19
- package/src/typed-pages.js +165 -4
- package/src/verbs/approve.js +63 -14
- package/src/verbs/authors.js +167 -0
- package/src/verbs/deploy.js +29 -32
- package/src/verbs/index.js +18 -1
- package/src/verbs/media-upload.js +0 -1
- package/src/verbs/pages-meta-set.js +303 -0
- package/src/verbs/pages-push.js +419 -36
- package/src/verbs/places.js +72 -2
- package/src/verbs/plan.js +11 -1
- package/src/verbs/pull.js +255 -20
- package/src/verbs/redirects-check.js +4 -10
- package/src/verbs/status.js +1 -4
- package/src/verbs/validate.js +47 -2
- package/src/workspace.js +58 -6
package/src/verbs/places.js
CHANGED
|
@@ -1,7 +1,21 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
|
|
3
|
+
import {
|
|
4
|
+
clearPlaceCategory,
|
|
5
|
+
isGooglePlaceId,
|
|
6
|
+
listPlaceCategories,
|
|
7
|
+
searchPlaces,
|
|
8
|
+
selectPlace,
|
|
9
|
+
setPlaceCategory,
|
|
10
|
+
withRefusalGuidance,
|
|
11
|
+
} from "../api.js";
|
|
12
|
+
import {
|
|
13
|
+
VERB_PLACES_CATEGORY_CLEAR,
|
|
14
|
+
VERB_PLACES_CATEGORY_LIST,
|
|
15
|
+
VERB_PLACES_CATEGORY_SET,
|
|
16
|
+
VERB_PLACES_SEARCH,
|
|
17
|
+
VERB_PLACES_SELECT,
|
|
18
|
+
} from "../constants.js";
|
|
5
19
|
import { SiteAuthoringError } from "../errors.js";
|
|
6
20
|
import { openSession, successResult } from "../session.js";
|
|
7
21
|
|
|
@@ -18,6 +32,9 @@ import { openSession, successResult } from "../session.js";
|
|
|
18
32
|
*/
|
|
19
33
|
|
|
20
34
|
const MAXIMUM_QUERY_LENGTH = 200;
|
|
35
|
+
const MAXIMUM_CATEGORY_LENGTH = 100;
|
|
36
|
+
// Any UUID version: the server mints place ids from a sequential-style generator.
|
|
37
|
+
const PLACE_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u;
|
|
21
38
|
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
39
|
|
|
23
40
|
export async function placesSearch(invocation) {
|
|
@@ -59,3 +76,56 @@ export async function placesSelect(invocation) {
|
|
|
59
76
|
onProgress(`Selected ${place.name}: use placeId ${place.placeId} in the page's front matter.`);
|
|
60
77
|
return successResult(VERB_PLACES_SELECT, siteId, { place });
|
|
61
78
|
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* `places category set|clear|list` — this site's own category for a place
|
|
82
|
+
* (TR01203).
|
|
83
|
+
*
|
|
84
|
+
* A place's category is shared by every site, and Google's types do not always
|
|
85
|
+
* give the one an owner wants. The override is the site's alone: its place
|
|
86
|
+
* reviews and `/places/<category>` pages use it from the next deployment, and
|
|
87
|
+
* the platform-wide place is never changed. The server owns the closed list, so
|
|
88
|
+
* the CLI sends the name as given and lets the server check it.
|
|
89
|
+
*/
|
|
90
|
+
export async function placesCategorySet(invocation) {
|
|
91
|
+
const [placeId, category, ...extra] = Array.isArray(invocation.placeCategory) ? invocation.placeCategory : [];
|
|
92
|
+
if (!PLACE_ID.test(placeId ?? "") || extra.length > 0 || typeof category !== "string"
|
|
93
|
+
|| category.trim() === "" || category.length > MAXIMUM_CATEGORY_LENGTH) {
|
|
94
|
+
throw new SiteAuthoringError(
|
|
95
|
+
"places.category_invalid",
|
|
96
|
+
"places category set takes the placeId, then one category from 'places category list'.",
|
|
97
|
+
{ field: "category", exitCode: 2 },
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
const session = await openSession(invocation);
|
|
101
|
+
const { client, siteId, onProgress } = session;
|
|
102
|
+
const state = await withRefusalGuidance(onProgress, "place category", async () =>
|
|
103
|
+
await setPlaceCategory(client, siteId, { placeId, category: category.trim() }));
|
|
104
|
+
onProgress(`This site now files ${placeId} under ${state.category}; deploy for pages to use it.`);
|
|
105
|
+
return successResult(VERB_PLACES_CATEGORY_SET, siteId, { placeCategory: state });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function placesCategoryClear(invocation) {
|
|
109
|
+
const [placeId, ...extra] = Array.isArray(invocation.placeId) ? invocation.placeId : [];
|
|
110
|
+
if (!PLACE_ID.test(placeId ?? "") || extra.length > 0) {
|
|
111
|
+
throw new SiteAuthoringError(
|
|
112
|
+
"places.category_invalid",
|
|
113
|
+
"places category clear takes the placeId of the place whose category this site set.",
|
|
114
|
+
{ field: "placeId", exitCode: 2 },
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
const session = await openSession(invocation);
|
|
118
|
+
const { client, siteId, onProgress } = session;
|
|
119
|
+
const state = await withRefusalGuidance(onProgress, "place category", async () =>
|
|
120
|
+
await clearPlaceCategory(client, siteId, { placeId }));
|
|
121
|
+
onProgress(`This site files ${placeId} under ${state.category} again; deploy for pages to use it.`);
|
|
122
|
+
return successResult(VERB_PLACES_CATEGORY_CLEAR, siteId, { placeCategory: state });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function placesCategoryList(invocation) {
|
|
126
|
+
const session = await openSession(invocation);
|
|
127
|
+
const { client, siteId, onProgress } = session;
|
|
128
|
+
const categories = await withRefusalGuidance(onProgress, "place categories", async () =>
|
|
129
|
+
await listPlaceCategories(client));
|
|
130
|
+
return successResult(VERB_PLACES_CATEGORY_LIST, siteId, { categories });
|
|
131
|
+
}
|
package/src/verbs/plan.js
CHANGED
|
@@ -42,7 +42,14 @@ import {
|
|
|
42
42
|
import { footerPush } from "./footer-push.js";
|
|
43
43
|
import { mediaUpload, pendingMediaFiles } from "./media-upload.js";
|
|
44
44
|
import { navPush, validateNavigationWorkspaceDocument } from "./nav-push.js";
|
|
45
|
-
import {
|
|
45
|
+
import {
|
|
46
|
+
liveAuthorsRefresher,
|
|
47
|
+
pagesPush,
|
|
48
|
+
placedVideoProblems,
|
|
49
|
+
planPages,
|
|
50
|
+
plannedPageItem,
|
|
51
|
+
staleGeneratedSourcesField,
|
|
52
|
+
} from "./pages-push.js";
|
|
46
53
|
import { comparePresentation, presentationMovedRefusal, presentationPullRequired, themePush } from "./theme-push.js";
|
|
47
54
|
|
|
48
55
|
/**
|
|
@@ -265,6 +272,8 @@ async function preflight(session, invocation) {
|
|
|
265
272
|
files: await walkWorkspaceFiles(workspaceDir, PAGES_DIRECTORY, PAGE_SOURCE_EXTENSIONS),
|
|
266
273
|
livePages,
|
|
267
274
|
online: true,
|
|
275
|
+
// Asked of the site but never written down: `plan` leaves the workspace as it found it.
|
|
276
|
+
refreshAuthors: liveAuthorsRefresher({ client, workspaceDir, siteId, write: false, onProgress }),
|
|
268
277
|
// From the theme validated above, so a broken styles file is one problem, not one per page.
|
|
269
278
|
getSharedThemeContexts: async () =>
|
|
270
279
|
offline.presentation === undefined
|
|
@@ -370,6 +379,7 @@ async function preflight(session, invocation) {
|
|
|
370
379
|
create: sending.filter((page) => page.action === "created").length,
|
|
371
380
|
update: sending.filter((page) => page.action === "updated").length,
|
|
372
381
|
unchanged: pages.planned.length - sending.length,
|
|
382
|
+
...staleGeneratedSourcesField(pages.staleGenerated),
|
|
373
383
|
items: pagesItems.items,
|
|
374
384
|
...(pagesItems.truncated ? { itemsTruncated: true } : {}),
|
|
375
385
|
}),
|
package/src/verbs/pull.js
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
getSettingsGroup,
|
|
5
5
|
getSitePresentation,
|
|
6
6
|
getSiteRedirectMap,
|
|
7
|
+
listSiteAuthors,
|
|
7
8
|
listSitePages,
|
|
8
9
|
PAGE_STATUS_DELETED,
|
|
9
10
|
PAGE_STATUS_DRAFT,
|
|
@@ -16,6 +17,7 @@ import {
|
|
|
16
17
|
SURFACE_DOCS_PRESENTATION,
|
|
17
18
|
VERB_PULL,
|
|
18
19
|
} from "../constants.js";
|
|
20
|
+
import { AUTHORS_FILE_NAME, parseAuthorReference, projectAuthorsForWorkspace } from "../authors-contract.js";
|
|
19
21
|
import { sanitizeDiagnostic, SiteAuthoringError } from "../errors.js";
|
|
20
22
|
import { appearanceManifestEntry, footerManifestEntry, presentationManifestEntry } from "../footer-workspace.js";
|
|
21
23
|
import { describeJsonDifferences, reportableDifferencePaths } from "../json-path-diff.js";
|
|
@@ -31,8 +33,15 @@ import {
|
|
|
31
33
|
SETTINGS_TYPE_TAPROOT_STYLES,
|
|
32
34
|
} from "../settings-catalog.js";
|
|
33
35
|
import { ApiError } from "../transport.js";
|
|
34
|
-
import { isAuthorableTemplateType, sentDisplayDate, workspaceDocumentFromPage } from "../typed-pages.js";
|
|
35
36
|
import {
|
|
37
|
+
generatedIdentity,
|
|
38
|
+
isAuthorableTemplateType,
|
|
39
|
+
isGeneratedTemplateType,
|
|
40
|
+
sentDisplayDate,
|
|
41
|
+
workspaceDocumentFromPage,
|
|
42
|
+
} from "../typed-pages.js";
|
|
43
|
+
import {
|
|
44
|
+
displayPagePath,
|
|
36
45
|
canonicalDocumentHash,
|
|
37
46
|
deleteWorkspaceFile,
|
|
38
47
|
ensureWorkspaceRoot,
|
|
@@ -52,6 +61,7 @@ import {
|
|
|
52
61
|
PAGE_WORKSPACE_MODE_EDITABLE,
|
|
53
62
|
PAGE_WORKSPACE_MODE_METADATA_ONLY,
|
|
54
63
|
PAGES_DIRECTORY,
|
|
64
|
+
isDiscoverablePageSource,
|
|
55
65
|
pageSourceFormat,
|
|
56
66
|
pageSourceRegistry,
|
|
57
67
|
readObservedPageRecord,
|
|
@@ -117,6 +127,57 @@ const APPEARANCE_SETTINGS_TYPES = Object.freeze([
|
|
|
117
127
|
SETTINGS_TYPE_SITE_HEADER,
|
|
118
128
|
]);
|
|
119
129
|
|
|
130
|
+
/** The well-formed `retiredGeneratedSources` records of a previous manifest. */
|
|
131
|
+
function retiredSourceRecords(value) {
|
|
132
|
+
return (Array.isArray(value) ? value : []).filter((record) =>
|
|
133
|
+
record !== null && typeof record === "object" && typeof record.pageId === "string"
|
|
134
|
+
&& typeof record.file === "string" && typeof record.path === "string"
|
|
135
|
+
&& pageSourceFormat(record.file) !== undefined && isDiscoverablePageSource(record.file)
|
|
136
|
+
).map(({ pageId, file, path }) => ({ pageId, file, path }));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Whether a .pm.json page's title, path or description in the manifest moved
|
|
141
|
+
* since the last pull or push agreed on them (`pages meta set`, TR01194). The
|
|
142
|
+
* recorded content key covers exactly those and the source bytes.
|
|
143
|
+
*/
|
|
144
|
+
function metadataEditedLocally(source, templateType) {
|
|
145
|
+
return source.sourceFormat !== PAGE_SOURCE_FORMAT_MARKDOWN
|
|
146
|
+
&& !isGeneratedTemplateType(templateType)
|
|
147
|
+
&& source.baseline?.contentKey !== undefined
|
|
148
|
+
&& source.baseline?.sourceHash !== undefined
|
|
149
|
+
&& pageContentKey(source.baseline.sourceHash, source.metadata) !== source.baseline.contentKey;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* A baseline recording the author the site holds now (TR01196). The reference
|
|
154
|
+
* compared against before an author is sent, so it has to move with every pull,
|
|
155
|
+
* kept edits included.
|
|
156
|
+
*/
|
|
157
|
+
function withHeldAuthor(baseline, author) {
|
|
158
|
+
if (baseline === undefined) return author === "" ? undefined : { author };
|
|
159
|
+
const { author: _previous, ...rest } = baseline;
|
|
160
|
+
return author === "" ? rest : { ...rest, author };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Says so when a kept `pages meta set --author` names someone other than the
|
|
165
|
+
* author the site now holds. The edit stays (a pull never discards the
|
|
166
|
+
* operator's work), but a push of it would be refused as pages.author_conflict,
|
|
167
|
+
* so the way out is named here instead of at the push (TR01196).
|
|
168
|
+
*/
|
|
169
|
+
function reportPendingAuthorDisagreement(source, summary, onProgress) {
|
|
170
|
+
const pending = parseAuthorReference(source.metadata?.author)?.value ?? "";
|
|
171
|
+
const held = parseAuthorReference(summary.authorRef)?.value ?? "";
|
|
172
|
+
if (pending === "" || held === "" || pending === held) return;
|
|
173
|
+
const shown = summary.path === "" ? "/" : summary.path;
|
|
174
|
+
onProgress(
|
|
175
|
+
`Kept the local author '${pending}' of '${source.file}', but the site credits '${held}', and an author is never `
|
|
176
|
+
+ `replaced from the CLI. Run 'taproot-site pages meta set ${shown} --author ${held}' to match the site; `
|
|
177
|
+
+ "to credit someone else, change it in the app.",
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
120
181
|
/**
|
|
121
182
|
* The refusal for a remote body this workspace cannot represent.
|
|
122
183
|
*
|
|
@@ -129,13 +190,19 @@ function pullConflict(conflicts) {
|
|
|
129
190
|
const first = conflicts[0];
|
|
130
191
|
const selector = first.pagePath || "/";
|
|
131
192
|
const remaining = conflicts.length - 1;
|
|
193
|
+
const local = first.metadataOnly
|
|
194
|
+
? " and this workspace's title, path or description for it (pages meta set)"
|
|
195
|
+
: first.localChanged
|
|
196
|
+
? " and in this workspace"
|
|
197
|
+
: "";
|
|
132
198
|
return new SiteAuthoringError(
|
|
133
199
|
"pages.pull_conflict",
|
|
134
|
-
`Page '${selector}' changed on the site${
|
|
200
|
+
`Page '${selector}' changed on the site${local} since the last `
|
|
135
201
|
+ `pull, and '${first.file}' cannot be rewritten from the site's document. Nothing under '${PAGES_DIRECTORY}/' `
|
|
136
202
|
+ `was changed, and the site's version is preserved at '${first.baselineFile}'. Either run `
|
|
137
203
|
+ `'taproot-site pages push ${selector}' to make the site match '${first.file}', or delete '${first.file}' `
|
|
138
|
-
+ `and pull again to adopt the site's version as this page's source
|
|
204
|
+
+ `and pull again to adopt the site's version as this page's source`
|
|
205
|
+
+ (first.metadataOnly ? ", which also drops the local title, path and description." : ".")
|
|
139
206
|
+ (remaining > 0 ? ` ${remaining} other tracked page(s) are in the same state.` : "")
|
|
140
207
|
// Last, so the diagnostic bounds itself: the message is cut at
|
|
141
208
|
// LIMITS.diagnosticScalars, and twenty long paths must never cut off the
|
|
@@ -284,8 +351,10 @@ function bodyStatusFor(summary) {
|
|
|
284
351
|
* produces the same file names on every pull, whatever order the listing
|
|
285
352
|
* arrives in. The manifest remains the authority on which page a file is.
|
|
286
353
|
*/
|
|
287
|
-
function assignPageFiles(pages, tracked) {
|
|
288
|
-
|
|
354
|
+
function assignPageFiles(pages, tracked, kept = new Set()) {
|
|
355
|
+
// `kept` holds files this pull must not write over: the sources of retired
|
|
356
|
+
// generated pages, which may hold unpushed edits (TR01195).
|
|
357
|
+
const reserved = new Set([...pages.map((summary) => `${PAGES_DIRECTORY}/${summary.pageId}.pm.json`), ...kept]);
|
|
289
358
|
const claimed = new Set();
|
|
290
359
|
const assigned = new Map();
|
|
291
360
|
for (const summary of pages) {
|
|
@@ -556,8 +625,8 @@ async function requireWorkspaceBelongsToSite(workspaceDir, siteId) {
|
|
|
556
625
|
PAGES_DIRECTORY,
|
|
557
626
|
);
|
|
558
627
|
}
|
|
559
|
-
// These
|
|
560
|
-
for (const artifact of [NAVIGATION_FILE_NAME, MEDIA_MANIFEST_FILE_NAME, REDIRECTS_FILE_NAME]) {
|
|
628
|
+
// These four do carry a site, so they can clear themselves.
|
|
629
|
+
for (const artifact of [NAVIGATION_FILE_NAME, MEDIA_MANIFEST_FILE_NAME, REDIRECTS_FILE_NAME, AUTHORS_FILE_NAME]) {
|
|
561
630
|
const artifactBinding = await inspectArtifactSiteBinding(workspaceDir, artifact);
|
|
562
631
|
if (artifactBinding.state === "absent") continue;
|
|
563
632
|
if (artifactBinding.state === "bound" && artifactBinding.siteId === siteId) continue;
|
|
@@ -600,8 +669,74 @@ export async function pull(invocation) {
|
|
|
600
669
|
}
|
|
601
670
|
const live = pages.filter((summary) => summary.status !== PAGE_STATUS_DELETED);
|
|
602
671
|
const authorable = live.filter((summary) => isAuthorableTemplateType(summary.templateType));
|
|
672
|
+
// Generated-page sources recorded apart as retired. One whose page the site
|
|
673
|
+
// lists again (a bounded listing left it out last time) takes its file back
|
|
674
|
+
// as that page's source; with no baseline, pull compares the bytes with the
|
|
675
|
+
// site's document before replacing anything, so unpushed edits are kept.
|
|
676
|
+
const previousRetired = presentationOnly
|
|
677
|
+
? []
|
|
678
|
+
: retiredSourceRecords(
|
|
679
|
+
(await readManifest(config.workspaceDir, siteId, { required: false }).catch(() => undefined))
|
|
680
|
+
?.retiredGeneratedSources,
|
|
681
|
+
);
|
|
682
|
+
const registeredFiles = new Set([...registry.values()].map((source) => source.file));
|
|
683
|
+
for (const record of previousRetired) {
|
|
684
|
+
if (registry.has(record.pageId) || registeredFiles.has(record.file)) continue;
|
|
685
|
+
if (!authorable.some((summary) => summary.pageId === record.pageId)) continue;
|
|
686
|
+
registry.set(record.pageId, {
|
|
687
|
+
pageId: record.pageId,
|
|
688
|
+
file: record.file,
|
|
689
|
+
sourceFormat: pageSourceFormat(record.file),
|
|
690
|
+
baseline: undefined,
|
|
691
|
+
metadata: { title: "", path: record.path, description: "" },
|
|
692
|
+
baselineDiscarded: false,
|
|
693
|
+
formatMismatch: false,
|
|
694
|
+
duplicateSource: false,
|
|
695
|
+
duplicateIdentity: false,
|
|
696
|
+
});
|
|
697
|
+
registeredFiles.add(record.file);
|
|
698
|
+
}
|
|
603
699
|
const tracked = await resolveTrackedSources(config.workspaceDir, authorable, registry, onProgress);
|
|
604
|
-
|
|
700
|
+
// A generated page the site retired (its tag, month, place or folder is
|
|
701
|
+
// gone) leaves its pulled file behind: authored content, so pull will not
|
|
702
|
+
// delete it, and settles it before naming any page's file so that a page
|
|
703
|
+
// created since at the same path never writes over it. It is recorded
|
|
704
|
+
// apart from the live pages, which navigation and the other checks read
|
|
705
|
+
// as existing, so that a push still knows the file and skips it rather
|
|
706
|
+
// than refusing it as an unknown raw document.
|
|
707
|
+
//
|
|
708
|
+
// A bounded listing cannot tell a retired page from one it left out, so
|
|
709
|
+
// there the files are kept and recorded the same way, without claiming
|
|
710
|
+
// the page is gone; the next full listing settles which they are.
|
|
711
|
+
const retiredGeneratedSources = [];
|
|
712
|
+
if (!presentationOnly) {
|
|
713
|
+
const livePageIds = new Set(live.map((summary) => summary.pageId));
|
|
714
|
+
const trackedFiles = new Set([...tracked.values()].map((source) => source.file));
|
|
715
|
+
const candidates = [
|
|
716
|
+
...[...registry].filter(([, source]) => source.generated !== undefined)
|
|
717
|
+
.map(([pageId, source]) => ({ pageId, file: source.file, path: source.metadata.path })),
|
|
718
|
+
...previousRetired,
|
|
719
|
+
];
|
|
720
|
+
const seen = new Set();
|
|
721
|
+
for (const { pageId, file, path } of candidates) {
|
|
722
|
+
if (livePageIds.has(pageId) || trackedFiles.has(file) || seen.has(file)) continue;
|
|
723
|
+
if (pageSourceFormat(file) === undefined || !isDiscoverablePageSource(file)) continue;
|
|
724
|
+
if (!await workspaceFileExists(config.workspaceDir, file)) continue;
|
|
725
|
+
seen.add(file);
|
|
726
|
+
retiredGeneratedSources.push({ pageId, file, path });
|
|
727
|
+
onProgress(
|
|
728
|
+
truncated
|
|
729
|
+
? `'${file}' is the source of a generated page this bounded listing did not include; it is kept as it is.`
|
|
730
|
+
: `'${file}' was the source of a generated page that the site no longer has. `
|
|
731
|
+
+ "Delete the file; a push sends nothing for it.",
|
|
732
|
+
);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
const pageFiles = assignPageFiles(
|
|
736
|
+
authorable,
|
|
737
|
+
tracked,
|
|
738
|
+
new Set(retiredGeneratedSources.map((source) => source.file)),
|
|
739
|
+
);
|
|
605
740
|
|
|
606
741
|
// The redirect map, with the revision a later push is fenced by (TR00702).
|
|
607
742
|
// Read here, before the first workspace write, for the same reason the
|
|
@@ -630,6 +765,27 @@ export async function pull(invocation) {
|
|
|
630
765
|
}
|
|
631
766
|
}
|
|
632
767
|
|
|
768
|
+
// Who a page can be credited to (TR01196). A Taproot that predates authors,
|
|
769
|
+
// or a credential without the content capability, leaves the file unwritten
|
|
770
|
+
// rather than failing a pull that does not need it.
|
|
771
|
+
let authorListing;
|
|
772
|
+
if (!presentationOnly) {
|
|
773
|
+
onProgress("Reading the site's authors.");
|
|
774
|
+
try {
|
|
775
|
+
authorListing = await listSiteAuthors(client, siteId);
|
|
776
|
+
} catch (error) {
|
|
777
|
+
if (
|
|
778
|
+
error instanceof ApiError
|
|
779
|
+
&& (error.refusalKind() === REFUSAL_UNCLASSIFIED || error.refusalKind() === REFUSAL_CAPABILITY_MISSING)
|
|
780
|
+
&& UNAVAILABLE_SETTINGS_STATUSES.has(error.httpStatus)
|
|
781
|
+
) {
|
|
782
|
+
onProgress(`The site's authors are not readable by this credential or this Taproot; ${AUTHORS_FILE_NAME} is not written.`);
|
|
783
|
+
} else {
|
|
784
|
+
throw error;
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
633
789
|
// Project every settings response before the first workspace write. Theme
|
|
634
790
|
// decoding is a wire-contract check and can fail on malformed stored data;
|
|
635
791
|
// discovering that after page bodies were written would strand an
|
|
@@ -742,12 +898,23 @@ export async function pull(invocation) {
|
|
|
742
898
|
// page's one source. Dropping it from the registry would let the next
|
|
743
899
|
// pull mint the competing document all over again.
|
|
744
900
|
onProgress(`Page '${summary.path}' has no readable body; keeping '${source.file}' as its source.`);
|
|
901
|
+
// A `pages meta set` edit is kept with the baseline that proves it is
|
|
902
|
+
// one; without the baseline the next pull could not tell it from the
|
|
903
|
+
// site's values and would overwrite it.
|
|
904
|
+
const metadataKept = metadataEditedLocally(source, summary.templateType);
|
|
905
|
+
const keptBaseline = metadataKept ? withHeldAuthor(source.baseline, summary.authorRef) : undefined;
|
|
906
|
+
if (metadataKept) reportPendingAuthorDisagreement(source, summary, onProgress);
|
|
745
907
|
trackedPlans.set(summary.pageId, {
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
908
|
+
// The page is still the generated page it was; losing the record would
|
|
909
|
+
// let the next push take an edit to its identity for the owner's.
|
|
910
|
+
...(source.generated === undefined ? {} : { generated: source.generated }),
|
|
911
|
+
description: metadataKept ? source.metadata.description : description,
|
|
912
|
+
title: metadataKept ? source.metadata.title : titleFromRead(page, summary),
|
|
913
|
+
path: metadataKept ? source.metadata.path : pathFromRead(page, summary),
|
|
914
|
+
author: metadataKept ? source.metadata.author : summary.authorRef,
|
|
749
915
|
file: source.file,
|
|
750
916
|
sourceFormat: source.sourceFormat,
|
|
917
|
+
...(keptBaseline === undefined ? {} : { baseline: keptBaseline }),
|
|
751
918
|
});
|
|
752
919
|
continue;
|
|
753
920
|
}
|
|
@@ -840,12 +1007,29 @@ export async function pull(invocation) {
|
|
|
840
1007
|
if (revision !== undefined && source.baseline?.revision === undefined && sourceMatchesRemote === undefined) {
|
|
841
1008
|
revisionsRecordedWithoutBodyComparison += 1;
|
|
842
1009
|
}
|
|
1010
|
+
// A .pm.json page keeps its title, path and description in the manifest,
|
|
1011
|
+
// so an edit there (`pages meta set`) is local work just like an edited
|
|
1012
|
+
// body: kept over the site's values, and a conflict if the site moved
|
|
1013
|
+
// too (TR01194). The recorded content key is what the two last agreed on.
|
|
1014
|
+
//
|
|
1015
|
+
// A generated page is the exception: its manifest title and description are what the site
|
|
1016
|
+
// reports, and its own are in the document, so editing them by hand changes nothing.
|
|
1017
|
+
const generatedPage = isGeneratedTemplateType(summary.templateType);
|
|
1018
|
+
const metadataEdited = metadataEditedLocally(source, summary.templateType);
|
|
843
1019
|
const keepLocal = markdown || localChanged;
|
|
844
|
-
const conflicted = markdown ? remoteChanged : localChanged && remoteChanged;
|
|
1020
|
+
const conflicted = markdown ? remoteChanged : (localChanged || metadataEdited) && remoteChanged;
|
|
1021
|
+
if (metadataEdited && !remoteChanged) {
|
|
1022
|
+
onProgress(`Kept the local title, path and description of '${source.file}'; push them to send them.`);
|
|
1023
|
+
}
|
|
1024
|
+
if (metadataEdited) reportPendingAuthorDisagreement(source, summary, onProgress);
|
|
845
1025
|
trackedPlans.set(summary.pageId, {
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
1026
|
+
...(generatedPage ? { generated: generatedIdentity(body.data) } : {}),
|
|
1027
|
+
description: metadataEdited ? source.metadata.description : description,
|
|
1028
|
+
title: metadataEdited ? source.metadata.title : titleFromRead(page, summary),
|
|
1029
|
+
path: metadataEdited ? source.metadata.path : pathFromRead(page, summary),
|
|
1030
|
+
// A `pages meta set --author` waiting to be sent is kept like any other
|
|
1031
|
+
// metadata edit; otherwise the manifest records who the site credits.
|
|
1032
|
+
author: metadataEdited ? source.metadata.author : summary.authorRef,
|
|
849
1033
|
file: source.file,
|
|
850
1034
|
sourceFormat: source.sourceFormat,
|
|
851
1035
|
// `sourceHash` records the source as of the last time it *agreed* with
|
|
@@ -867,10 +1051,25 @@ export async function pull(invocation) {
|
|
|
867
1051
|
remoteHash,
|
|
868
1052
|
sourceHash: localChanged ? source.baseline?.sourceHash : sourceHash,
|
|
869
1053
|
// Carried, never minted here: a kept source is only known to match
|
|
870
|
-
// the site if a push or an earlier pull already said so.
|
|
871
|
-
|
|
1054
|
+
// the site if a push or an earlier pull already said so. When this
|
|
1055
|
+
// pull adopts the site's title, path and description (they were not
|
|
1056
|
+
// edited here), the key is re-derived over them, or the next pull
|
|
1057
|
+
// would read the adopted values as a local edit.
|
|
1058
|
+
...(source.baseline?.contentKey === undefined
|
|
1059
|
+
? {}
|
|
1060
|
+
: {
|
|
1061
|
+
contentKey: markdown || metadataEdited || source.baseline.sourceHash === undefined
|
|
1062
|
+
? source.baseline.contentKey
|
|
1063
|
+
: pageContentKey(source.baseline.sourceHash, {
|
|
1064
|
+
title: titleFromRead(page, summary),
|
|
1065
|
+
path: pathFromRead(page, summary),
|
|
1066
|
+
description,
|
|
1067
|
+
author: summary.authorRef,
|
|
1068
|
+
}),
|
|
1069
|
+
}),
|
|
872
1070
|
...(revision === undefined ? {} : { revision }),
|
|
873
1071
|
...(remoteDisplayDate === undefined ? {} : { displayDate: remoteDisplayDate }),
|
|
1072
|
+
...(summary.authorRef === "" ? {} : { author: summary.authorRef }),
|
|
874
1073
|
}
|
|
875
1074
|
: {
|
|
876
1075
|
remoteHash,
|
|
@@ -879,11 +1078,17 @@ export async function pull(invocation) {
|
|
|
879
1078
|
title: titleFromRead(page, summary),
|
|
880
1079
|
path: pathFromRead(page, summary),
|
|
881
1080
|
description,
|
|
1081
|
+
author: summary.authorRef,
|
|
882
1082
|
}),
|
|
883
1083
|
...(revision === undefined ? {} : { revision }),
|
|
884
1084
|
...(remoteDisplayDate === undefined ? {} : { displayDate: remoteDisplayDate }),
|
|
1085
|
+
...(summary.authorRef === "" ? {} : { author: summary.authorRef }),
|
|
885
1086
|
},
|
|
886
|
-
|
|
1087
|
+
// A conflict preserves the site's version whatever kept the source: the
|
|
1088
|
+
// refusal names that copy as where the site's document now is.
|
|
1089
|
+
...(keepLocal || conflicted
|
|
1090
|
+
? { baselineBody: serialized, baselineFile }
|
|
1091
|
+
: { refresh: serialized, baselineFile }),
|
|
887
1092
|
});
|
|
888
1093
|
if (remoteChanged) {
|
|
889
1094
|
// A refusal for this same version already worked this out, against a
|
|
@@ -937,7 +1142,9 @@ export async function pull(invocation) {
|
|
|
937
1142
|
pagePath: normalizePagePath(summary.path) ?? summary.path,
|
|
938
1143
|
file: source.file,
|
|
939
1144
|
baselineFile,
|
|
940
|
-
localChanged,
|
|
1145
|
+
localChanged: localChanged || metadataEdited,
|
|
1146
|
+
// Only the manifest's title, path or description moved here, not the file.
|
|
1147
|
+
metadataOnly: metadataEdited && !localChanged,
|
|
941
1148
|
differences,
|
|
942
1149
|
observedRevision: revision,
|
|
943
1150
|
observedRemoteHash: remoteHash,
|
|
@@ -1013,6 +1220,7 @@ export async function pull(invocation) {
|
|
|
1013
1220
|
hasDraft: summary.hasDraft,
|
|
1014
1221
|
isGenerated: summary.isGenerated,
|
|
1015
1222
|
workspaceMode: PAGE_WORKSPACE_MODE_METADATA_ONLY,
|
|
1223
|
+
...(summary.authorRef === "" ? {} : { author: summary.authorRef }),
|
|
1016
1224
|
};
|
|
1017
1225
|
if (isAuthorableTemplateType(summary.templateType)) {
|
|
1018
1226
|
// This pull is reconciling the page, so the manifest below carries the
|
|
@@ -1026,8 +1234,12 @@ export async function pull(invocation) {
|
|
|
1026
1234
|
const plan = trackedPlans.get(summary.pageId);
|
|
1027
1235
|
if (plan !== undefined) {
|
|
1028
1236
|
entry.description = plan.description;
|
|
1237
|
+
if (plan.author === undefined || plan.author === "") delete entry.author;
|
|
1238
|
+
else entry.author = plan.author;
|
|
1029
1239
|
if (plan.title !== undefined) entry.title = plan.title;
|
|
1030
1240
|
if (plan.path !== undefined) entry.path = plan.path;
|
|
1241
|
+
// What a generated page is, so a push can refuse a source that changed it.
|
|
1242
|
+
if (plan.generated !== undefined) entry.generated = plan.generated;
|
|
1031
1243
|
if (plan.file !== undefined) {
|
|
1032
1244
|
entry.file = plan.file;
|
|
1033
1245
|
entry.sourceFormat = plan.sourceFormat;
|
|
@@ -1060,6 +1272,7 @@ export async function pull(invocation) {
|
|
|
1060
1272
|
onProgress(`Page '${summary.path}' has no readable body; snapshotting metadata only.`);
|
|
1061
1273
|
} else {
|
|
1062
1274
|
entry.file = pageFiles.get(summary.pageId);
|
|
1275
|
+
if (isGeneratedTemplateType(summary.templateType)) entry.generated = generatedIdentity(body.data);
|
|
1063
1276
|
const source = Buffer.from(`${JSON.stringify(body, undefined, 2)}\n`, "utf8");
|
|
1064
1277
|
entry.workspaceMode = PAGE_WORKSPACE_MODE_EDITABLE;
|
|
1065
1278
|
entry.sourceFormat = pageSourceFormat(entry.file);
|
|
@@ -1072,9 +1285,11 @@ export async function pull(invocation) {
|
|
|
1072
1285
|
title: entry.title,
|
|
1073
1286
|
path: entry.path,
|
|
1074
1287
|
description: entry.description,
|
|
1288
|
+
author: entry.author,
|
|
1075
1289
|
}),
|
|
1076
1290
|
...(revision === undefined ? {} : { revision }),
|
|
1077
1291
|
...(displayDate === undefined ? {} : { displayDate }),
|
|
1292
|
+
...(summary.authorRef === "" ? {} : { author: summary.authorRef }),
|
|
1078
1293
|
};
|
|
1079
1294
|
await writeWorkspaceFile(config.workspaceDir, entry.file, source);
|
|
1080
1295
|
bodies += 1;
|
|
@@ -1100,6 +1315,14 @@ export async function pull(invocation) {
|
|
|
1100
1315
|
);
|
|
1101
1316
|
}
|
|
1102
1317
|
|
|
1318
|
+
if (authorListing !== undefined) {
|
|
1319
|
+
await writeWorkspaceJson(
|
|
1320
|
+
config.workspaceDir,
|
|
1321
|
+
AUTHORS_FILE_NAME,
|
|
1322
|
+
projectAuthorsForWorkspace(siteId, authorListing),
|
|
1323
|
+
);
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1103
1326
|
for (const document of settingsDocuments) {
|
|
1104
1327
|
await writeWorkspaceJson(config.workspaceDir, document.file, document.value);
|
|
1105
1328
|
}
|
|
@@ -1132,6 +1355,7 @@ export async function pull(invocation) {
|
|
|
1132
1355
|
...(presentationRevision === undefined ? {} : { presentation: presentationManifestEntry(presentationRevision) }),
|
|
1133
1356
|
...(formsBaseline === undefined ? {} : { forms: formsBaseline }),
|
|
1134
1357
|
pages: manifestPages,
|
|
1358
|
+
...(retiredGeneratedSources.length === 0 ? {} : { retiredGeneratedSources }),
|
|
1135
1359
|
};
|
|
1136
1360
|
await writeManifest(config.workspaceDir, manifest);
|
|
1137
1361
|
onProgress(`Wrote ${MANIFEST_FILE_NAME} describing ${manifestPages.length} pages.`);
|
|
@@ -1147,7 +1371,7 @@ export async function pull(invocation) {
|
|
|
1147
1371
|
const reported = boundedList(
|
|
1148
1372
|
manifestPages.map((entry) => ({
|
|
1149
1373
|
pageId: entry.pageId,
|
|
1150
|
-
path: entry.path,
|
|
1374
|
+
path: displayPagePath(entry.path),
|
|
1151
1375
|
status: entry.status,
|
|
1152
1376
|
templateType: entry.templateType,
|
|
1153
1377
|
file: entry.file,
|
|
@@ -1181,6 +1405,17 @@ export async function pull(invocation) {
|
|
|
1181
1405
|
entries: redirectMap.entries.length,
|
|
1182
1406
|
},
|
|
1183
1407
|
}),
|
|
1408
|
+
// Absent, not zeroed, when the site served no listing: `0` would read as
|
|
1409
|
+
// a site with nobody to credit.
|
|
1410
|
+
...(authorListing === undefined
|
|
1411
|
+
? {}
|
|
1412
|
+
: {
|
|
1413
|
+
authors: {
|
|
1414
|
+
file: AUTHORS_FILE_NAME,
|
|
1415
|
+
authors: authorListing.authors.length,
|
|
1416
|
+
members: authorListing.members.length,
|
|
1417
|
+
},
|
|
1418
|
+
}),
|
|
1184
1419
|
settings: {
|
|
1185
1420
|
pulled: pulledSettings.map((entry) => entry.settingsType),
|
|
1186
1421
|
skipped: skippedSettings,
|
|
@@ -1,18 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { withRefusalGuidance } from "../api.js";
|
|
2
2
|
import { VERB_REDIRECTS_CHECK } from "../constants.js";
|
|
3
3
|
import { openSession, successResult } from "../session.js";
|
|
4
|
-
import {
|
|
4
|
+
import { inspectStagingPreview } from "../staging-check.js";
|
|
5
5
|
|
|
6
6
|
export async function redirectsCheck(invocation) {
|
|
7
7
|
const { client, siteId, onProgress, now } = await openSession(invocation);
|
|
8
8
|
return await withRefusalGuidance(onProgress, "staging redirects", async () => {
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
onProgress(
|
|
12
|
-
"Warning: staging redirects are not verified. Check the reported mismatches against the staged release and retry once its deployment has completed.",
|
|
13
|
-
);
|
|
14
|
-
}
|
|
15
|
-
const handoff = await mintStagingPreviewHandoff(client, siteId, { now });
|
|
16
|
-
return successResult(VERB_REDIRECTS_CHECK, siteId, { stagingPreview: { ...checks, ...handoff } });
|
|
9
|
+
const { routeCheck, redirects, stagingPreview } = await inspectStagingPreview(client, siteId, onProgress, now);
|
|
10
|
+
return successResult(VERB_REDIRECTS_CHECK, siteId, { routeCheck, redirects, stagingPreview });
|
|
17
11
|
});
|
|
18
12
|
}
|
package/src/verbs/status.js
CHANGED
|
@@ -132,10 +132,7 @@ export async function status(invocation) {
|
|
|
132
132
|
const selection = presentationOnly
|
|
133
133
|
? { ...reviewed, stagedPageIds: [], includeNavigation: false }
|
|
134
134
|
: reviewed;
|
|
135
|
-
const readiness = await getPublishingReadiness(client, siteId,
|
|
136
|
-
...selection,
|
|
137
|
-
stagedPageIds: selection.stagedPageIds.length > 100 ? undefined : selection.stagedPageIds,
|
|
138
|
-
});
|
|
135
|
+
const readiness = await getPublishingReadiness(client, siteId, selection);
|
|
139
136
|
onProgress("Reading the deployment log.");
|
|
140
137
|
const {
|
|
141
138
|
deployments,
|