@taprootio/site-authoring 0.14.0 → 0.15.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 (39) hide show
  1. package/README.md +62 -10
  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 +59 -1
  6. package/src/cli.js +147 -22
  7. package/src/constants.js +6 -0
  8. package/src/content/components.js +64 -6
  9. package/src/content/free-form-sections.js +21 -4
  10. package/src/content/markdown.js +161 -26
  11. package/src/content/tiptap-prosemirror.ts +66 -8
  12. package/src/content/validate-document.js +30 -6
  13. package/src/content/vocabulary.js +26 -0
  14. package/src/errors.js +22 -0
  15. package/src/espalier-shared.js +11 -0
  16. package/src/font-catalog.js +87 -0
  17. package/src/footer-contract.js +1 -1
  18. package/src/output.js +19 -1
  19. package/src/presentation-reference.js +21 -7
  20. package/src/problems.js +116 -0
  21. package/src/reference-help.js +100 -34
  22. package/src/session.js +13 -0
  23. package/src/theme-proposals.js +393 -0
  24. package/src/theme-validation.js +31 -10
  25. package/src/typed-page-reference.js +11 -3
  26. package/src/typed-pages.js +31 -9
  27. package/src/verbs/deploy.js +1 -0
  28. package/src/verbs/index.js +13 -0
  29. package/src/verbs/media-upload.js +127 -30
  30. package/src/verbs/nav-push.js +7 -0
  31. package/src/verbs/pages-push.js +406 -199
  32. package/src/verbs/places.js +61 -0
  33. package/src/verbs/plan.js +585 -0
  34. package/src/verbs/theme-push.js +68 -36
  35. package/src/verbs/validate.js +264 -217
  36. package/src/video-prepare.js +48 -12
  37. package/src/workspace-check.js +199 -0
  38. package/src/workspace-ledger.js +173 -0
  39. package/src/workspace.js +47 -4
