@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.
@@ -1,7 +1,21 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
 
3
- import { isGooglePlaceId, searchPlaces, selectPlace, withRefusalGuidance } from "../api.js";
4
- import { VERB_PLACES_SEARCH, VERB_PLACES_SELECT } from "../constants.js";
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 { pagesPush, placedVideoProblems, planPages, plannedPageItem } from "./pages-push.js";
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${first.localChanged ? " and in this workspace" : ""} since the last `
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
- const reserved = new Set(pages.map((summary) => `${PAGES_DIRECTORY}/${summary.pageId}.pm.json`));
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 three do carry a site, so they can clear themselves.
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
- const pageFiles = assignPageFiles(authorable, tracked);
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
- description,
747
- title: titleFromRead(page, summary),
748
- path: pathFromRead(page, summary),
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
- description,
847
- title: titleFromRead(page, summary),
848
- path: pathFromRead(page, summary),
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
- ...(source.baseline?.contentKey === undefined ? {} : { contentKey: source.baseline.contentKey }),
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
- ...(keepLocal ? { baselineBody: serialized, baselineFile } : { refresh: serialized, baselineFile }),
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 { mintStagingPreviewHandoff, withRefusalGuidance } from "../api.js";
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 { checkStagingRedirects } from "../staging-check.js";
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 checks = await checkStagingRedirects(client, siteId, onProgress, now);
10
- if (!checks.redirects.verified) {
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
  }
@@ -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,