@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.
- package/README.md +62 -10
- package/examples/riverbend-wellness/.taproot-site-media.json +23 -0
- package/examples/riverbend-wellness/pages/index.md +1 -17
- package/package.json +2 -2
- package/src/api.js +59 -1
- package/src/cli.js +147 -22
- package/src/constants.js +6 -0
- package/src/content/components.js +64 -6
- package/src/content/free-form-sections.js +21 -4
- package/src/content/markdown.js +161 -26
- package/src/content/tiptap-prosemirror.ts +66 -8
- package/src/content/validate-document.js +30 -6
- package/src/content/vocabulary.js +26 -0
- package/src/errors.js +22 -0
- package/src/espalier-shared.js +11 -0
- package/src/font-catalog.js +87 -0
- package/src/footer-contract.js +1 -1
- package/src/output.js +19 -1
- package/src/presentation-reference.js +21 -7
- package/src/problems.js +116 -0
- package/src/reference-help.js +100 -34
- package/src/session.js +13 -0
- package/src/theme-proposals.js +393 -0
- package/src/theme-validation.js +31 -10
- package/src/typed-page-reference.js +11 -3
- package/src/typed-pages.js +31 -9
- package/src/verbs/deploy.js +1 -0
- package/src/verbs/index.js +13 -0
- package/src/verbs/media-upload.js +127 -30
- package/src/verbs/nav-push.js +7 -0
- package/src/verbs/pages-push.js +406 -199
- package/src/verbs/places.js +61 -0
- package/src/verbs/plan.js +585 -0
- package/src/verbs/theme-push.js +68 -36
- package/src/verbs/validate.js +264 -217
- package/src/video-prepare.js +48 -12
- package/src/workspace-check.js +199 -0
- package/src/workspace-ledger.js +173 -0
- package/src/workspace.js +47 -4
package/src/verbs/pages-push.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
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 ${
|
|
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
|
-
}): ${
|
|
388
|
-
`${file}:${
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
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
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
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
|
-
//
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
const
|
|
834
|
-
|
|
835
|
-
|
|
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
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
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
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
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
|
-
|
|
927
|
-
|
|
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
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
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
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
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(
|
|
1012
|
-
const observed = await readObservedPageRecord(
|
|
1051
|
+
if (observedRecordFile !== undefined) await workspaceFileExists(workspaceDir, observedRecordFile);
|
|
1052
|
+
const observed = await readObservedPageRecord(workspaceDir, target.pageId);
|
|
1013
1053
|
observedRecord = observed;
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
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
|
|
1092
|
+
workspaceDir,
|
|
1051
1093
|
siteId,
|
|
1052
1094
|
file,
|
|
1053
1095
|
document: document_,
|
|
1054
1096
|
content,
|
|
1055
|
-
getSharedThemeContexts: async () => {
|
|
1056
|
-
sharedThemeContexts ??= await readSharedThemeContexts(
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|