@taprootio/site-authoring 0.14.1 → 0.16.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.
Files changed (49) hide show
  1. package/README.md +181 -16
  2. package/examples/riverbend-wellness/.taproot-site-media.json +23 -0
  3. package/examples/riverbend-wellness/pages/index.md +1 -17
  4. package/package.json +2 -2
  5. package/src/api.js +123 -1
  6. package/src/authors-contract.js +170 -0
  7. package/src/cli.js +355 -30
  8. package/src/constants.js +9 -0
  9. package/src/content/components.js +64 -6
  10. package/src/content/free-form-sections.js +21 -4
  11. package/src/content/link-warnings.js +109 -0
  12. package/src/content/markdown.js +161 -26
  13. package/src/content/tiptap-prosemirror.ts +66 -8
  14. package/src/content/validate-document.js +30 -6
  15. package/src/content/vocabulary.js +26 -0
  16. package/src/errors.js +22 -0
  17. package/src/espalier-shared.js +11 -0
  18. package/src/fixture-init.js +55 -3
  19. package/src/font-catalog.js +87 -0
  20. package/src/footer-contract.js +1 -1
  21. package/src/output.js +19 -1
  22. package/src/page-metadata.js +92 -0
  23. package/src/presentation-reference.js +21 -7
  24. package/src/problems.js +116 -0
  25. package/src/reference-help.js +157 -41
  26. package/src/session.js +13 -0
  27. package/src/staging-check.js +62 -0
  28. package/src/theme-proposals.js +393 -0
  29. package/src/theme-validation.js +31 -10
  30. package/src/typed-page-reference.js +97 -21
  31. package/src/typed-pages.js +196 -13
  32. package/src/verbs/approve.js +63 -14
  33. package/src/verbs/authors.js +167 -0
  34. package/src/verbs/deploy.js +5 -28
  35. package/src/verbs/index.js +22 -0
  36. package/src/verbs/media-upload.js +127 -30
  37. package/src/verbs/nav-push.js +7 -0
  38. package/src/verbs/pages-meta-set.js +303 -0
  39. package/src/verbs/pages-push.js +814 -224
  40. package/src/verbs/places.js +61 -0
  41. package/src/verbs/plan.js +595 -0
  42. package/src/verbs/pull.js +255 -20
  43. package/src/verbs/redirects-check.js +4 -10
  44. package/src/verbs/theme-push.js +68 -36
  45. package/src/verbs/validate.js +309 -217
  46. package/src/video-prepare.js +48 -12
  47. package/src/workspace-check.js +199 -0
  48. package/src/workspace-ledger.js +173 -0
  49. package/src/workspace.js +105 -10
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
  }
@@ -286,6 +286,69 @@ function describeDifferences(validated, presentation) {
286
286
  });
287
287
  }
288
288
 
