@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
@@ -5,20 +5,39 @@ import {
5
5
  listVideos,
6
6
  IMAGE_PROCESSING_STATE_COMPLETE,
7
7
  PAGE_STATUS_DELETED,
8
+ listSiteAuthors,
8
9
  posterFromImage,
10
+ translateAuthorRefusal,
9
11
  updatePage,
10
12
  withRefusalGuidance,
11
13
  } from "../api.js";
14
+ import {
15
+ AUTHORS_FILE_NAME,
16
+ authorConflictMessage,
17
+ authorKnown,
18
+ parseAuthorReference,
19
+ projectAuthorsForWorkspace,
20
+ readAuthorsDocument,
21
+ } from "../authors-contract.js";
12
22
  import { VERB_PAGES_PUSH } from "../constants.js";
23
+ import { autolinkWarnings } from "../content/link-warnings.js";
24
+ import { ApiError } from "../transport.js";
13
25
  import { waitForProcessing } from "./media-upload.js";
14
26
  import { SiteAuthoringError } from "../errors.js";
27
+ import { DESCRIPTION_MAXIMUM_LENGTH, DESCRIPTION_WARN_LENGTH, descriptionWarnings } from "../page-metadata.js";
28
+ import { stableJson } from "../footer-draft-hash.js";
29
+ import { CHECK_AREA, collectProblems, planStale, problemsOf, refuseProblems } from "../problems.js";
15
30
  import { RUNTIME_MIRROR_PATH_REASON, isRuntimeMirrorPath } from "../reserved-paths.js";
16
- import { boundedList, openSession, successResult, warnIfExternalWritesPaused } from "../session.js";
31
+ import { boundedByBytes, openSession, successResult, warnIfExternalWritesPaused } from "../session.js";
17
32
  import { SETTINGS_TYPE_TAPROOT_STYLES } from "../settings-catalog.js";