@@ -12,8 +12,10 @@ import {
12
12
  import { VERB_PAGES_PUSH } from "../constants.js";
13
13
  import { waitForProcessing } from "./media-upload.js";
14
14
  import { SiteAuthoringError } from "../errors.js";
15
+ import { stableJson } from "../footer-draft-hash.js";
16
+ import { CHECK_AREA, collectProblems, planStale, problemsOf, refuseProblems } from "../problems.js";
15
17
  import { RUNTIME_MIRROR_PATH_REASON, isRuntimeMirrorPath } from "../reserved-paths.js";
16
- import { boundedList, openSession, successResult, warnIfExternalWritesPaused } from "../session.js";
18
+ import { boundedByBytes, openSession, successResult, warnIfExternalWritesPaused } from "../session.js";
17
19
  import { SETTINGS_TYPE_TAPROOT_STYLES } from "../settings-catalog.js";
18
20
  import {
19
21
  contentDocuments,
@@ -109,7 +111,40 @@ import {
109
111
  * ambiguous outcome surfaces as ambiguous.
110
112
  */
111
113
 
112
- const MAXIMUM_REPORTED = 200;
114
+ // Shares of the result's 64 KiB for its lists, whose entries carry page paths
115
+ // of any length.
116
+ const ITEMS_BYTES = 32 * 1024;
117
+ const LIST_BYTES = 8 * 1024;
118
+ // A send that ended this way may still have written the page.
119
+ const OUTCOME_UNKNOWN_CODES = new Set([
120
+ "transport.mutation_ambiguous",
121
+ "transport.response_read",
122
+ "transport.response_too_large",
123
+ "transport.invalid_json",
124
+ "api.page_contract",
125
+ ]);
126
+
127
+ function outcomeUnknown(error) {
128
+ // A cancelled retry of a write, and a server error on a write the transport
129
+ // does not replay, leave the same question open.
130
+ return OUTCOME_UNKNOWN_CODES.has(error.code) || error.ambiguousMutation === true || error.httpStatus >= 500;
131
+ }
132
+
133
+ /** The creates this workspace sent without hearing back, by page path. */
134
+ function pendingCreates(manifest) {
135
+ const recorded = manifest.pendingCreates;
136
+ return recorded !== null && typeof recorded === "object" && !Array.isArray(recorded) ? recorded : {};
137
+ }
138
+
139
+ function pendingCreateFor(manifest, pagePath) {
140
+ return Object.hasOwn(pendingCreates(manifest), pagePath);
141
+ }
142
+
143
+ /** A result list bounded by bytes, as `<name>` plus `<name>Truncated` when cut. */
144
+ function reportedList(name, values, budget) {
145
+ const shown = boundedByBytes(values, budget);
146
+ return { [name]: shown.items, ...(shown.truncated ? { [`${name}Truncated`]: true } : {}) };
147
+ }
113
148
  const FRONT_MATTER_FENCE = "---";
114
149
  const FRONT_MATTER_ENTRY = /^([A-Za-z][A-Za-z0-9_]*)[ \t]*:[ \t]*(.*)$/u;
115
150
 
@@ -379,14 +414,22 @@ function assertValid(content, document_, file, { mayBeEmpty = false } = {}) {
379
414
  const errors = mayBeEmpty ? reported.filter((error) => error?.code !== CONTENT_ERROR_CODES.emptyDocument) : reported;
380
415
  if (errors.length === 0) return;
381
416
  const first = errors[0] ?? {};
382
- const location = typeof first.path === "string" && first.path !== "" ? first.path : "doc";
417
+ const locate = (error) => typeof error?.path === "string" && error.path !== "" ? error.path : "doc";
418
+ const describe = (error) =>
419
+ typeof error?.message === "string" ? error.message : "the node or mark is outside the accepted vocabulary.";
420
+ // Every finding travels with the refusal, so a whole-workspace check reports
421
+ // all of a page's problems and not only the first (TR01002).
383
422
  throw documentError(
384
423
  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} (${
424
+ `'${file}' is not a valid Taproot document: ${errors.length} problem(s), first at ${locate(first)} (${
386
425
  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
- );
426
+ }): ${describe(first)}`,
427
+ `${file}:${locate(first)}`,
428
+ ).withProblems(errors.map((error) => ({
429
+ code: typeof error?.code === "string" ? error.code : "pages.document_invalid",
430
+ field: `${file}:${locate(error)}`,
431
+ message: describe(error),
432
+ })));
390
433
  }
391
434
 
392
435
  function systemPageKind(pagePath) {
@@ -708,6 +751,9 @@ export async function validateWorkspacePageDocument({
708
751
  const contexts = typeof getSharedThemeContexts === "function"
709
752
  ? await getSharedThemeContexts()
710
753
  : await readSharedThemeContexts(workspaceDir, siteId);
754
+ // No contexts means the theme itself failed validation and was reported
755
+ // as its own problem; a context cannot be checked against it.
756
+ if (contexts === undefined) continue;
711
757
  const contextErrors = validateFreeFormSectionContexts(doc, contexts).errors;
712
758
  if (contextErrors.length > 0) {
713
759
  const first = contextErrors[0];
@@ -722,10 +768,10 @@ export async function validateWorkspacePageDocument({
722
768
  }
723
769
 
724
770
  /**
725
- * Refuses a push that places a video the site's library does not hold; every
771
+ * The pages that place a video the site's library does not hold; every
726
772
  * library video is ready to play. Pages that will not be sent are not asked about.
727
773
  */
728
- async function requirePlacedVideosExist(client, siteId, planned) {
774
+ export async function placedVideoProblems(client, siteId, planned) {
729
775
  const placements = new Map();
730
776
  for (const page of planned) {
731
777
  if (page.unchanged) continue;
@@ -733,30 +779,32 @@ async function requirePlacedVideosExist(client, siteId, planned) {
733
779
  if (!placements.has(videoId)) placements.set(videoId, page.file);
734
780
  }
735
781
  }
736
- if (placements.size === 0) return;
782
+ if (placements.size === 0) return [];
737
783
 
738
784
  const library = await listVideos(client, siteId);
739
785
  const knownIds = new Set(library.videos.map((video) => video.videoId));
786
+ const problems = [];
740
787
  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
- }
788
+ if (knownIds.has(videoId)) continue;
789
+ problems.push(...problemsOf(
790
+ library.truncated
791
+ ? documentError(
792
+ "pages.video_library_unverifiable",
793
+ `'${file}' places video ${videoId}, which is not among the first ${library.videos.length} videos of this `
794
+ + "site's library, and the library is larger than one push can check. Reduce the library or place the video "
795
+ + "from the editor.",
796
+ file,
797
+ )
798
+ : documentError(
799
+ "pages.video_unknown",
800
+ `'${file}' places video ${videoId}, which is not in this site's video library. Upload it with `
801
+ + "'taproot-site media upload' and use the videoId it reports.",
802
+ file,
803
+ ),
804
+ { area: CHECK_AREA.pages, file },
805
+ ));
759
806
  }
807
+ return problems;
760
808
  }
761
809
 
762
810
  /**
@@ -796,49 +844,41 @@ async function copyVideoEmbedPosters(client, siteId, planned, { onProgress, now
796
844
  }
797
845
  }
798
846
 
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 },
820
- );
821
- }
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));
847
+ /**
848
+ * Phase one over the workspace's page sources (TR01002): resolve which file is
849
+ * each page's one source, convert and validate it, and decide create, update,
850
+ * or unchanged — collecting every problem rather than stopping at the first.
851
+ *
852
+ * `livePages` is what the sources are checked against. A push passes the
853
+ * site's own list with `online`; the offline workspace check passes the pull
854
+ * manifest's pages, which is what the site held when the workspace was pulled,
855
+ * and skips the two guards only a live read can arm (a moved revision or
856
+ * display date). `getSharedThemeContexts` names the theme's contexts when
857
+ * the caller has already validated the theme.
858
+ */
859
+ export async function planPages({
860
+ workspaceDir,
861
+ siteId,
862
+ manifest,
863
+ mediaManifest,
864
+ content,
865
+ files,
866
+ livePages,
867
+ online,
868
+ requestedPaths,
869
+ getSharedThemeContexts,
870
+ onProgress = () => {},
871
+ }) {
872
+ const problems = [];
873
+ const area = { area: CHECK_AREA.pages };
828
874
  const targeted = requestedPaths !== undefined;
829
-
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
- );
840
- }
841
- let sharedThemeContexts;
875
+ // Manifest integrity is never scoped: a registry that contradicts itself
876
+ // describes some other workspace, so nothing in it can be planned.
877
+ await collectProblems(problems, area, () => requireManifestSourceRegistry(pageSourceRegistry(manifest)));
878
+ if (problems.length > 0) return { targeted, sources: [], selected: [], planned: [], unresolved: [], problems };
879
+ const live = livePages.filter((summary) => summary.status !== PAGE_STATUS_DELETED);
880
+ const liveById = new Map(live.map((summary) => [summary.pageId, summary]));
881
+ const liveByPath = new Map(live.map((summary) => [normalizePagePath(summary.path) ?? summary.path, summary]));
842
882
  const manifestByFile = new Map(
843
883
  manifest.pages.filter((entry) => typeof entry?.file === "string").map((entry) => [entry.file, entry]),
844
884
  );
@@ -850,118 +890,109 @@ export async function pagesPush(invocation) {
850
890
  const manifestByPageId = new Map(
851
891
  manifest.pages.filter((entry) => typeof entry?.pageId === "string").map((entry) => [entry.pageId, entry]),
852
892
  );
893
+ let sharedThemeContexts;
853
894
 
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
- );
868
- }
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]));
872
-
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
- }
895
+ // Resolution: which file is each page path's one authoritative source.
896
+ // This reads metadata only — a manifest entry, or a front-matter block —
897
+ // so no document is converted, no media reference is resolved, and no
898
+ // component contract is checked until the selection is known.
899
+ const sources = [];
900
+ const claimants = [];
901
+ const unresolved = [];
902
+ for (const file of files) {
903
+ const manifestEntry = manifestByFile.get(file);
904
+ try {
905
+ const source = await readWorkspacePageSource({ workspaceDir, file, manifestEntry });
906
+ if (source.pagePath === undefined) {
907
+ // A file whose page identity is ambiguous. A whole-workspace check
908
+ // reports it; a targeted one refuses only if the selection touches a
909
+ // path it might be.
910
+ if (targeted) claimants.push(source);
911
+ else problems.push(...problemsOf(source.fault ?? missingPathError(file), { ...area, file }));
912
+ continue;
913
+ }
914
+ sources.push(source);
915
+ } catch (error) {
916
+ if (!(error instanceof SiteAuthoringError)) throw error;
917
+ // A source whose metadata cannot be read declares no page path, so it
918
+ // cannot be the page a selection asked for: a targeted push names it and
919
+ // carries on. Anything that is not a metadata fault — a containment
920
+ // refusal, an oversized file, an unreadable one — still fails closed.
921
+ if (targeted && RESOLUTION_METADATA_CODES.has(error.code)) {
902
922
  unresolved.push({ file, code: error.code });
903
923
  onProgress(`'${file}' declares no readable page path (${error.code}); it is outside this selection.`);
924
+ continue;
904
925
  }
926
+ if (targeted) throw error;
927
+ problems.push(...problemsOf(error, { ...area, file }));
905
928
  }
929
+ }
906
930
 
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(
931
+ // Two editable sources for one page path. Whichever check touches that path
932
+ // refuses: choosing between them is exactly the ambiguity one source per
933
+ // page exists to remove, and it is also how the documented format change —
934
+ // remove the old source, author the other beside it — is proved to have
935
+ // actually removed the old one.
936
+ const sourcesByPath = new Map();
937
+ const conflicted = new Set();
938
+ for (const source of sources) {
939
+ const existing = sourcesByPath.get(source.pagePath);
940
+ if (existing === undefined) {
941
+ sourcesByPath.set(source.pagePath, source);
942
+ continue;
943
+ }
944
+ if (!targeted || requestedPaths.has(source.pagePath)) {
945
+ conflicted.add(source.pagePath);
946
+ problems.push(...problemsOf(
947
+ documentError(
921
948
  "pages.path_conflict",
922
949
  `'${source.file}' and '${existing.file}' both claim page path '${source.pagePath}'.`,
923
950
  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
- );
951
+ ),
952
+ { ...area, file: source.file },
953
+ ));
954
+ continue;
930
955
  }
956
+ onProgress(
957
+ `'${source.file}' and '${existing.file}' both claim page path '${source.pagePath}'; `
958
+ + "neither is in this selection.",
959
+ );
960
+ }
931
961
 
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.",
962
+ const selected = (targeted ? sources.filter((source) => requestedPaths.has(source.pagePath)) : sources)
963
+ .filter((source) => !conflicted.has(source.pagePath));
964
+ if (targeted) {
965
+ // Before deciding what the selection resolved to: a file that might be
966
+ // one of the selected pages makes the selection unprovable, so its own
967
+ // fault is what this push reports.
968
+ const claimant = claimants.find((source) => source.pathClaims.some((claim) => requestedPaths.has(claim)));
969
+ if (claimant !== undefined) throw claimant.fault;
970
+ const matched = new Set([...selected.map((source) => source.pagePath), ...conflicted]);
971
+ const missing = [...requestedPaths].filter((pagePath) => !matched.has(pagePath)).sort();
972
+ if (missing.length > 0) {
973
+ // The homepage normalizes to the empty path, which the result emitters
974
+ // drop as falsy; name it by its documented '/' spelling so the stable
975
+ // error contract keeps a field for every unknown path.
976
+ throw new SiteAuthoringError(
977
+ "pages.page_not_found",
978
+ `No workspace page was found for page path '${missing[0] || "/"}'.`,
979
+ { field: missing[0] || "/" },
956
980
  );
957
- } else {
958
- onProgress(`Validating every one of the ${sources.length} page source(s) in this workspace.`);
959
981
  }
982
+ onProgress(
983
+ `Selected ${selected.length} of ${sources.length} page source(s) by path; `
984
+ + "only the selection is validated and sent.",
985
+ );
986
+ } else {
987
+ onProgress(`Validating every one of the ${sources.length} page source(s) in this workspace.`);
988
+ }
960
989
 
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) {
990
+ // Phase one proper: convert and validate the selection. Each source's first
991
+ // refusal is collected and the next source is checked; nothing is sent
992
+ // until the whole list is empty.
993
+ const planned = [];
994
+ for (const source of selected) {
995
+ await collectProblems(problems, { ...area, file: source.file }, async () => {
965
996
  const { file, pagePath, title, declaredDescription } = source;
966
997
  onProgress(`Validating '${file}'.`);
967
998
  const manifestEntry = manifestByFile.get(file);
@@ -972,6 +1003,15 @@ export async function pagesPush(invocation) {
972
1003
  const target = manifestEntry?.pageId !== undefined
973
1004
  ? liveById.get(manifestEntry.pageId)
974
1005
  : liveByPath.get(pagePath);
1006
+ if (manifestEntry?.pageId === undefined && target !== undefined && pendingCreateFor(manifest, pagePath)) {
1007
+ throw documentError(
1008
+ "pages.create_unconfirmed",
1009
+ `An earlier push tried to create page '${pagePath || "/"}' from '${file}' without hearing back, and the site now has a `
1010
+ + "page at that path. It may be that create or someone else's page, so nothing will write over it: run "
1011
+ + "'taproot-site pull' to record the site's page, then reconcile it with this source and push again.",
1012
+ file,
1013
+ );
1014
+ }
975
1015
  const workspaceSystemKind = systemPageKind(pagePath);
976
1016
  if (target === undefined) {
977
1017
  if (workspaceSystemKind !== undefined) {
@@ -1008,18 +1048,20 @@ export async function pagesPush(invocation) {
1008
1048
  // a directory or a symlink at its path is refused here, before any
1009
1049
  // page is written, rather than discovered after the site has changed.
1010
1050
  const observedRecordFile = internalPageObservedRevisionFile(target.pageId);
1011
- if (observedRecordFile !== undefined) await workspaceFileExists(config.workspaceDir, observedRecordFile);
1012
- const observed = await readObservedPageRecord(config.workspaceDir, target.pageId);
1051
+ if (observedRecordFile !== undefined) await workspaceFileExists(workspaceDir, observedRecordFile);
1052
+ const observed = await readObservedPageRecord(workspaceDir, target.pageId);
1013
1053
  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 });
1054
+ if (online) {
1055
+ const guard = {
1056
+ file,
1057
+ pagePath,
1058
+ target,
1059
+ entry: manifestByPageId.get(target.pageId),
1060
+ observedRevision: observed?.revision,
1061
+ };
1062
+ requireReconciledRevision(guard);
1063
+ requireReconciledDisplayDate({ ...guard, document_, observed });
1064
+ }
1023
1065
  }
1024
1066
 
1025
1067
  // The duplicate-source check above only catches two workspace files
@@ -1047,15 +1089,15 @@ export async function pagesPush(invocation) {
1047
1089
  const description = declaredDescription ?? targetEntry?.description ?? "";
1048
1090
 
1049
1091
  await validateWorkspacePageDocument({
1050
- workspaceDir: config.workspaceDir,
1092
+ workspaceDir,
1051
1093
  siteId,
1052
1094
  file,
1053
1095
  document: document_,
1054
1096
  content,
1055
- getSharedThemeContexts: async () => {
1056
- sharedThemeContexts ??= await readSharedThemeContexts(config.workspaceDir, siteId);
1097
+ getSharedThemeContexts: getSharedThemeContexts ?? (async () => {
1098
+ sharedThemeContexts ??= await readSharedThemeContexts(workspaceDir, siteId);
1057
1099
  return sharedThemeContexts;
1058
- },
1100
+ }),
1059
1101
  });
1060
1102
 
1061
1103
  const sentDescription = typeof description === "string" ? description : "";
@@ -1084,22 +1126,159 @@ export async function pagesPush(invocation) {
1084
1126
  sourceFormat: source.sourceFormat,
1085
1127
  sourceHash: source.sourceHash,
1086
1128
  pageId: target?.pageId,
1129
+ // The live revision this page would be sent against; `plan` binds it
1130
+ // into its hash so a concurrent edit invalidates the plan.
1131
+ revision: target?.bodyRevision,
1087
1132
  templateType: PAGE_TEMPLATES[documentTemplate(document_)].wireType,
1088
1133
  action: target === undefined ? "created" : "updated",
1089
1134
  });
1090
- }
1135
+ });
1136
+ }
1091
1137
 
1138
+ return { targeted, sources, selected, planned, unresolved, problems };
1139
+ }
1140
+
1141
+ function missingPathError(file) {
1142
+ return documentError(
1143
+ "pages.path_missing",
1144
+ `'${file}' does not declare a usable page path. Add 'path:' to its front-matter (use an empty value for the home page).`,
1145
+ file,
1146
+ );
1147
+ }
1148
+
1149
+ /** What a dry run or a plan reports for one planned page. */
1150
+ export function plannedPageItem(page) {
1151
+ return { file: page.file, path: page.pagePath || "/", action: page.unchanged ? "unchanged" : page.action };
1152
+ }
1153
+
1154
+ export async function pagesPush(invocation) {
1155
+ const session = await openSession(invocation);
1156
+ const { client, config, siteId, now, onProgress } = session;
1157
+ const dryRun = invocation.dryRun === true;
1158
+ // One advisory line before this verb does any work, and only when the
1159
+ // exchange said the platform is paused. It changes nothing else: the write
1160
+ // still runs and its refusal still classifies as platform_paused (TR00692).
1161
+ if (!dryRun) warnIfExternalWritesPaused(session, VERB_PAGES_PUSH);
1162
+ // Both manifests are bound to the site before anything is planned or sent:
1163
+ // every id in them is site-scoped, and a workspace pulled from another site
1164
+ // reads as entirely valid until phase two is already writing.
1165
+ const manifest = await readManifest(config.workspaceDir, siteId);
1166
+ const mediaManifest = await readMediaManifest(config.workspaceDir, siteId);
1167
+ const content = requireContentFunctions(await loadContentModule(invocation.content));
1168
+ const requestedPaths = Array.isArray(invocation.pagePaths)
1169
+ ? new Set(invocation.pagePaths.map((value) => {
1170
+ const normalized = normalizePagePath(value);
1171
+ if (normalized === undefined) {
1172
+ throw new SiteAuthoringError(
1173
+ "pages.page_path_invalid",
1174
+ `'${typeof value === "string" ? value : String(value)}' is not a usable page path.`,
1175
+ { field: typeof value === "string" ? value : undefined, exitCode: 2 },
1176
+ );
1177
+ }
1178
+ return normalized;
1179
+ }))
1180
+ : undefined;
1181
+ // Refused before any request: a registry that contradicts itself describes
1182
+ // some other workspace, and a selection cannot make that safe.
1183
+ requireManifestSourceRegistry(pageSourceRegistry(manifest));
1184
+
1185
+ // The shared list, so the set `pull` refuses to write over is exactly the set
1186
+ // this walks: a file one counted and the other ignored would be a page pushed
1187
+ // from a workspace nothing proved the ownership of.
1188
+ const files = await walkWorkspaceFiles(config.workspaceDir, PAGES_DIRECTORY, PAGE_SOURCE_EXTENSIONS);
1189
+ if (files.length === 0) {
1190
+ throw new SiteAuthoringError(
1191
+ "pages.none_found",
1192
+ `No Markdown or ProseMirror page files were found under '${PAGES_DIRECTORY}/' in the workspace.`,
1193
+ { field: PAGES_DIRECTORY },
1194
+ );
1195
+ }
1196
+
1197
+ return await withRefusalGuidance(onProgress, "push", async () => {
1198
+ onProgress("Listing the site's pages to resolve creates from updates.");
1199
+ const { pages: livePages, truncated } = await listSitePages(client, siteId, { onProgress });
1200
+ if (truncated) {
1201
+ // Every create-or-update decision below is made against this list. A
1202
+ // partial one turns a tracked page into a create, which the server then
1203
+ // refuses for a duplicate path — mid-phase-two, after earlier pages have
1204
+ // already been written.
1205
+ throw new SiteAuthoringError(
1206
+ "pages.live_list_truncated",
1207
+ "The site has more pages than this CLI can enumerate, so an update cannot be told from a create. "
1208
+ + "No page was pushed.",
1209
+ { field: PAGES_DIRECTORY },
1210
+ );
1211
+ }
1212
+ const plan = await planPages({
1213
+ workspaceDir: config.workspaceDir,
1214
+ siteId,
1215
+ manifest,
1216
+ mediaManifest,
1217
+ content,
1218
+ files,
1219
+ livePages,
1220
+ online: true,
1221
+ requestedPaths,
1222
+ onProgress,
1223
+ });
1224
+ const { planned, targeted, unresolved } = plan;
1092
1225
  // A page that places a video names it by id, and only the library knows
1093
1226
  // whether that id is one of this site's videos. Asked once for the whole
1094
1227
  // push, before anything is written, so a page cannot be sent that
1095
1228
  // publishing would then refuse.
1096
- await requirePlacedVideosExist(client, siteId, planned);
1229
+ plan.problems.push(...await placedVideoProblems(client, siteId, planned));
1230
+
1231
+ // Under apply, the workspace read ledger has already held every source and
1232
+ // manifest to the plan. What it cannot see is the site: every page the plan
1233
+ // would send must still be the page, revision and action it listed, and
1234
+ // nothing it did not list may be sent. A planned page can still turn out
1235
+ // unchanged, when an upload deduplicates to the image it already shows, and
1236
+ // is then skipped.
1237
+ if (invocation.plannedPages !== undefined) {
1238
+ const byFile = new Map(planned.map((page) => [page.file, page]));
1239
+ for (const [file, expected] of invocation.plannedPages) {
1240
+ const page = byFile.get(file);
1241
+ if (page === undefined || stableJson([page.pageId ?? "", page.revision ?? "", page.action]) !== expected) {
1242
+ throw planStale(`'${file}'`, file);
1243
+ }
1244
+ }
1245
+ const unplanned = planned.find((page) => !page.unchanged && !invocation.plannedPages.has(page.file));
1246
+ if (unplanned !== undefined) throw planStale(`'${unplanned.file}'`, unplanned.file);
1247
+ }
1248
+ // What this run actually looked at, so automation can tell a targeted
1249
+ // push from a whole-workspace one without inferring it from counts.
1250
+ const selection = {
1251
+ selection: targeted ? "targeted" : "workspace",
1252
+ ...(targeted
1253
+ ? reportedList("selectedPaths", [...requestedPaths].sort().map((pagePath) => pagePath || "/"), LIST_BYTES)
1254
+ : {}),
1255
+ discovered: files.length,
1256
+ };
1257
+ if (dryRun) {
1258
+ refuseProblems(plan.problems, "pages push --dry-run found problems and sent nothing");
1259
+ onProgress("Dry run: nothing was sent.");
1260
+ return successResult(VERB_PAGES_PUSH, siteId, {
1261
+ dryRun: true,
1262
+ pages: {
1263
+ total: planned.length,
1264
+ unchanged: planned.filter((page) => page.unchanged).length,
1265
+ wouldCreate: planned.filter((page) => !page.unchanged && page.action === "created").length,
1266
+ wouldUpdate: planned.filter((page) => !page.unchanged && page.action === "updated").length,
1267
+ ...selection,
1268
+ validated: planned.length,
1269
+ ...reportedList("items", planned.map(plannedPageItem), ITEMS_BYTES),
1270
+ },
1271
+ });
1272
+ }
1273
+ refuseProblems(plan.problems, "pages push sent nothing");
1097
1274
  await copyVideoEmbedPosters(client, siteId, planned, { onProgress, now });
1098
1275
 
1099
1276
  // Phase two: send. The manifest is written back even if a later page fails,
1100
1277
  // so a retry updates the pages this run created rather than duplicating them.
1101
1278
  const applied = [];
1102
1279
  let manifestDirty = false;
1280
+ // The page whose request is in flight, so a failure names it and not the next one.
1281
+ let attempting;
1103
1282
  try {
1104
1283
  for (const page of planned) {
1105
1284
  if (page.unchanged) {
@@ -1114,10 +1293,23 @@ export async function pagesPush(invocation) {
1114
1293
  template: wireTemplate(page.document),
1115
1294
  ...wirePageFields(page.document),
1116
1295
  };
1296
+ // A create whose answer is lost may still have made the page. Recorded
1297
+ // first, so a later push or plan that finds a page at this path refuses
1298
+ // to treat it as this workspace's own until a pull adopts it.
1299
+ if (page.action === "created") {
1300
+ manifest.pendingCreates = { ...pendingCreates(manifest), [page.pagePath]: page.file };
1301
+ await writeManifest(config.workspaceDir, manifest);
1302
+ }
1303
+ attempting = page;
1117
1304
  const summary = page.action === "created"
1118
1305
  ? await createPage(client, { siteId, ...fields })
1119
1306
  : await updatePage(client, page.pageId, { pageId: page.pageId, ...fields });
1307
+ attempting = undefined;
1120
1308
  manifestDirty = true;
1309
+ if (page.action === "created") {
1310
+ delete manifest.pendingCreates[page.pagePath];
1311
+ if (Object.keys(manifest.pendingCreates).length === 0) delete manifest.pendingCreates;
1312
+ }
1121
1313
  applied.push({
1122
1314
  file: page.file,
1123
1315
  path: summary.path,
@@ -1185,33 +1377,48 @@ export async function pagesPush(invocation) {
1185
1377
  await deleteWorkspaceFile(config.workspaceDir, observedRevisionFile);
1186
1378
  }
1187
1379
  }
1380
+ } catch (error) {
1381
+ // Each page is its own request, so a failure partway leaves earlier
1382
+ // pages written. Name every page's outcome; the manifest below records
1383
+ // the written ones, so a retry updates rather than duplicates them.
1384
+ // The labels are bounded, so the failure and the count of what was not
1385
+ // sent come first and the written pages after.
1386
+ if (error instanceof SiteAuthoringError) {
1387
+ // A create the site refused outright made nothing, so it leaves no marker.
1388
+ if (attempting?.action === "created" && !outcomeUnknown(error)) {
1389
+ delete manifest.pendingCreates[attempting.pagePath];
1390
+ if (Object.keys(manifest.pendingCreates).length === 0) delete manifest.pendingCreates;
1391
+ manifestDirty = true;
1392
+ }
1393
+ const sent = new Set(applied.map((entry) => entry.file));
1394
+ const unsent = planned.filter((page) => !page.unchanged && !sent.has(page.file) && page !== attempting);
1395
+ error.withCompletedWrites([
1396
+ ...(attempting === undefined
1397
+ ? []
1398
+ // An ambiguous or unreadable answer may have committed; the next pull or plan tells.
1399
+ : [`page ${attempting.pagePath || "/"}: ${outcomeUnknown(error) ? "unknown" : "failed"}`]),
1400
+ ...(unsent.length === 0 ? [] : [`${unsent.length} more page(s): not sent`]),
1401
+ ...(error.completedWrites ?? []),
1402
+ ...applied.map((entry) => `page ${entry.path || "/"}: ${entry.action}`),
1403
+ ]);
1404
+ }
1405
+ throw error;
1188
1406
  } finally {
1189
1407
  if (manifestDirty) await writeManifest(config.workspaceDir, manifest);
1190
1408
  }
1191
1409
 
1192
- const reported = boundedList(applied, MAXIMUM_REPORTED);
1193
- const reportedUnresolved = boundedList(unresolved, MAXIMUM_REPORTED);
1194
1410
  return successResult(VERB_PAGES_PUSH, siteId, {
1195
1411
  pages: {
1196
1412
  total: planned.length,
1197
1413
  unchanged: planned.filter((page) => page.unchanged).length,
1198
1414
  created: applied.filter((entry) => entry.action === "created").length,
1199
1415
  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,
1416
+ ...selection,
1205
1417
  validated: planned.length,
1206
1418
  ...(unresolved.length > 0
1207
- ? {
1208
- unresolved: unresolved.length,
1209
- unresolvedItems: reportedUnresolved.items,
1210
- ...(reportedUnresolved.truncated ? { unresolvedItemsTruncated: true } : {}),
1211
- }
1419
+ ? { unresolved: unresolved.length, ...reportedList("unresolvedItems", unresolved, LIST_BYTES) }
1212
1420
  : {}),
1213
- items: reported.items,
1214
- ...(reported.truncated ? { itemsTruncated: true } : {}),
1421
+ ...reportedList("items", applied, ITEMS_BYTES),
1215
1422
  },
1216
1423
  // `pages push` writes drafts. Nothing reaches an audience until `approve`
1217
1424
  // stages them and `deploy` publishes the site.