289
+ /** The refusal for a workspace pulled before the presentation baseline existed. */
290
+ export function presentationPullRequired() {
291
+ return new SiteAuthoringError(
292
+ "theme.pull_required",
293
+ "The pull manifest records no presentation baseline, so this push has no revision to be fenced by. Run "
294
+ + "'taproot-site pull' against a Taproot that serves the atomic presentation save, then retry 'theme push'.",
295
+ { field: "presentation.revision" },
296
+ );
297
+ }
298
+
299
+ /** The refusal for a presentation that moved since the pull, naming where the two differ. */
300
+ export function presentationMovedRefusal({ baseline, current, differences }) {
301
+ return new SiteAuthoringError(
302
+ "theme.concurrent_modification",
303
+ `The site's presentation changed after this workspace was pulled (revision ${baseline.revision} → `
304
+ + `${current.revision}), so nothing was written. Keep copies of the edited settings files, run `
305
+ + "'taproot-site pull' to refresh the baseline, re-apply the edits, and push again; the paths below name "
306
+ + "where the workspace and the site now differ.",
307
+ { field: "revision", alternatives: differences.filter((entry) => entry.paths.length > 0).map((entry) => entry.file) },
308
+ ).withDifferences(differences.flatMap((entry) => entry.paths.map((path) => `${entry.file}:${path}`)));
309
+ }
310
+
311
+ /**
312
+ * What pushing this validated presentation would do, from one fresh read of
313
+ * the site: the JSON paths that differ, whether the pull's baseline is still
314
+ * current, and whether a save whose response was lost would be replayed.
315
+ * `theme push` and `plan` both decide from this (TR00823).
316
+ */
317
+ export async function comparePresentation(client, siteId, validated, manifest) {
318
+ const baseline = readPresentationBaseline(manifest);
319
+ let current;
320
+ try {
321
+ current = await getSitePresentation(client, siteId);
322
+ } catch (error) {
323
+ throw translateServerSupport(error, "read");
324
+ }
325
+ const stale = baseline !== undefined && baseline.revision !== current.revision;
326
+ // A save this workspace sent and never heard back from (TR00807). When the
327
+ // change set about to go is the same one, a moved revision is not evidence
328
+ // of a concurrent edit: it is what that save leaves behind when it
329
+ // committed. Replaying it under the baseline it was sent with lets the site
330
+ // answer already-current, or apply it if it never committed; anything else
331
+ // it refuses itself. A pending record for a different change set proves
332
+ // nothing about the site and takes the ordinary path.
333
+ const changeSet = buildChangeSet(validated);
334
+ const changeSetHash = computePresentationChangeSetHash(changeSet);
335
+ const pending = readPendingPresentationSave(manifest);
336
+ const replayable = pending !== undefined && pending.changeSetHash === changeSetHash;
337
+ const differences = describeDifferences(validated, current);
338
+ return {
339
+ baseline,
340
+ current,
341
+ stale,
342
+ changeSet,
343
+ changeSetHash,
344
+ pending,
345
+ replayable,
346
+ replaying: stale && replayable,
347
+ differences,
348
+ changed: differences.some((entry) => entry.paths.length > 0 || entry.truncated === true),
349
+ };
350
+ }
351
+
289
352
  export async function themePush(invocation) {
290
353
  const session = await openSession(invocation);
291
354
  const { client, config, siteId, onProgress } = session;
@@ -307,12 +370,7 @@ export async function themePush(invocation) {
307
370
  requireFooterContentPushed(appearanceContext.footer, publishing.footerSettings);
308
371
  const baseline = readPresentationBaseline(appearanceContext.manifest);
309
372
  if (!dryRun && baseline === undefined) {
310
- throw new SiteAuthoringError(
311
- "theme.pull_required",
312
- "The pull manifest records no presentation baseline, so this push has no revision to be fenced by. Run "
313
- + "'taproot-site pull' against a Taproot that serves the atomic presentation save, then retry 'theme push'.",
314
- { field: "presentation.revision" },
315
- );
373
+ throw presentationPullRequired();
316
374
  }
317
375
  onProgress(`Validated the complete light/dark theme pair and ${scalarOperations.length} appearance settings.`);
318
376
  for (const warning of themes.warnings) onProgress(`Espalier warning: ${warning}`);
@@ -329,29 +387,11 @@ export async function themePush(invocation) {
329
387
  // The fresh read serves both modes: the dry run compares against it, and
330
388
  // the push takes the footer document's draft token from it. Nothing before
331
389
  // this point, and nothing in a dry run after it, writes to the site.
332
- let current;
333
- try {
334
- current = await withRefusalGuidance(onProgress, "theme push", async () => await getSitePresentation(client, siteId));
335
- } catch (error) {
336
- throw translateServerSupport(error, "read");
337
- }
338
- const stale = baseline !== undefined && baseline.revision !== current.revision;
339
- // A save this workspace sent and never heard back from (TR00807). When the
340
- // change set about to go is the same one, a moved revision is not evidence
341
- // of a concurrent edit: it is what that save leaves behind when it
342
- // committed. Replaying it under the baseline it was sent with lets the site
343
- // answer already-current, or apply it if it never committed; anything else
344
- // it refuses itself. A pending record for a different change set proves
345
- // nothing about the site and takes the ordinary path.
346
- const changeSet = buildChangeSet(validated);
347
- const changeSetHash = computePresentationChangeSetHash(changeSet);
348
- const pending = readPendingPresentationSave(appearanceContext.manifest);
349
- const replayable = pending !== undefined && pending.changeSetHash === changeSetHash;
350
- const replaying = stale && replayable;
390
+ const comparison = await withRefusalGuidance(onProgress, "theme push", async () =>
391
+ await comparePresentation(client, siteId, validated, appearanceContext.manifest));
392
+ const { current, stale, changeSet, changeSetHash, pending, replayable, replaying, differences, changed } = comparison;
351
393
 
352
394
  if (dryRun) {
353
- const differences = describeDifferences(validated, current);
354
- const changed = differences.some((entry) => entry.paths.length > 0 || entry.truncated === true);
355
395
  for (const entry of differences) {
356
396
  if (entry.paths.length === 0 && entry.truncated !== true) continue;
357
397
  onProgress(`${entry.file} differs at: ${entry.paths.join(", ")}${entry.truncated ? ", and more" : ""}.`);
@@ -401,15 +441,7 @@ export async function themePush(invocation) {
401
441
  + "it. The pull that refreshes the baseline shows what the site holds now.",
402
442
  );
403
443
  }
404
- const differences = describeDifferences(validated, current);
405
- throw new SiteAuthoringError(
406
- "theme.concurrent_modification",
407
- `The site's presentation changed after this workspace was pulled (revision ${baseline.revision} → `
408
- + `${current.revision}), so nothing was written. Keep copies of the edited settings files, run `
409
- + "'taproot-site pull' to refresh the baseline, re-apply the edits, and push again; the paths below name "
410
- + "where the workspace and the site now differ.",
411
- { field: "revision", alternatives: differences.filter((entry) => entry.paths.length > 0).map((entry) => entry.file) },
412
- ).withDifferences(differences.flatMap((entry) => entry.paths.map((path) => `${entry.file}:${path}`)));
444
+ throw presentationMovedRefusal(comparison);
413
445
  }
414
446
 
415
447
  const expectedRevision = replaying ? pending.expectedRevision : baseline.revision;