18
33
  import {
34
+ changedGeneratedIdentity,
19
35
  contentDocuments,
20
36
  COVER_IMAGE_UNUSED_MESSAGE,
21
37
  documentTemplate,
38
+ generatedMarkdownFault,
39
+ generatedPageDescription,
40
+ generatedPageTitle,
22
41
  placedVideoIds,
23
42
  posterlessVideoEmbeds,
24
43
  FRONT_MATTER_KEYS,
@@ -26,6 +45,7 @@ import {
26
45
  requireTemplateName,
27
46
  sentDisplayDate,
28
47
  TEMPLATE_FREE_FORM,
48
+ TEMPLATE_GENERATED,
29
49
  templateFrontMatterFault,
30
50
  typedDocumentFromJson,
31
51
  typedDocumentFromMarkdown,
@@ -41,6 +61,7 @@ import {
41
61
  } from "../content/free-form-sections.js";
42
62
  import { CONTENT_ERROR_CODES } from "../content/vocabulary.js";
43
63
  import {
64
+ displayPagePath,
44
65
  deleteWorkspaceFile,
45
66
  internalPageObservedRevisionFile,
46
67
  MEDIA_MANIFEST_FILE_NAME,
@@ -68,6 +89,7 @@ import {
68
89
  workspaceContentHash,
69
90
  workspaceFileExists,
70
91
  writeManifest,
92
+ writeWorkspaceJson,
71
93
  } from "../workspace.js";
72
94
 
73
95
  /**
@@ -109,7 +131,40 @@ import {
109
131
  * ambiguous outcome surfaces as ambiguous.
110
132
  */
111
133
 
112
- const MAXIMUM_REPORTED = 200;
134
+ // Shares of the result's 64 KiB for its lists, whose entries carry page paths
135
+ // of any length.
136
+ const ITEMS_BYTES = 32 * 1024;
137
+ const LIST_BYTES = 8 * 1024;
138
+ // A send that ended this way may still have written the page.
139
+ const OUTCOME_UNKNOWN_CODES = new Set([
140
+ "transport.mutation_ambiguous",
141
+ "transport.response_read",
142
+ "transport.response_too_large",
143
+ "transport.invalid_json",
144
+ "api.page_contract",
145
+ ]);
146
+
147
+ function outcomeUnknown(error) {
148
+ // A cancelled retry of a write, and a server error on a write the transport
149
+ // does not replay, leave the same question open.
150
+ return OUTCOME_UNKNOWN_CODES.has(error.code) || error.ambiguousMutation === true || error.httpStatus >= 500;
151
+ }
152
+
153
+ /** The creates this workspace sent without hearing back, by page path. */
154
+ function pendingCreates(manifest) {
155
+ const recorded = manifest.pendingCreates;
156
+ return recorded !== null && typeof recorded === "object" && !Array.isArray(recorded) ? recorded : {};
157
+ }
158
+
159
+ function pendingCreateFor(manifest, pagePath) {
160
+ return Object.hasOwn(pendingCreates(manifest), pagePath);
161
+ }
162
+
163
+ /** A result list bounded by bytes, as `<name>` plus `<name>Truncated` when cut. */
164
+ function reportedList(name, values, budget) {
165
+ const shown = boundedByBytes(values, budget);
166
+ return { [name]: shown.items, ...(shown.truncated ? { [`${name}Truncated`]: true } : {}) };
167
+ }
113
168
  const FRONT_MATTER_FENCE = "---";
114
169
  const FRONT_MATTER_ENTRY = /^([A-Za-z][A-Za-z0-9_]*)[ \t]*:[ \t]*(.*)$/u;
115
170
 
@@ -231,6 +286,7 @@ function parseFrontMatter(source, file) {
231
286
  if (fields.has("template")) {
232
287
  try {
233
288
  template = requireTemplateName(fields.get("template"), file);
289
+ if (template === TEMPLATE_GENERATED) record(generatedMarkdownFault(file));
234
290
  } catch (error) {
235
291
  record(error);
236
292
  }
@@ -379,14 +435,22 @@ function assertValid(content, document_, file, { mayBeEmpty = false } = {}) {
379
435
  const errors = mayBeEmpty ? reported.filter((error) => error?.code !== CONTENT_ERROR_CODES.emptyDocument) : reported;
380
436
  if (errors.length === 0) return;
381
437
  const first = errors[0] ?? {};
382
- const location = typeof first.path === "string" && first.path !== "" ? first.path : "doc";
438
+ const locate = (error) => typeof error?.path === "string" && error.path !== "" ? error.path : "doc";
439
+ const describe = (error) =>
440
+ typeof error?.message === "string" ? error.message : "the node or mark is outside the accepted vocabulary.";
441
+ // Every finding travels with the refusal, so a whole-workspace check reports
442
+ // all of a page's problems and not only the first (TR01002).
383
443
  throw documentError(
384
444
  first.code === CONTENT_ERROR_CODES.rawHtmlForbidden ? CONTENT_ERROR_CODES.rawHtmlForbidden : "pages.document_invalid",
385
- `'${file}' is not a valid Taproot document: ${errors.length} problem(s), first at ${location} (${
445
+ `'${file}' is not a valid Taproot document: ${errors.length} problem(s), first at ${locate(first)} (${
386
446
  typeof first.code === "string" ? first.code : "invalid"
387
- }): ${typeof first.message === "string" ? first.message : "the node or mark is outside the accepted vocabulary."}`,
388
- `${file}:${location}`,
389
- );
447
+ }): ${describe(first)}`,
448
+ `${file}:${locate(first)}`,
449
+ ).withProblems(errors.map((error) => ({
450
+ code: typeof error?.code === "string" ? error.code : "pages.document_invalid",
451
+ field: `${file}:${locate(error)}`,
452
+ message: describe(error),
453
+ })));
390
454
  }
391
455
 
392
456
  function systemPageKind(pagePath) {
@@ -435,6 +499,7 @@ export async function readWorkspacePageSource({ workspaceDir, file, manifestEntr
435
499
  let pathCandidates = [];
436
500
  let title;
437
501
  let declaredDescription;
502
+ let declaredAuthor;
438
503
  let declaredPath;
439
504
  let template = TEMPLATE_FREE_FORM;
440
505
  let fields;
@@ -444,6 +509,9 @@ export async function readWorkspacePageSource({ workspaceDir, file, manifestEntr
444
509
  fields = frontMatter.fields;
445
510
  title = frontMatter.fields.get("title") ?? manifestEntry?.title;
446
511
  declaredDescription = frontMatter.fields.get("description") ?? manifestEntry?.description;
512
+ // An empty `author:` names nobody, so it falls back like an absent one
513
+ // rather than overriding the manifest's with nothing (TR01196).
514
+ declaredAuthor = frontMatter.fields.get("author") || manifestEntry?.author;
447
515
  declaredPath = frontMatter.fields.has("path") ? frontMatter.fields.get("path") : manifestEntry?.path;
448
516
  markdown = frontMatter.markdown;
449
517
  fault = frontMatter.fault;
@@ -459,10 +527,10 @@ export async function readWorkspacePageSource({ workspaceDir, file, manifestEntr
459
527
  }
460
528
  title = manifestEntry.title;
461
529
  declaredDescription = manifestEntry.description;
530
+ declaredAuthor = manifestEntry.author;
462
531
  declaredPath = manifestEntry.path;
463
532
  template = peekJsonTemplate(source);
464
533
  }
465
-
466
534
  const pagePath = normalizePagePath(declaredPath);
467
535
  if (pagePath === undefined) {
468
536
  // Not one resolved page, but not nothing either: a file declaring two
@@ -511,6 +579,7 @@ export async function readWorkspacePageSource({ workspaceDir, file, manifestEntr
511
579
  pagePath,
512
580
  title,
513
581
  declaredDescription,
582
+ declaredAuthor,
514
583
  fault,
515
584
  pathClaims: [],
516
585
  template,
@@ -588,6 +657,51 @@ function requireReconciledDisplayDate({ file, pagePath, target, entry, document_
588
657
  );
589
658
  }
590
659
 
660
+ /**
661
+ * The refusals that keep a generated page what Taproot made it (TR01195). Only
662
+ * its title, breadcrumb, description and introduction are the owner's, so a
663
+ * push may update an existing generated page and nothing else: it never creates
664
+ * one, moves it, or changes the kind or identity it was pulled with. Checked
665
+ * here, before anything is sent, because the API refuses the same changes only
666
+ * after earlier pages in the push were written.
667
+ *
668
+ * A source with no live page is the caller's to classify (a stale file in a
669
+ * whole-workspace push, a refusal when the path was asked for), so `target` is
670
+ * always a live page here.
671
+ */
672
+ function requireGeneratedUpdate({ file, pagePath, target, entry, document_ }) {
673
+ const livePath = normalizePagePath(target.path) ?? target.path;
674
+ if (livePath !== pagePath) {
675
+ throw documentError(
676
+ "pages.generated_move",
677
+ `'${file}' would move the generated page at '${livePath || "/"}' to '${pagePath || "/"}'. A generated page's `
678
+ + "path is set by Taproot from the content it lists: change the content that produces it, not its path.",
679
+ file,
680
+ );
681
+ }
682
+ const recorded = entry?.generated;
683
+ if (recorded === null || typeof recorded !== "object" || Array.isArray(recorded)) {
684
+ // Without the record there is nothing to prove this file is still the page
685
+ // it was pulled as, and an edit to its identity would pass for an owner's.
686
+ throw documentError(
687
+ "pages.generated_identity",
688
+ `'${file}' is a generated-page source, but this workspace has no record of which generated page it was pulled `
689
+ + "as, so its identity cannot be checked. Run 'taproot-site pull' to record it, then push again.",
690
+ file,
691
+ );
692
+ }
693
+ const changed = changedGeneratedIdentity(recorded, document_.data);
694
+ if (changed.length > 0) {
695
+ throw documentError(
696
+ "pages.generated_identity",
697
+ `'${file}' changes ${changed.join(", ")}, which say what this generated page is and which the site manages. `
698
+ + "Only customTitle, breadcrumbTitle, customDescription and introductionBody are yours to edit: restore "
699
+ + "the pulled values, or delete the file and pull again.",
700
+ `${file}:data.${changed[0]}`,
701
+ );
702
+ }
703
+ }
704
+
591
705
  /**
592
706
  * The metadata checks that are *not* part of deciding which page a source is.
593
707
  *
@@ -708,6 +822,9 @@ export async function validateWorkspacePageDocument({
708
822
  const contexts = typeof getSharedThemeContexts === "function"
709
823
  ? await getSharedThemeContexts()
710
824
  : await readSharedThemeContexts(workspaceDir, siteId);
825
+ // No contexts means the theme itself failed validation and was reported
826
+ // as its own problem; a context cannot be checked against it.
827
+ if (contexts === undefined) continue;
711
828
  const contextErrors = validateFreeFormSectionContexts(doc, contexts).errors;
712
829
  if (contextErrors.length > 0) {
713
830
  const first = contextErrors[0];
@@ -722,10 +839,10 @@ export async function validateWorkspacePageDocument({
722
839
  }
723
840
 
724
841
  /**
725
- * Refuses a push that places a video the site's library does not hold; every
842
+ * The pages that place a video the site's library does not hold; every
726
843
  * library video is ready to play. Pages that will not be sent are not asked about.
727
844
  */
728
- async function requirePlacedVideosExist(client, siteId, planned) {
845
+ export async function placedVideoProblems(client, siteId, planned) {
729
846
  const placements = new Map();
730
847
  for (const page of planned) {
731
848
  if (page.unchanged) continue;
@@ -733,30 +850,32 @@ async function requirePlacedVideosExist(client, siteId, planned) {
733
850
  if (!placements.has(videoId)) placements.set(videoId, page.file);
734
851
  }
735
852
  }
736
- if (placements.size === 0) return;
853
+ if (placements.size === 0) return [];
737
854
 
738
855
  const library = await listVideos(client, siteId);
739
856
  const knownIds = new Set(library.videos.map((video) => video.videoId));
857
+ const problems = [];
740
858
  for (const [videoId, file] of placements) {
741
- const known = knownIds.has(videoId);
742
- if (!known && library.truncated) {
743
- throw documentError(
744
- "pages.video_library_unverifiable",
745
- `'${file}' places video ${videoId}, which is not among the first ${library.videos.length} videos of this `
746
- + "site's library, and the library is larger than one push can check. Reduce the library or place the video "
747
- + "from the editor.",
748
- file,
749
- );
750
- }
751
- if (!known) {
752
- throw documentError(
753
- "pages.video_unknown",
754
- `'${file}' places video ${videoId}, which is not in this site's video library. Upload it with `
755
- + "'taproot-site media upload' and use the videoId it reports.",
756
- file,
757
- );
758
- }
859
+ if (knownIds.has(videoId)) continue;
860
+ problems.push(...problemsOf(
861
+ library.truncated
862
+ ? documentError(
863
+ "pages.video_library_unverifiable",
864
+ `'${file}' places video ${videoId}, which is not among the first ${library.videos.length} videos of this `
865
+ + "site's library, and the library is larger than one push can check. Reduce the library or place the video "
866
+ + "from the editor.",
867
+ file,
868
+ )
869
+ : documentError(
870
+ "pages.video_unknown",
871
+ `'${file}' places video ${videoId}, which is not in this site's video library. Upload it with `
872
+ + "'taproot-site media upload' and use the videoId it reports.",
873
+ file,
874
+ ),
875
+ { area: CHECK_AREA.pages, file },
876
+ ));
759
877
  }
878
+ return problems;
760
879
  }
761
880
 
762
881
  /**
@@ -796,49 +915,151 @@ async function copyVideoEmbedPosters(client, siteId, planned, { onProgress, now
796
915
  }
797
916
  }
798
917
 
799
- export async function pagesPush(invocation) {
800
- const session = await openSession(invocation);
801
- const { client, config, siteId, now, onProgress } = session;
802
- // One advisory line before this verb does any work, and only when the
803
- // exchange said the platform is paused. It changes nothing else: the write
804
- // still runs and its refusal still classifies as platform_paused (TR00692).
805
- warnIfExternalWritesPaused(session, VERB_PAGES_PUSH);
806
- // Both manifests are bound to the site before anything is planned or sent:
807
- // every id in them is site-scoped, and a workspace pulled from another site
808
- // reads as entirely valid until phase two is already writing.
809
- const manifest = await readManifest(config.workspaceDir, siteId);
810
- const mediaManifest = await readMediaManifest(config.workspaceDir, siteId);
811
- const content = requireContentFunctions(await loadContentModule(invocation.content));
812
- const requestedPaths = Array.isArray(invocation.pagePaths)
813
- ? new Set(invocation.pagePaths.map((value) => {
814
- const normalized = normalizePagePath(value);
815
- if (normalized === undefined) {
816
- throw new SiteAuthoringError(
817
- "pages.page_path_invalid",
818
- `'${typeof value === "string" ? value : String(value)}' is not a usable page path.`,
819
- { field: typeof value === "string" ? value : undefined, exitCode: 2 },
918
+ /** `authors.json`, or undefined when it is absent, unreadable, or another site's. */
919
+ async function readRecordedAuthors(workspaceDir, siteId) {
920
+ try {
921
+ if (!await workspaceFileExists(workspaceDir, AUTHORS_FILE_NAME)) return undefined;
922
+ return readAuthorsDocument(
923
+ await readWorkspaceJson(workspaceDir, AUTHORS_FILE_NAME, WORKSPACE_LIMITS.authorsBytes),
924
+ siteId,
925
+ );
926
+ } catch {
927
+ return undefined;
928
+ }
929
+ }
930
+
931
+ /**
932
+ * Reads the site's live author listing for an online run (TR01196), in the
933
+ * shape `readAuthorsDocument` gives, so a name added since `authors.json` was
934
+ * written is found. `write` keeps the workspace copy current; `plan` leaves the
935
+ * workspace alone and passes false. A site that cannot be asked (an older
936
+ * Taproot, a credential without the content capability) answers `undefined`,
937
+ * which proves nothing either way.
938
+ */
939
+ export function liveAuthorsRefresher({ client, workspaceDir, siteId, write, onProgress }) {
940
+ return async () => {
941
+ onProgress(`An author is not in ${AUTHORS_FILE_NAME}; asking the site who can be named.`);
942
+ let listing;
943
+ try {
944
+ listing = await listSiteAuthors(client, siteId);
945
+ } catch (error) {
946
+ if (!(error instanceof ApiError)) throw error;
947
+ onProgress("The site's authors could not be read, so the author is left for the site to check.");
948
+ return undefined;
949
+ }
950
+ const document_ = projectAuthorsForWorkspace(siteId, listing);
951
+ if (write) await writeWorkspaceJson(workspaceDir, AUTHORS_FILE_NAME, document_);
952
+ return readAuthorsDocument(document_, siteId);
953
+ };
954
+ }
955
+
956
+ /**
957
+ * Decides what a page says about its author before anything is sent
958
+ * (TR01196). `declared` is what the source (or, for a `.pm.json` page, its
959
+ * manifest entry) names; `recorded` is the author the site held when the
960
+ * workspace last reconciled with the page.
961
+ *
962
+ * Pages are authorless by default and an author is never replaced, so:
963
+ * nothing declared sends nothing; the author the site already has sends
964
+ * nothing and passes; a different one is refused here, where the site would
965
+ * refuse it only after earlier pages were written; and a new one is sent once
966
+ * the site is known to have it.
967
+ *
968
+ * "Known to have it" is asked of `authors.json` first, but a miss there is only
969
+ * as good as the day the file was written. An online run (`refreshAuthors`)
970
+ * asks the site once and refuses only what the site's own list lacks; an
971
+ * offline one cannot ask, so it sends nothing and flags the name as unverified
972
+ * rather than refusing a person who may have been added since.
973
+ */
974
+ async function resolvePageAuthor({ file, pagePath, declared, recorded, markdown, knownAuthors, refreshAuthors }) {
975
+ if (declared === undefined || declared === "") return { author: "", sendAuthor: false };
976
+ const reference = parseAuthorReference(declared);
977
+ if (reference === undefined) {
978
+ throw documentError(
979
+ "pages.author_invalid",
980
+ `'${file}' names author '${String(declared).slice(0, 80)}', which is neither a site author's handle (such as `
981
+ + "jane-doe) nor a member's email address.",
982
+ file,
983
+ );
984
+ }
985
+ const current = parseAuthorReference(recorded)?.value ?? "";
986
+ if (reference.value === current) return { author: reference.value, sendAuthor: false };
987
+ if (current !== "") {
988
+ throw documentError(
989
+ "pages.author_conflict",
990
+ authorConflictMessage({
991
+ shown: displayPagePath(pagePath),
992
+ file,
993
+ held: current,
994
+ wanted: reference.value,
995
+ markdown,
996
+ }),
997
+ file,
998
+ );
999
+ }
1000
+ let known = authorKnown(await knownAuthors(), reference.value);
1001
+ let unverified = false;
1002
+ if (known !== true) {
1003
+ if (refreshAuthors === undefined) {
1004
+ unverified = known === false;
1005
+ } else {
1006
+ const fresh = await refreshAuthors();
1007
+ known = authorKnown(fresh, reference.value);
1008
+ if (known === false) {
1009
+ throw documentError(
1010
+ "pages.author_unknown",
1011
+ `'${file}' names author '${reference.value}', who is not among the site's authors or the members who can `
1012
+ + "create pages. Run 'taproot-site authors list' to see who can be named, or 'taproot-site authors add' "
1013
+ + "to create a site author.",
1014
+ file,
820
1015
  );
821
1016
  }
822
- return normalized;
823
- }))
824
- : undefined;
825
- // Manifest integrity is never scoped: a registry that contradicts itself
826
- // describes some other workspace, and a selection cannot make that safe.
827
- requireManifestSourceRegistry(pageSourceRegistry(manifest));
828
- const targeted = requestedPaths !== undefined;
1017
+ }
1018
+ }
1019
+ return { author: reference.value, sendAuthor: true, unverified };
1020
+ }
829
1021
 
830
- // The shared list, so the set `pull` refuses to write over is exactly the set
831
- // this walks: a file one counted and the other ignored would be a page pushed
832
- // from a workspace nothing proved the ownership of.
833
- const files = await walkWorkspaceFiles(config.workspaceDir, PAGES_DIRECTORY, PAGE_SOURCE_EXTENSIONS);
834
- if (files.length === 0) {
835
- throw new SiteAuthoringError(
836
- "pages.none_found",
837
- `No Markdown or ProseMirror page files were found under '${PAGES_DIRECTORY}/' in the workspace.`,
838
- { field: PAGES_DIRECTORY },
839
- );
1022
+ /**
1023
+ * Phase one over the workspace's page sources (TR01002): resolve which file is
1024
+ * each page's one source, convert and validate it, and decide create, update,
1025
+ * or unchanged — collecting every problem rather than stopping at the first.
1026
+ *
1027
+ * `livePages` is what the sources are checked against. A push passes the
1028
+ * site's own list with `online`; the offline workspace check passes the pull
1029
+ * manifest's pages, which is what the site held when the workspace was pulled,
1030
+ * and skips the two guards only a live read can arm (a moved revision or
1031
+ * display date). `getSharedThemeContexts` names the theme's contexts when
1032
+ * the caller has already validated the theme.
1033
+ */
1034
+ export async function planPages({
1035
+ workspaceDir,
1036
+ siteId,
1037
+ manifest,
1038
+ mediaManifest,
1039
+ content,
1040
+ files,
1041
+ livePages,
1042
+ online,
1043
+ requestedPaths,
1044
+ getSharedThemeContexts,
1045
+ refreshAuthors,
1046
+ onProgress = () => {},
1047
+ }) {
1048
+ const problems = [];
1049
+ const area = { area: CHECK_AREA.pages };
1050
+ const targeted = requestedPaths !== undefined;
1051
+ // Generated-page sources whose page no longer exists on the site, found in a
1052
+ // whole-workspace run: reported, and nothing is sent for them.
1053
+ const staleGenerated = [];
1054
+ // Manifest integrity is never scoped: a registry that contradicts itself
1055
+ // describes some other workspace, so nothing in it can be planned.
1056
+ await collectProblems(problems, area, () => requireManifestSourceRegistry(pageSourceRegistry(manifest)));
1057
+ if (problems.length > 0) {
1058
+ return { targeted, sources: [], selected: [], planned: [], unresolved: [], staleGenerated, problems };
840
1059
  }
841
- let sharedThemeContexts;
1060
+ const live = livePages.filter((summary) => summary.status !== PAGE_STATUS_DELETED);
1061
+ const liveById = new Map(live.map((summary) => [summary.pageId, summary]));
1062
+ const liveByPath = new Map(live.map((summary) => [normalizePagePath(summary.path) ?? summary.path, summary]));
842
1063
  const manifestByFile = new Map(
843
1064
  manifest.pages.filter((entry) => typeof entry?.file === "string").map((entry) => [entry.file, entry]),
844
1065
  );
@@ -850,118 +1071,156 @@ export async function pagesPush(invocation) {
850
1071
  const manifestByPageId = new Map(
851
1072
  manifest.pages.filter((entry) => typeof entry?.pageId === "string").map((entry) => [entry.pageId, entry]),
852
1073
  );
853
-
854
- return await withRefusalGuidance(onProgress, "push", async () => {
855
- onProgress("Listing the site's pages to resolve creates from updates.");
856
- const { pages: livePages, truncated } = await listSitePages(client, siteId, { onProgress });
857
- if (truncated) {
858
- // Every create-or-update decision below is made against this list. A
859
- // partial one turns a tracked page into a create, which the server then
860
- // refuses for a duplicate path — mid-phase-two, after earlier pages have
861
- // already been written.
862
- throw new SiteAuthoringError(
863
- "pages.live_list_truncated",
864
- "The site has more pages than this CLI can enumerate, so an update cannot be told from a create. "
865
- + "No page was pushed.",
866
- { field: PAGES_DIRECTORY },
867
- );
1074
+ let sharedThemeContexts;
1075
+ // What the workspace last recorded about who can be named (TR01196). Absent
1076
+ // is the common case and means a reference cannot be checked offline, which
1077
+ // is never a reason to refuse it.
1078
+ let recordedAuthors;
1079
+ let recordedAuthorsRead = false;
1080
+ const getRecordedAuthors = async () => {
1081
+ if (!recordedAuthorsRead) {
1082
+ recordedAuthorsRead = true;
1083
+ recordedAuthors = await readRecordedAuthors(workspaceDir, siteId);
868
1084
  }
869
- const live = livePages.filter((summary) => summary.status !== PAGE_STATUS_DELETED);
870
- const liveById = new Map(live.map((summary) => [summary.pageId, summary]));
871
- const liveByPath = new Map(live.map((summary) => [normalizePagePath(summary.path) ?? summary.path, summary]));
1085
+ return recordedAuthors;
1086
+ };
1087
+ // At most one live read per run, however many pages name a new author.
1088
+ let liveAuthorsRead;
1089
+ const getLiveAuthors = refreshAuthors === undefined
1090
+ ? undefined
1091
+ : () => {
1092
+ liveAuthorsRead ??= refreshAuthors();
1093
+ return liveAuthorsRead;
1094
+ };
872
1095
 
873
- // Resolution: which file is each page path's one authoritative source.
874
- // This reads metadata only — a manifest entry, or a front-matter block —
875
- // so no document is converted, no media reference is resolved, and no
876
- // component contract is checked until the selection is known.
877
- const sources = [];
878
- const claimants = [];
879
- const unresolved = [];
880
- for (const file of files) {
881
- const manifestEntry = manifestByFile.get(file);
882
- try {
883
- const source = await readWorkspacePageSource({ workspaceDir: config.workspaceDir, file, manifestEntry });
884
- if (source.pagePath === undefined) {
885
- // A file whose page identity is ambiguous. A whole-workspace push
886
- // reports every authored page, so it is a refusal there; a targeted
887
- // one refuses only if the selection touches a path it might be.
888
- if (!targeted) throw source.fault;
889
- claimants.push(source);
890
- continue;
891
- }
892
- sources.push(source);
893
- } catch (error) {
894
- // A source whose metadata cannot be read declares no page path, so it
895
- // cannot be the page a selection asked for. A whole-workspace push is
896
- // the command that reports it; a targeted one names it and carries on.
897
- // Anything that is not a metadata fault — a containment refusal, an
898
- // oversized file, an unreadable one — still fails closed.
899
- if (!targeted || !(error instanceof SiteAuthoringError) || !RESOLUTION_METADATA_CODES.has(error.code)) {
900
- throw error;
901
- }
1096
+ // Resolution: which file is each page path's one authoritative source.
1097
+ // This reads metadata only — a manifest entry, or a front-matter block —
1098
+ // so no document is converted, no media reference is resolved, and no
1099
+ // component contract is checked until the selection is known.
1100
+ const sources = [];
1101
+ const claimants = [];
1102
+ const unresolved = [];
1103
+ // Sources of generated pages the site has since retired, which pull keeps
1104
+ // apart from the live pages (TR01195). Never sent; reported, not refused,
1105
+ // unless a push names that path.
1106
+ const retiredByFile = new Map(
1107
+ (Array.isArray(manifest.retiredGeneratedSources) ? manifest.retiredGeneratedSources : [])
1108
+ .filter((entry) => typeof entry?.file === "string" && typeof entry?.path === "string")
1109
+ .map((entry) => [entry.file, entry]),
1110
+ );
1111
+ for (const file of files) {
1112
+ const manifestEntry = manifestByFile.get(file);
1113
+ const retired = manifestEntry === undefined ? retiredByFile.get(file) : undefined;
1114
+ if (retired !== undefined) {
1115
+ const retiredPath = normalizePagePath(retired.path) ?? retired.path;
1116
+ // A page created since at the same path has its own source, which is the
1117
+ // one a push by that path sends.
1118
+ if (targeted && requestedPaths.has(retiredPath) && !liveByPath.has(retiredPath)) {
1119
+ throw documentError(
1120
+ "pages.generated_create",
1121
+ `'${file}' was the source of a generated page that the site no longer has, and a push cannot create `
1122
+ + "one. Delete the file.",
1123
+ file,
1124
+ );
1125
+ }
1126
+ if (!targeted) {
1127
+ staleGenerated.push({ file, path: displayPagePath(retiredPath) });
1128
+ onProgress(`'${file}' is the source of a generated page the site no longer has; nothing is sent for it.`);
1129
+ }
1130
+ continue;
1131
+ }
1132
+ try {
1133
+ const source = await readWorkspacePageSource({ workspaceDir, file, manifestEntry });
1134
+ if (source.pagePath === undefined) {
1135
+ // A file whose page identity is ambiguous. A whole-workspace check
1136
+ // reports it; a targeted one refuses only if the selection touches a
1137
+ // path it might be.
1138
+ if (targeted) claimants.push(source);
1139
+ else problems.push(...problemsOf(source.fault ?? missingPathError(file), { ...area, file }));
1140
+ continue;
1141
+ }
1142
+ sources.push(source);
1143
+ } catch (error) {
1144
+ if (!(error instanceof SiteAuthoringError)) throw error;
1145
+ // A source whose metadata cannot be read declares no page path, so it
1146
+ // cannot be the page a selection asked for: a targeted push names it and
1147
+ // carries on. Anything that is not a metadata fault — a containment
1148
+ // refusal, an oversized file, an unreadable one — still fails closed.
1149
+ if (targeted && RESOLUTION_METADATA_CODES.has(error.code)) {
902
1150
  unresolved.push({ file, code: error.code });
903
1151
  onProgress(`'${file}' declares no readable page path (${error.code}); it is outside this selection.`);
1152
+ continue;
904
1153
  }
1154
+ if (targeted) throw error;
1155
+ problems.push(...problemsOf(error, { ...area, file }));
905
1156
  }
1157
+ }
906
1158
 
907
- // Two editable sources for one page path. Whichever push would touch that
908
- // path refuses: choosing between them is exactly the ambiguity one source
909
- // per page exists to remove, and it is also how the documented format
910
- // change — remove the old source, author the other beside it — is proved
911
- // to have actually removed the old one.
912
- const sourcesByPath = new Map();
913
- for (const source of sources) {
914
- const existing = sourcesByPath.get(source.pagePath);
915
- if (existing === undefined) {
916
- sourcesByPath.set(source.pagePath, source);
917
- continue;
918
- }
919
- if (!targeted || requestedPaths.has(source.pagePath)) {
920
- throw documentError(
1159
+ // Two editable sources for one page path. Whichever check touches that path
1160
+ // refuses: choosing between them is exactly the ambiguity one source per
1161
+ // page exists to remove, and it is also how the documented format change —
1162
+ // remove the old source, author the other beside it — is proved to have
1163
+ // actually removed the old one.
1164
+ const sourcesByPath = new Map();
1165
+ const conflicted = new Set();
1166
+ for (const source of sources) {
1167
+ const existing = sourcesByPath.get(source.pagePath);
1168
+ if (existing === undefined) {
1169
+ sourcesByPath.set(source.pagePath, source);
1170
+ continue;
1171
+ }
1172
+ if (!targeted || requestedPaths.has(source.pagePath)) {
1173
+ conflicted.add(source.pagePath);
1174
+ problems.push(...problemsOf(
1175
+ documentError(
921
1176
  "pages.path_conflict",
922
1177
  `'${source.file}' and '${existing.file}' both claim page path '${source.pagePath}'.`,
923
1178
  source.pagePath,
924
- );
925
- }
926
- onProgress(
927
- `'${source.file}' and '${existing.file}' both claim page path '${source.pagePath}'; `
928
- + "neither is in this selection.",
929
- );
1179
+ ),
1180
+ { ...area, file: source.file },
1181
+ ));
1182
+ continue;
930
1183
  }
1184
+ onProgress(
1185
+ `'${source.file}' and '${existing.file}' both claim page path '${source.pagePath}'; `
1186
+ + "neither is in this selection.",
1187
+ );
1188
+ }
931
1189
 
932
- const selected = targeted ? sources.filter((source) => requestedPaths.has(source.pagePath)) : sources;
933
- if (targeted) {
934
- // Before deciding what the selection resolved to: a file that might be
935
- // one of the selected pages makes the selection unprovable, so its own
936
- // fault is what this push reports.
937
- const claimant = claimants.find((source) => source.pathClaims.some((claim) => requestedPaths.has(claim)));
938
- if (claimant !== undefined) throw claimant.fault;
939
- }
940
- if (targeted) {
941
- const matched = new Set(selected.map((source) => source.pagePath));
942
- const missing = [...requestedPaths].filter((pagePath) => !matched.has(pagePath)).sort();
943
- if (missing.length > 0) {
944
- // The homepage normalizes to the empty path, which the result emitters
945
- // drop as falsy; name it by its documented '/' spelling so the stable
946
- // error contract keeps a field for every unknown path.
947
- throw new SiteAuthoringError(
948
- "pages.page_not_found",
949
- `No workspace page was found for page path '${missing[0] || "/"}'.`,
950
- { field: missing[0] || "/" },
951
- );
952
- }
953
- onProgress(
954
- `Selected ${selected.length} of ${sources.length} page source(s) by path; `
955
- + "only the selection is validated and sent.",
1190
+ const selected = (targeted ? sources.filter((source) => requestedPaths.has(source.pagePath)) : sources)
1191
+ .filter((source) => !conflicted.has(source.pagePath));
1192
+ if (targeted) {
1193
+ // Before deciding what the selection resolved to: a file that might be
1194
+ // one of the selected pages makes the selection unprovable, so its own
1195
+ // fault is what this push reports.
1196
+ const claimant = claimants.find((source) => source.pathClaims.some((claim) => requestedPaths.has(claim)));
1197
+ if (claimant !== undefined) throw claimant.fault;
1198
+ const matched = new Set([...selected.map((source) => source.pagePath), ...conflicted]);
1199
+ const missing = [...requestedPaths].filter((pagePath) => !matched.has(pagePath)).sort();
1200
+ if (missing.length > 0) {
1201
+ // The homepage normalizes to the empty path, which the result emitters
1202
+ // drop as falsy; name it by its documented '/' spelling so the stable
1203
+ // error contract keeps a field for every unknown path.
1204
+ throw new SiteAuthoringError(
1205
+ "pages.page_not_found",
1206
+ `No workspace page was found for page path '${missing[0] || "/"}'.`,
1207
+ { field: missing[0] || "/" },
956
1208
  );
957
- } else {
958
- onProgress(`Validating every one of the ${sources.length} page source(s) in this workspace.`);
959
1209
  }
1210
+ onProgress(
1211
+ `Selected ${selected.length} of ${sources.length} page source(s) by path; `
1212
+ + "only the selection is validated and sent.",
1213
+ );
1214
+ } else {
1215
+ onProgress(`Validating every one of the ${sources.length} page source(s) in this workspace.`);
1216
+ }
960
1217
 
961
- // Phase one: convert and validate the selection. No mutation is sent until
962
- // every document that will be sent has passed.
963
- const planned = [];
964
- for (const source of selected) {
1218
+ // Phase one proper: convert and validate the selection. Each source's first
1219
+ // refusal is collected and the next source is checked; nothing is sent
1220
+ // until the whole list is empty.
1221
+ const planned = [];
1222
+ for (const source of selected) {
1223
+ await collectProblems(problems, { ...area, file: source.file }, async () => {
965
1224
  const { file, pagePath, title, declaredDescription } = source;
966
1225
  onProgress(`Validating '${file}'.`);
967
1226
  const manifestEntry = manifestByFile.get(file);
@@ -972,6 +1231,88 @@ export async function pagesPush(invocation) {
972
1231
  const target = manifestEntry?.pageId !== undefined
973
1232
  ? liveById.get(manifestEntry.pageId)
974
1233
  : liveByPath.get(pagePath);
1234
+ if (manifestEntry?.pageId === undefined && target !== undefined && pendingCreateFor(manifest, pagePath)) {
1235
+ throw documentError(
1236
+ "pages.create_unconfirmed",
1237
+ `An earlier push tried to create page '${pagePath || "/"}' from '${file}' without hearing back, and the site now has a `
1238
+ + "page at that path. It may be that create or someone else's page, so nothing will write over it: run "
1239
+ + "'taproot-site pull' to record the site's page, then reconcile it with this source and push again.",
1240
+ file,
1241
+ );
1242
+ }
1243
+ const generated = documentTemplate(document_) === TEMPLATE_GENERATED;
1244
+ if (generated && target === undefined) {
1245
+ if (targeted) {
1246
+ throw documentError(
1247
+ "pages.generated_create",
1248
+ `'${file}' is a generated-page source, but the site has no generated page for it. Taproot creates `
1249
+ + "generated pages from the site's content; a push cannot create one. If the page no longer exists, "
1250
+ + "delete this file and run 'taproot-site pull'.",
1251
+ file,
1252
+ );
1253
+ }
1254
+ // The site retired this page (its tag, month, place or folder is gone),
1255
+ // which is Taproot's doing, not a mistake in the workspace: it cannot
1256
+ // block the pages that do exist.
1257
+ staleGenerated.push({ file, path: displayPagePath(pagePath) });
1258
+ onProgress(
1259
+ `'${file}' is a generated-page source, but the site has no generated page at '${pagePath || "/"}' any more; `
1260
+ + "nothing is sent for it. Delete the file, or run 'taproot-site pull' to refresh the workspace.",
1261
+ );
1262
+ return;
1263
+ }
1264
+ // The file-keyed entry is gone whenever the file was renamed or replaced —
1265
+ // which is exactly what the pull, delete the `.pm.json`, author a `.md`
1266
+ // flow does. Falling back through the page's own identity keeps the
1267
+ // fields the workspace never restated; without it the whole-object
1268
+ // UpdatePage sends "" and false and silently clears both.
1269
+ const targetEntry = target === undefined ? undefined : manifestByPageId.get(target.pageId);
1270
+ const description = generated
1271
+ // The manifest's title and description are what the site reported; the owner's are in the document.
1272
+ ? generatedPageDescription(document_.data, declaredDescription ?? targetEntry?.description ?? "")
1273
+ : declaredDescription ?? targetEntry?.description ?? "";
1274
+ const sentTitle = generated ? generatedPageTitle(document_.data, title) : title;
1275
+ const sentDescription = typeof description === "string" ? description : "";
1276
+ // A Markdown source carries its author in the front matter its hash
1277
+ // already covers; only a .pm.json page keeps one in the manifest, where
1278
+ // the key has to see it. Absent, the key is the one it always was.
1279
+ const markdownSource = source.sourceFormat === PAGE_SOURCE_FORMAT_MARKDOWN;
1280
+ const contentKeyFor = (author) => pageContentKey(
1281
+ source.sourceHash,
1282
+ {
1283
+ title: sentTitle,
1284
+ path: pagePath,
1285
+ description: sentDescription,
1286
+ author: markdownSource ? "" : author,
1287
+ },
1288
+ markdownSource ? canonicalDocumentHash(document_) : "",
1289
+ );
1290
+ // A generated page has no author, so its key is final here. Every other
1291
+ // page's key waits for the author resolved below.
1292
+ let contentKey = contentKeyFor("");
1293
+ // The source and everything sent with it match what the site last agreed
1294
+ // to, so sending again would only turn an approved page back into a draft.
1295
+ // Not when the operator was shown a conflict for this page (the record
1296
+ // exists to let this push reassert the source), and not when the source
1297
+ // moved or changed format, which the send re-registers in the manifest.
1298
+ const isUnchanged = (record) => target !== undefined
1299
+ && targetEntry?.baseline?.contentKey === contentKey
1300
+ && record === undefined
1301
+ && targetEntry.file === file
1302
+ && targetEntry.sourceFormat === source.sourceFormat;
1303
+ // An untouched generated page is not this push's to judge. Taproot
1304
+ // moves its revision and its path on its own (a tag rename, a month that
1305
+ // gained a post), and refusing the whole push for a page nothing here
1306
+ // sends would make one renamed page block every other edit.
1307
+ let untouchedGenerated = false;
1308
+ if (generated) {
1309
+ const observedFile = internalPageObservedRevisionFile(target.pageId);
1310
+ if (observedFile !== undefined) await workspaceFileExists(workspaceDir, observedFile);
1311
+ untouchedGenerated = isUnchanged(await readObservedPageRecord(workspaceDir, target.pageId));
1312
+ if (!untouchedGenerated) {
1313
+ requireGeneratedUpdate({ file, pagePath, target, entry: manifestEntry ?? targetEntry, document_ });
1314
+ }
1315
+ }
975
1316
  const workspaceSystemKind = systemPageKind(pagePath);
976
1317
  if (target === undefined) {
977
1318
  if (workspaceSystemKind !== undefined) {
@@ -1008,18 +1349,20 @@ export async function pagesPush(invocation) {
1008
1349
  // a directory or a symlink at its path is refused here, before any
1009
1350
  // page is written, rather than discovered after the site has changed.
1010
1351
  const observedRecordFile = internalPageObservedRevisionFile(target.pageId);
1011
- if (observedRecordFile !== undefined) await workspaceFileExists(config.workspaceDir, observedRecordFile);
1012
- const observed = await readObservedPageRecord(config.workspaceDir, target.pageId);
1352
+ if (observedRecordFile !== undefined) await workspaceFileExists(workspaceDir, observedRecordFile);
1353
+ const observed = await readObservedPageRecord(workspaceDir, target.pageId);
1013
1354
  observedRecord = observed;
1014
- const guard = {
1015
- file,
1016
- pagePath,
1017
- target,
1018
- entry: manifestByPageId.get(target.pageId),
1019
- observedRevision: observed?.revision,
1020
- };
1021
- requireReconciledRevision(guard);
1022
- requireReconciledDisplayDate({ ...guard, document_, observed });
1355
+ if (online && !untouchedGenerated) {
1356
+ const guard = {
1357
+ file,
1358
+ pagePath,
1359
+ target,
1360
+ entry: manifestByPageId.get(target.pageId),
1361
+ observedRevision: observed?.revision,
1362
+ };
1363
+ requireReconciledRevision(guard);
1364
+ requireReconciledDisplayDate({ ...guard, document_, observed });
1365
+ }
1023
1366
  }
1024
1367
 
1025
1368
  // The duplicate-source check above only catches two workspace files
@@ -1029,7 +1372,7 @@ export async function pagesPush(invocation) {
1029
1372
  // earlier pages have been written. A page matching itself is the ordinary
1030
1373
  // update and is left alone.
1031
1374
  const holder = liveByPath.get(pagePath);
1032
- if (holder !== undefined && holder.pageId !== target?.pageId) {
1375
+ if (!untouchedGenerated && holder !== undefined && holder.pageId !== target?.pageId) {
1033
1376
  throw documentError(
1034
1377
  "pages.path_taken",
1035
1378
  `'${file}' claims page path '${pagePath}', which page ${holder.pageId} already holds on this site. `
@@ -1038,68 +1381,248 @@ export async function pagesPush(invocation) {
1038
1381
  );
1039
1382
  }
1040
1383
 
1041
- // The file-keyed entry is gone whenever the file was renamed or replaced —
1042
- // which is exactly what the pull, delete the `.pm.json`, author a `.md`
1043
- // flow does. Falling back through the page's own identity keeps the
1044
- // fields the workspace never restated; without it the whole-object
1045
- // UpdatePage sends "" and false and silently clears both.
1046
- const targetEntry = target === undefined ? undefined : manifestByPageId.get(target.pageId);
1047
- const description = declaredDescription ?? targetEntry?.description ?? "";
1048
-
1049
1384
  await validateWorkspacePageDocument({
1050
- workspaceDir: config.workspaceDir,
1385
+ workspaceDir,
1051
1386
  siteId,
1052
1387
  file,
1053
1388
  document: document_,
1054
1389
  content,
1055
- getSharedThemeContexts: async () => {
1056
- sharedThemeContexts ??= await readSharedThemeContexts(config.workspaceDir, siteId);
1390
+ getSharedThemeContexts: getSharedThemeContexts ?? (async () => {
1391
+ sharedThemeContexts ??= await readSharedThemeContexts(workspaceDir, siteId);
1057
1392
  return sharedThemeContexts;
1058
- },
1393
+ }),
1059
1394
  });
1060
1395
 
1061
- const sentDescription = typeof description === "string" ? description : "";
1062
- const contentKey = pageContentKey(
1063
- source.sourceHash,
1064
- { title, path: pagePath, description: sentDescription },
1065
- source.sourceFormat === PAGE_SOURCE_FORMAT_MARKDOWN ? canonicalDocumentHash(document_) : "",
1066
- );
1396
+ // Online, the listing says whom the site credits now; the baseline is
1397
+ // only whom it credited at the last reconcile. Discarding a draft in the
1398
+ // app can drop an author the CLI assigned, and the push must then ask
1399
+ // again rather than trust the record. A page that credits someone this
1400
+ // credential may not name keeps the recorded author (TR01196).
1401
+ const recordedAuthor = targetEntry?.baseline?.author;
1402
+ const heldAuthor = online && target !== undefined
1403
+ ? target.authorRef || (target.hasAuthor ? recordedAuthor : "")
1404
+ : recordedAuthor;
1405
+ const authorship = generated
1406
+ ? { author: "", sendAuthor: false }
1407
+ : await resolvePageAuthor({
1408
+ file,
1409
+ pagePath,
1410
+ declared: source.declaredAuthor ?? targetEntry?.author,
1411
+ recorded: heldAuthor,
1412
+ markdown: markdownSource,
1413
+ knownAuthors: getRecordedAuthors,
1414
+ refreshAuthors: getLiveAuthors,
1415
+ });
1416
+ contentKey = contentKeyFor(authorship.author);
1417
+ // An author the site no longer holds is a change to send, however the
1418
+ // source compares with the record.
1419
+ const unchanged = isUnchanged(observedRecord) && !(online && target !== undefined && authorship.sendAuthor);
1420
+ // Only for a page this push sends: a description stored before the limit
1421
+ // existed must not block a push that leaves it alone (TR01194). A
1422
+ // generated page's derived description is not the owner's to shorten;
1423
+ // its customDescription is checked with the document.
1424
+ if (!generated && !unchanged && [...sentDescription].length > DESCRIPTION_MAXIMUM_LENGTH) {
1425
+ throw documentError(
1426
+ "pages.description_too_long",
1427
+ `'${file}' has a ${[...sentDescription].length}-character description; it may be at most `
1428
+ + `${DESCRIPTION_MAXIMUM_LENGTH}, and search results show about ${DESCRIPTION_WARN_LENGTH}.`,
1429
+ file,
1430
+ );
1431
+ }
1067
1432
  planned.push({
1068
1433
  file,
1069
1434
  pagePath,
1070
- title,
1435
+ title: sentTitle,
1436
+ // Only the owner's own description is warned about for length: the derived one is not theirs to shorten.
1437
+ ...(generated ? { declaredDescription: document_.data.customDescription } : {}),
1071
1438
  contentKey,
1072
- // The source and everything sent with it match what the site last agreed
1073
- // to, so sending again would only turn an approved page back into a draft.
1074
- // Not when the operator was shown a conflict for this page (the record
1075
- // exists to let this push reassert the source), and not when the source
1076
- // moved or changed format, which the send re-registers in the manifest.
1077
- unchanged: target !== undefined
1078
- && targetEntry?.baseline?.contentKey === contentKey
1079
- && observedRecord === undefined
1080
- && targetEntry.file === file
1081
- && targetEntry.sourceFormat === source.sourceFormat,
1439
+ unchanged,
1082
1440
  description: sentDescription,
1441
+ author: authorship.author,
1442
+ sendAuthor: authorship.sendAuthor,
1443
+ ...(authorship.unverified === true ? { authorUnverified: true } : {}),
1083
1444
  document: document_,
1084
1445
  sourceFormat: source.sourceFormat,
1085
1446
  sourceHash: source.sourceHash,
1086
1447
  pageId: target?.pageId,
1448
+ // The live revision this page would be sent against; `plan` binds it
1449
+ // into its hash so a concurrent edit invalidates the plan.
1450
+ revision: target?.bodyRevision,
1087
1451
  templateType: PAGE_TEMPLATES[documentTemplate(document_)].wireType,
1088
1452
  action: target === undefined ? "created" : "updated",
1089
1453
  });
1090
- }
1454
+ });
1455
+ }
1456
+
1457
+ return { targeted, sources, selected, planned, unresolved, staleGenerated, problems };
1458
+ }
1459
+
1460
+ function missingPathError(file) {
1461
+ return documentError(
1462
+ "pages.path_missing",
1463
+ `'${file}' does not declare a usable page path. Add 'path:' to its front-matter (use an empty value for the home page).`,
1464
+ file,
1465
+ );
1466
+ }
1467
+
1468
+ /**
1469
+ * The generated-page sources whose page the site no longer has, as a result
1470
+ * field (TR01195). Reported rather than refused: the site retired the page, and
1471
+ * nothing is sent for the file.
1472
+ */
1473
+ export function staleGeneratedSourcesField(staleGenerated) {
1474
+ return staleGenerated.length === 0
1475
+ ? {}
1476
+ : { staleGeneratedSources: { total: staleGenerated.length, ...reportedList("items", staleGenerated, LIST_BYTES) } };
1477
+ }
1478
+
1479
+ /** What a dry run or a plan reports for one planned page. */
1480
+ export function plannedPageItem(page) {
1481
+ return { file: page.file, path: displayPagePath(page.pagePath), action: page.unchanged ? "unchanged" : page.action };
1482
+ }
1483
+
1484
+ export async function pagesPush(invocation) {
1485
+ const session = await openSession(invocation);
1486
+ const { client, config, siteId, now, onProgress } = session;
1487
+ const dryRun = invocation.dryRun === true;
1488
+ // One advisory line before this verb does any work, and only when the
1489
+ // exchange said the platform is paused. It changes nothing else: the write
1490
+ // still runs and its refusal still classifies as platform_paused (TR00692).
1491
+ if (!dryRun) warnIfExternalWritesPaused(session, VERB_PAGES_PUSH);
1492
+ // Both manifests are bound to the site before anything is planned or sent:
1493
+ // every id in them is site-scoped, and a workspace pulled from another site
1494
+ // reads as entirely valid until phase two is already writing.
1495
+ const manifest = await readManifest(config.workspaceDir, siteId);
1496
+ const mediaManifest = await readMediaManifest(config.workspaceDir, siteId);
1497
+ const content = requireContentFunctions(await loadContentModule(invocation.content));
1498
+ const requestedPaths = Array.isArray(invocation.pagePaths)
1499
+ ? new Set(invocation.pagePaths.map((value) => {
1500
+ const normalized = normalizePagePath(value);
1501
+ if (normalized === undefined) {
1502
+ throw new SiteAuthoringError(
1503
+ "pages.page_path_invalid",
1504
+ `'${typeof value === "string" ? value : String(value)}' is not a usable page path.`,
1505
+ { field: typeof value === "string" ? value : undefined, exitCode: 2 },
1506
+ );
1507
+ }
1508
+ return normalized;
1509
+ }))
1510
+ : undefined;
1511
+ // Refused before any request: a registry that contradicts itself describes
1512
+ // some other workspace, and a selection cannot make that safe.
1513
+ requireManifestSourceRegistry(pageSourceRegistry(manifest));
1514
+
1515
+ // The shared list, so the set `pull` refuses to write over is exactly the set
1516
+ // this walks: a file one counted and the other ignored would be a page pushed
1517
+ // from a workspace nothing proved the ownership of.
1518
+ const files = await walkWorkspaceFiles(config.workspaceDir, PAGES_DIRECTORY, PAGE_SOURCE_EXTENSIONS);
1519
+ if (files.length === 0) {
1520
+ throw new SiteAuthoringError(
1521
+ "pages.none_found",
1522
+ `No Markdown or ProseMirror page files were found under '${PAGES_DIRECTORY}/' in the workspace.`,
1523
+ { field: PAGES_DIRECTORY },
1524
+ );
1525
+ }
1091
1526
 
1527
+ return await withRefusalGuidance(onProgress, "push", async () => {
1528
+ onProgress("Listing the site's pages to resolve creates from updates.");
1529
+ const { pages: livePages, truncated } = await listSitePages(client, siteId, { onProgress });
1530
+ if (truncated) {
1531
+ // Every create-or-update decision below is made against this list. A
1532
+ // partial one turns a tracked page into a create, which the server then
1533
+ // refuses for a duplicate path — mid-phase-two, after earlier pages have
1534
+ // already been written.
1535
+ throw new SiteAuthoringError(
1536
+ "pages.live_list_truncated",
1537
+ "The site has more pages than this CLI can enumerate, so an update cannot be told from a create. "
1538
+ + "No page was pushed.",
1539
+ { field: PAGES_DIRECTORY },
1540
+ );
1541
+ }
1542
+ const plan = await planPages({
1543
+ workspaceDir: config.workspaceDir,
1544
+ siteId,
1545
+ manifest,
1546
+ mediaManifest,
1547
+ content,
1548
+ files,
1549
+ livePages,
1550
+ online: true,
1551
+ requestedPaths,
1552
+ refreshAuthors: liveAuthorsRefresher({
1553
+ client,
1554
+ workspaceDir: config.workspaceDir,
1555
+ siteId,
1556
+ // A dry run writes nothing, authors.json included.
1557
+ write: !dryRun,
1558
+ onProgress,
1559
+ }),
1560
+ onProgress,
1561
+ });
1562
+ const { planned, targeted, unresolved, staleGenerated } = plan;
1092
1563
  // A page that places a video names it by id, and only the library knows
1093
1564
  // whether that id is one of this site's videos. Asked once for the whole
1094
1565
  // push, before anything is written, so a page cannot be sent that
1095
1566
  // publishing would then refuse.
1096
- await requirePlacedVideosExist(client, siteId, planned);
1567
+ plan.problems.push(...await placedVideoProblems(client, siteId, planned));
1568
+
1569
+ // Under apply, the workspace read ledger has already held every source and
1570
+ // manifest to the plan. What it cannot see is the site: every page the plan
1571
+ // would send must still be the page, revision and action it listed, and
1572
+ // nothing it did not list may be sent. A planned page can still turn out
1573
+ // unchanged, when an upload deduplicates to the image it already shows, and
1574
+ // is then skipped.
1575
+ if (invocation.plannedPages !== undefined) {
1576
+ const byFile = new Map(planned.map((page) => [page.file, page]));
1577
+ for (const [file, expected] of invocation.plannedPages) {
1578
+ const page = byFile.get(file);
1579
+ if (page === undefined || stableJson([page.pageId ?? "", page.revision ?? "", page.action]) !== expected) {
1580
+ throw planStale(`'${file}'`, file);
1581
+ }
1582
+ }
1583
+ const unplanned = planned.find((page) => !page.unchanged && !invocation.plannedPages.has(page.file));
1584
+ if (unplanned !== undefined) throw planStale(`'${unplanned.file}'`, unplanned.file);
1585
+ }
1586
+ // What this run actually looked at, so automation can tell a targeted
1587
+ // push from a whole-workspace one without inferring it from counts.
1588
+ const selection = {
1589
+ selection: targeted ? "targeted" : "workspace",
1590
+ ...(targeted
1591
+ ? reportedList("selectedPaths", [...requestedPaths].sort().map((pagePath) => pagePath || "/"), LIST_BYTES)
1592
+ : {}),
1593
+ discovered: files.length,
1594
+ };
1595
+ // Warned about only for pages this push sends, so an unchanged page is not
1596
+ // re-reported every time.
1597
+ const sending = planned.filter((page) => !page.unchanged);
1598
+ const warnings = { ...autolinkWarnings(sending, onProgress), ...descriptionWarnings(sending, onProgress) };
1599
+ if (dryRun) {
1600
+ refuseProblems(plan.problems, "pages push --dry-run found problems and sent nothing");
1601
+ onProgress("Dry run: nothing was sent.");
1602
+ return successResult(VERB_PAGES_PUSH, siteId, {
1603
+ dryRun: true,
1604
+ pages: {
1605
+ total: planned.length,
1606
+ unchanged: planned.filter((page) => page.unchanged).length,
1607
+ wouldCreate: planned.filter((page) => !page.unchanged && page.action === "created").length,
1608
+ wouldUpdate: planned.filter((page) => !page.unchanged && page.action === "updated").length,
1609
+ ...selection,
1610
+ validated: planned.length,
1611
+ ...staleGeneratedSourcesField(staleGenerated),
1612
+ ...reportedList("items", planned.map(plannedPageItem), ITEMS_BYTES),
1613
+ },
1614
+ ...warnings,
1615
+ });
1616
+ }
1617
+ refuseProblems(plan.problems, "pages push sent nothing");
1097
1618
  await copyVideoEmbedPosters(client, siteId, planned, { onProgress, now });
1098
1619
 
1099
1620
  // Phase two: send. The manifest is written back even if a later page fails,
1100
1621
  // so a retry updates the pages this run created rather than duplicating them.
1101
1622
  const applied = [];
1102
1623
  let manifestDirty = false;
1624
+ // The page whose request is in flight, so a failure names it and not the next one.
1625
+ let attempting;
1103
1626
  try {
1104
1627
  for (const page of planned) {
1105
1628
  if (page.unchanged) {
@@ -1113,17 +1636,40 @@ export async function pagesPush(invocation) {
1113
1636
  shortDescription: page.description,
1114
1637
  template: wireTemplate(page.document),
1115
1638
  ...wirePageFields(page.document),
1639
+ // Only a name the site has not been told: repeating the author a page
1640
+ // already has would let a later change of that person's permissions
1641
+ // refuse a push that changes nothing about authorship.
1642
+ ...(page.sendAuthor ? { author: page.author } : {}),
1116
1643
  };
1117
- const summary = page.action === "created"
1118
- ? await createPage(client, { siteId, ...fields })
1119
- : await updatePage(client, page.pageId, { pageId: page.pageId, ...fields });
1644
+ // A create whose answer is lost may still have made the page. Recorded
1645
+ // first, so a later push or plan that finds a page at this path refuses
1646
+ // to treat it as this workspace's own until a pull adopts it.
1647
+ if (page.action === "created") {
1648
+ manifest.pendingCreates = { ...pendingCreates(manifest), [page.pagePath]: page.file };
1649
+ await writeManifest(config.workspaceDir, manifest);
1650
+ }
1651
+ attempting = page;
1652
+ let summary;
1653
+ try {
1654
+ summary = page.action === "created"
1655
+ ? await createPage(client, { siteId, ...fields })
1656
+ : await updatePage(client, page.pageId, { pageId: page.pageId, ...fields });
1657
+ } catch (error) {
1658
+ throw translateAuthorRefusal(error, page.file);
1659
+ }
1660
+ attempting = undefined;
1120
1661
  manifestDirty = true;
1662
+ if (page.action === "created") {
1663
+ delete manifest.pendingCreates[page.pagePath];
1664
+ if (Object.keys(manifest.pendingCreates).length === 0) delete manifest.pendingCreates;
1665
+ }
1121
1666
  applied.push({
1122
1667
  file: page.file,
1123
- path: summary.path,
1668
+ path: displayPagePath(summary.path),
1124
1669
  pageId: summary.pageId,
1125
1670
  action: page.action,
1126
1671
  status: summary.status,
1672
+ ...(page.author === "" ? {} : { author: page.author }),
1127
1673
  });
1128
1674
  // `entry?.` because a hand-edited manifest can hold a null in the list,
1129
1675
  // and a raw TypeError here would collapse to an opaque `site.failed`
@@ -1131,6 +1677,10 @@ export async function pagesPush(invocation) {
1131
1677
  // where a crash costs more than a refusal.
1132
1678
  const existing = manifest.pages.find((entry) => entry?.pageId === summary.pageId);
1133
1679
  const record = existing ?? {};
1680
+ // The author the site holds now: what it reports, else what was just
1681
+ // accepted, else what this workspace already knew it held.
1682
+ const heldAuthor = summary.authorRef
1683
+ || (page.sendAuthor ? page.author : parseAuthorReference(record.baseline?.author)?.value ?? "");
1134
1684
  Object.assign(record, {
1135
1685
  pageId: summary.pageId,
1136
1686
  resourceId: summary.resourceId || record.resourceId || "",
@@ -1169,9 +1719,14 @@ export async function pagesPush(invocation) {
1169
1719
  // Recorded for the pages whose push sends a date, so the next push
1170
1720
  // can tell a date the site changed from one it already holds.
1171
1721
  ...(sentDisplayDate(page.templateType) ? { displayDate: summary.displayDate } : {}),
1722
+ ...(heldAuthor === "" ? {} : { author: heldAuthor }),
1172
1723
  },
1173
1724
  pendingApproval: true,
1174
1725
  });
1726
+ // What the source names, kept beside the site's own for a .pm.json
1727
+ // page, whose manifest entry is the only place it is written down.
1728
+ if (page.author === "") delete record.author;
1729
+ else record.author = page.author;
1175
1730
  if (existing === undefined) manifest.pages.push(record);
1176
1731
  // The override this page's record permitted has now been taken, and
1177
1732
  // the baseline above records what the site holds because of it.
@@ -1185,34 +1740,69 @@ export async function pagesPush(invocation) {
1185
1740
  await deleteWorkspaceFile(config.workspaceDir, observedRevisionFile);
1186
1741
  }
1187
1742
  }
1743
+ } catch (error) {
1744
+ // Each page is its own request, so a failure partway leaves earlier
1745
+ // pages written. Name every page's outcome; the manifest below records
1746
+ // the written ones, so a retry updates rather than duplicates them.
1747
+ // The labels are bounded, so the failure and the count of what was not
1748
+ // sent come first and the written pages after.
1749
+ if (error instanceof SiteAuthoringError) {
1750
+ // A create the site refused outright made nothing, so it leaves no marker.
1751
+ if (attempting?.action === "created" && !outcomeUnknown(error)) {
1752
+ delete manifest.pendingCreates[attempting.pagePath];
1753
+ if (Object.keys(manifest.pendingCreates).length === 0) delete manifest.pendingCreates;
1754
+ manifestDirty = true;
1755
+ }
1756
+ const sent = new Set(applied.map((entry) => entry.file));
1757
+ const unsent = planned.filter((page) => !page.unchanged && !sent.has(page.file) && page !== attempting);
1758
+ error.withCompletedWrites([
1759
+ ...(attempting === undefined
1760
+ ? []
1761
+ // An ambiguous or unreadable answer may have committed; the next pull or plan tells.
1762
+ : [`page ${attempting.pagePath || "/"}: ${outcomeUnknown(error) ? "unknown" : "failed"}`]),
1763
+ ...(unsent.length === 0 ? [] : [`${unsent.length} more page(s): not sent`]),
1764
+ ...(error.completedWrites ?? []),
1765
+ ...applied.map((entry) => `page ${entry.path || "/"}: ${entry.action}`),
1766
+ ]);
1767
+ }
1768
+ throw error;
1188
1769
  } finally {
1189
1770
  if (manifestDirty) await writeManifest(config.workspaceDir, manifest);
1190
1771
  }
1191
1772
 
1192
- const reported = boundedList(applied, MAXIMUM_REPORTED);
1193
- const reportedUnresolved = boundedList(unresolved, MAXIMUM_REPORTED);
1773
+ // Pages are authorless unless a source names an author, and an owner
1774
+ // otherwise finds the missing byline only after publishing (TR01196).
1775
+ const authorless = applied.filter((entry) => entry.action === "created" && entry.author === undefined);
1776
+ if (authorless.length > 0) {
1777
+ onProgress(
1778
+ `${authorless.length} page(s) were created without an author, so they publish with no byline. To credit one, `
1779
+ + "add 'author: <handle or email>' to its front matter or run 'taproot-site pages meta set <path> --author "
1780
+ + "<handle or email>', then push again; 'taproot-site authors list' shows who can be named.",
1781
+ );
1782
+ }
1194
1783
  return successResult(VERB_PAGES_PUSH, siteId, {
1195
1784
  pages: {
1196
1785
  total: planned.length,
1197
1786
  unchanged: planned.filter((page) => page.unchanged).length,
1198
1787
  created: applied.filter((entry) => entry.action === "created").length,
1199
1788
  updated: applied.filter((entry) => entry.action === "updated").length,
1200
- // What this run actually looked at, so automation can tell a targeted
1201
- // push from a whole-workspace one without inferring it from counts.
1202
- selection: targeted ? "targeted" : "workspace",
1203
- ...(targeted ? { selectedPaths: [...requestedPaths].sort().map((pagePath) => pagePath || "/") } : {}),
1204
- discovered: files.length,
1789
+ ...selection,
1205
1790
  validated: planned.length,
1206
1791
  ...(unresolved.length > 0
1792
+ ? { unresolved: unresolved.length, ...reportedList("unresolvedItems", unresolved, LIST_BYTES) }
1793
+ : {}),
1794
+ ...staleGeneratedSourcesField(staleGenerated),
1795
+ ...reportedList("items", applied, ITEMS_BYTES),
1796
+ ...(authorless.length > 0
1207
1797
  ? {
1208
- unresolved: unresolved.length,
1209
- unresolvedItems: reportedUnresolved.items,
1210
- ...(reportedUnresolved.truncated ? { unresolvedItemsTruncated: true } : {}),
1798
+ authorless: {
1799
+ total: authorless.length,
1800
+ ...reportedList("items", authorless.map(({ file, path }) => ({ file, path })), LIST_BYTES),
1801
+ },
1211
1802
  }
1212
1803
  : {}),
1213
- items: reported.items,
1214
- ...(reported.truncated ? { itemsTruncated: true } : {}),
1215
1804
  },
1805
+ ...warnings,
1216
1806
  // `pages push` writes drafts. Nothing reaches an audience until `approve`
1217
1807
  // stages them and `deploy` publishes the site.
1218
1808
  // Nothing to approve when every page was unchanged and nothing was sent.