@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.
- package/README.md +181 -16
- 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 +123 -1
- package/src/authors-contract.js +170 -0
- package/src/cli.js +355 -30
- package/src/constants.js +9 -0
- package/src/content/components.js +64 -6
- package/src/content/free-form-sections.js +21 -4
- package/src/content/link-warnings.js +109 -0
- 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/fixture-init.js +55 -3
- package/src/font-catalog.js +87 -0
- package/src/footer-contract.js +1 -1
- package/src/output.js +19 -1
- package/src/page-metadata.js +92 -0
- package/src/presentation-reference.js +21 -7
- package/src/problems.js +116 -0
- package/src/reference-help.js +157 -41
- package/src/session.js +13 -0
- package/src/staging-check.js +62 -0
- package/src/theme-proposals.js +393 -0
- package/src/theme-validation.js +31 -10
- package/src/typed-page-reference.js +97 -21
- package/src/typed-pages.js +196 -13
- package/src/verbs/approve.js +63 -14
- package/src/verbs/authors.js +167 -0
- package/src/verbs/deploy.js +5 -28
- package/src/verbs/index.js +22 -0
- package/src/verbs/media-upload.js +127 -30
- package/src/verbs/nav-push.js +7 -0
- package/src/verbs/pages-meta-set.js +303 -0
- package/src/verbs/pages-push.js +814 -224
- package/src/verbs/places.js +61 -0
- package/src/verbs/plan.js +595 -0
- package/src/verbs/pull.js +255 -20
- package/src/verbs/redirects-check.js +4 -10
- package/src/verbs/theme-push.js +68 -36
- package/src/verbs/validate.js +309 -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 +105 -10
package/src/verbs/pages-push.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
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 ${
|
|
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
|
-
}): ${
|
|
388
|
-
`${file}:${
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
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
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
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
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
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
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
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
|
-
|
|
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
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
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
|
-
|
|
870
|
-
|
|
871
|
-
|
|
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
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
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
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
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
|
-
|
|
927
|
-
|
|
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
|
-
|
|
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.",
|
|
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
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
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(
|
|
1012
|
-
const observed = await readObservedPageRecord(
|
|
1352
|
+
if (observedRecordFile !== undefined) await workspaceFileExists(workspaceDir, observedRecordFile);
|
|
1353
|
+
const observed = await readObservedPageRecord(workspaceDir, target.pageId);
|
|
1013
1354
|
observedRecord = observed;
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
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
|
|
1385
|
+
workspaceDir,
|
|
1051
1386
|
siteId,
|
|
1052
1387
|
file,
|
|
1053
1388
|
document: document_,
|
|
1054
1389
|
content,
|
|
1055
|
-
getSharedThemeContexts: async () => {
|
|
1056
|
-
sharedThemeContexts ??= await readSharedThemeContexts(
|
|
1390
|
+
getSharedThemeContexts: getSharedThemeContexts ?? (async () => {
|
|
1391
|
+
sharedThemeContexts ??= await readSharedThemeContexts(workspaceDir, siteId);
|
|
1057
1392
|
return sharedThemeContexts;
|
|
1058
|
-
},
|
|
1393
|
+
}),
|
|
1059
1394
|
});
|
|
1060
1395
|
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
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
|
-
|
|
1193
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
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.
|