@taprootio/site-authoring 0.15.0 → 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 +121 -8
- package/package.json +1 -1
- package/src/api.js +65 -0
- package/src/authors-contract.js +170 -0
- package/src/cli.js +211 -11
- package/src/constants.js +3 -0
- package/src/content/link-warnings.js +109 -0
- package/src/fixture-init.js +55 -3
- package/src/page-metadata.js +92 -0
- package/src/reference-help.js +58 -8
- package/src/staging-check.js +62 -0
- package/src/typed-page-reference.js +86 -18
- package/src/typed-pages.js +165 -4
- 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 +9 -0
- package/src/verbs/pages-meta-set.js +303 -0
- package/src/verbs/pages-push.js +419 -36
- package/src/verbs/plan.js +11 -1
- package/src/verbs/pull.js +255 -20
- package/src/verbs/redirects-check.js +4 -10
- package/src/verbs/validate.js +47 -2
- package/src/workspace.js +58 -6
package/src/verbs/pages-push.js
CHANGED
|
@@ -5,22 +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";
|
|
15
28
|
import { stableJson } from "../footer-draft-hash.js";
|
|
16
29
|
import { CHECK_AREA, collectProblems, planStale, problemsOf, refuseProblems } from "../problems.js";
|
|
17
30
|
import { RUNTIME_MIRROR_PATH_REASON, isRuntimeMirrorPath } from "../reserved-paths.js";
|
|
18
31
|
import { boundedByBytes, openSession, successResult, warnIfExternalWritesPaused } from "../session.js";
|
|
19
32
|
import { SETTINGS_TYPE_TAPROOT_STYLES } from "../settings-catalog.js";
|
|
20
33
|
import {
|
|
34
|
+
changedGeneratedIdentity,
|
|
21
35
|
contentDocuments,
|
|
22
36
|
COVER_IMAGE_UNUSED_MESSAGE,
|
|
23
37
|
documentTemplate,
|
|
38
|
+
generatedMarkdownFault,
|
|
39
|
+
generatedPageDescription,
|
|
40
|
+
generatedPageTitle,
|
|
24
41
|
placedVideoIds,
|
|
25
42
|
posterlessVideoEmbeds,
|
|
26
43
|
FRONT_MATTER_KEYS,
|
|
@@ -28,6 +45,7 @@ import {
|
|
|
28
45
|
requireTemplateName,
|
|
29
46
|
sentDisplayDate,
|
|
30
47
|
TEMPLATE_FREE_FORM,
|
|
48
|
+
TEMPLATE_GENERATED,
|
|
31
49
|
templateFrontMatterFault,
|
|
32
50
|
typedDocumentFromJson,
|
|
33
51
|
typedDocumentFromMarkdown,
|
|
@@ -43,6 +61,7 @@ import {
|
|
|
43
61
|
} from "../content/free-form-sections.js";
|
|
44
62
|
import { CONTENT_ERROR_CODES } from "../content/vocabulary.js";
|
|
45
63
|
import {
|
|
64
|
+
displayPagePath,
|
|
46
65
|
deleteWorkspaceFile,
|
|
47
66
|
internalPageObservedRevisionFile,
|
|
48
67
|
MEDIA_MANIFEST_FILE_NAME,
|
|
@@ -70,6 +89,7 @@ import {
|
|
|
70
89
|
workspaceContentHash,
|
|
71
90
|
workspaceFileExists,
|
|
72
91
|
writeManifest,
|
|
92
|
+
writeWorkspaceJson,
|
|
73
93
|
} from "../workspace.js";
|
|
74
94
|
|
|
75
95
|
/**
|
|
@@ -266,6 +286,7 @@ function parseFrontMatter(source, file) {
|
|
|
266
286
|
if (fields.has("template")) {
|
|
267
287
|
try {
|
|
268
288
|
template = requireTemplateName(fields.get("template"), file);
|
|
289
|
+
if (template === TEMPLATE_GENERATED) record(generatedMarkdownFault(file));
|
|
269
290
|
} catch (error) {
|
|
270
291
|
record(error);
|
|
271
292
|
}
|
|
@@ -478,6 +499,7 @@ export async function readWorkspacePageSource({ workspaceDir, file, manifestEntr
|
|
|
478
499
|
let pathCandidates = [];
|
|
479
500
|
let title;
|
|
480
501
|
let declaredDescription;
|
|
502
|
+
let declaredAuthor;
|
|
481
503
|
let declaredPath;
|
|
482
504
|
let template = TEMPLATE_FREE_FORM;
|
|
483
505
|
let fields;
|
|
@@ -487,6 +509,9 @@ export async function readWorkspacePageSource({ workspaceDir, file, manifestEntr
|
|
|
487
509
|
fields = frontMatter.fields;
|
|
488
510
|
title = frontMatter.fields.get("title") ?? manifestEntry?.title;
|
|
489
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;
|
|
490
515
|
declaredPath = frontMatter.fields.has("path") ? frontMatter.fields.get("path") : manifestEntry?.path;
|
|
491
516
|
markdown = frontMatter.markdown;
|
|
492
517
|
fault = frontMatter.fault;
|
|
@@ -502,10 +527,10 @@ export async function readWorkspacePageSource({ workspaceDir, file, manifestEntr
|
|
|
502
527
|
}
|
|
503
528
|
title = manifestEntry.title;
|
|
504
529
|
declaredDescription = manifestEntry.description;
|
|
530
|
+
declaredAuthor = manifestEntry.author;
|
|
505
531
|
declaredPath = manifestEntry.path;
|
|
506
532
|
template = peekJsonTemplate(source);
|
|
507
533
|
}
|
|
508
|
-
|
|
509
534
|
const pagePath = normalizePagePath(declaredPath);
|
|
510
535
|
if (pagePath === undefined) {
|
|
511
536
|
// Not one resolved page, but not nothing either: a file declaring two
|
|
@@ -554,6 +579,7 @@ export async function readWorkspacePageSource({ workspaceDir, file, manifestEntr
|
|
|
554
579
|
pagePath,
|
|
555
580
|
title,
|
|
556
581
|
declaredDescription,
|
|
582
|
+
declaredAuthor,
|
|
557
583
|
fault,
|
|
558
584
|
pathClaims: [],
|
|
559
585
|
template,
|
|
@@ -631,6 +657,51 @@ function requireReconciledDisplayDate({ file, pagePath, target, entry, document_
|
|
|
631
657
|
);
|
|
632
658
|
}
|
|
633
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
|
+
|
|
634
705
|
/**
|
|
635
706
|
* The metadata checks that are *not* part of deciding which page a source is.
|
|
636
707
|
*
|
|
@@ -844,6 +915,110 @@ async function copyVideoEmbedPosters(client, siteId, planned, { onProgress, now
|
|
|
844
915
|
}
|
|
845
916
|
}
|
|
846
917
|
|
|
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,
|
|
1015
|
+
);
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
return { author: reference.value, sendAuthor: true, unverified };
|
|
1020
|
+
}
|
|
1021
|
+
|
|
847
1022
|
/**
|
|
848
1023
|
* Phase one over the workspace's page sources (TR01002): resolve which file is
|
|
849
1024
|
* each page's one source, convert and validate it, and decide create, update,
|
|
@@ -867,15 +1042,21 @@ export async function planPages({
|
|
|
867
1042
|
online,
|
|
868
1043
|
requestedPaths,
|
|
869
1044
|
getSharedThemeContexts,
|
|
1045
|
+
refreshAuthors,
|
|
870
1046
|
onProgress = () => {},
|
|
871
1047
|
}) {
|
|
872
1048
|
const problems = [];
|
|
873
1049
|
const area = { area: CHECK_AREA.pages };
|
|
874
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 = [];
|
|
875
1054
|
// Manifest integrity is never scoped: a registry that contradicts itself
|
|
876
1055
|
// describes some other workspace, so nothing in it can be planned.
|
|
877
1056
|
await collectProblems(problems, area, () => requireManifestSourceRegistry(pageSourceRegistry(manifest)));
|
|
878
|
-
if (problems.length > 0)
|
|
1057
|
+
if (problems.length > 0) {
|
|
1058
|
+
return { targeted, sources: [], selected: [], planned: [], unresolved: [], staleGenerated, problems };
|
|
1059
|
+
}
|
|
879
1060
|
const live = livePages.filter((summary) => summary.status !== PAGE_STATUS_DELETED);
|
|
880
1061
|
const liveById = new Map(live.map((summary) => [summary.pageId, summary]));
|
|
881
1062
|
const liveByPath = new Map(live.map((summary) => [normalizePagePath(summary.path) ?? summary.path, summary]));
|
|
@@ -891,6 +1072,26 @@ export async function planPages({
|
|
|
891
1072
|
manifest.pages.filter((entry) => typeof entry?.pageId === "string").map((entry) => [entry.pageId, entry]),
|
|
892
1073
|
);
|
|
893
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);
|
|
1084
|
+
}
|
|
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
|
+
};
|
|
894
1095
|
|
|
895
1096
|
// Resolution: which file is each page path's one authoritative source.
|
|
896
1097
|
// This reads metadata only — a manifest entry, or a front-matter block —
|
|
@@ -899,8 +1100,35 @@ export async function planPages({
|
|
|
899
1100
|
const sources = [];
|
|
900
1101
|
const claimants = [];
|
|
901
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
|
+
);
|
|
902
1111
|
for (const file of files) {
|
|
903
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
|
+
}
|
|
904
1132
|
try {
|
|
905
1133
|
const source = await readWorkspacePageSource({ workspaceDir, file, manifestEntry });
|
|
906
1134
|
if (source.pagePath === undefined) {
|
|
@@ -1012,6 +1240,79 @@ export async function planPages({
|
|
|
1012
1240
|
file,
|
|
1013
1241
|
);
|
|
1014
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
|
+
}
|
|
1015
1316
|
const workspaceSystemKind = systemPageKind(pagePath);
|
|
1016
1317
|
if (target === undefined) {
|
|
1017
1318
|
if (workspaceSystemKind !== undefined) {
|
|
@@ -1051,7 +1352,7 @@ export async function planPages({
|
|
|
1051
1352
|
if (observedRecordFile !== undefined) await workspaceFileExists(workspaceDir, observedRecordFile);
|
|
1052
1353
|
const observed = await readObservedPageRecord(workspaceDir, target.pageId);
|
|
1053
1354
|
observedRecord = observed;
|
|
1054
|
-
if (online) {
|
|
1355
|
+
if (online && !untouchedGenerated) {
|
|
1055
1356
|
const guard = {
|
|
1056
1357
|
file,
|
|
1057
1358
|
pagePath,
|
|
@@ -1071,7 +1372,7 @@ export async function planPages({
|
|
|
1071
1372
|
// earlier pages have been written. A page matching itself is the ordinary
|
|
1072
1373
|
// update and is left alone.
|
|
1073
1374
|
const holder = liveByPath.get(pagePath);
|
|
1074
|
-
if (holder !== undefined && holder.pageId !== target?.pageId) {
|
|
1375
|
+
if (!untouchedGenerated && holder !== undefined && holder.pageId !== target?.pageId) {
|
|
1075
1376
|
throw documentError(
|
|
1076
1377
|
"pages.path_taken",
|
|
1077
1378
|
`'${file}' claims page path '${pagePath}', which page ${holder.pageId} already holds on this site. `
|
|
@@ -1080,14 +1381,6 @@ export async function planPages({
|
|
|
1080
1381
|
);
|
|
1081
1382
|
}
|
|
1082
1383
|
|
|
1083
|
-
// The file-keyed entry is gone whenever the file was renamed or replaced —
|
|
1084
|
-
// which is exactly what the pull, delete the `.pm.json`, author a `.md`
|
|
1085
|
-
// flow does. Falling back through the page's own identity keeps the
|
|
1086
|
-
// fields the workspace never restated; without it the whole-object
|
|
1087
|
-
// UpdatePage sends "" and false and silently clears both.
|
|
1088
|
-
const targetEntry = target === undefined ? undefined : manifestByPageId.get(target.pageId);
|
|
1089
|
-
const description = declaredDescription ?? targetEntry?.description ?? "";
|
|
1090
|
-
|
|
1091
1384
|
await validateWorkspacePageDocument({
|
|
1092
1385
|
workspaceDir,
|
|
1093
1386
|
siteId,
|
|
@@ -1100,28 +1393,54 @@ export async function planPages({
|
|
|
1100
1393
|
}),
|
|
1101
1394
|
});
|
|
1102
1395
|
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
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
|
+
}
|
|
1109
1432
|
planned.push({
|
|
1110
1433
|
file,
|
|
1111
1434
|
pagePath,
|
|
1112
|
-
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 } : {}),
|
|
1113
1438
|
contentKey,
|
|
1114
|
-
|
|
1115
|
-
// to, so sending again would only turn an approved page back into a draft.
|
|
1116
|
-
// Not when the operator was shown a conflict for this page (the record
|
|
1117
|
-
// exists to let this push reassert the source), and not when the source
|
|
1118
|
-
// moved or changed format, which the send re-registers in the manifest.
|
|
1119
|
-
unchanged: target !== undefined
|
|
1120
|
-
&& targetEntry?.baseline?.contentKey === contentKey
|
|
1121
|
-
&& observedRecord === undefined
|
|
1122
|
-
&& targetEntry.file === file
|
|
1123
|
-
&& targetEntry.sourceFormat === source.sourceFormat,
|
|
1439
|
+
unchanged,
|
|
1124
1440
|
description: sentDescription,
|
|
1441
|
+
author: authorship.author,
|
|
1442
|
+
sendAuthor: authorship.sendAuthor,
|
|
1443
|
+
...(authorship.unverified === true ? { authorUnverified: true } : {}),
|
|
1125
1444
|
document: document_,
|
|
1126
1445
|
sourceFormat: source.sourceFormat,
|
|
1127
1446
|
sourceHash: source.sourceHash,
|
|
@@ -1135,7 +1454,7 @@ export async function planPages({
|
|
|
1135
1454
|
});
|
|
1136
1455
|
}
|
|
1137
1456
|
|
|
1138
|
-
return { targeted, sources, selected, planned, unresolved, problems };
|
|
1457
|
+
return { targeted, sources, selected, planned, unresolved, staleGenerated, problems };
|
|
1139
1458
|
}
|
|
1140
1459
|
|
|
1141
1460
|
function missingPathError(file) {
|
|
@@ -1146,9 +1465,20 @@ function missingPathError(file) {
|
|
|
1146
1465
|
);
|
|
1147
1466
|
}
|
|
1148
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
|
+
|
|
1149
1479
|
/** What a dry run or a plan reports for one planned page. */
|
|
1150
1480
|
export function plannedPageItem(page) {
|
|
1151
|
-
return { file: page.file, path: page.pagePath
|
|
1481
|
+
return { file: page.file, path: displayPagePath(page.pagePath), action: page.unchanged ? "unchanged" : page.action };
|
|
1152
1482
|
}
|
|
1153
1483
|
|
|
1154
1484
|
export async function pagesPush(invocation) {
|
|
@@ -1219,9 +1549,17 @@ export async function pagesPush(invocation) {
|
|
|
1219
1549
|
livePages,
|
|
1220
1550
|
online: true,
|
|
1221
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
|
+
}),
|
|
1222
1560
|
onProgress,
|
|
1223
1561
|
});
|
|
1224
|
-
const { planned, targeted, unresolved } = plan;
|
|
1562
|
+
const { planned, targeted, unresolved, staleGenerated } = plan;
|
|
1225
1563
|
// A page that places a video names it by id, and only the library knows
|
|
1226
1564
|
// whether that id is one of this site's videos. Asked once for the whole
|
|
1227
1565
|
// push, before anything is written, so a page cannot be sent that
|
|
@@ -1254,6 +1592,10 @@ export async function pagesPush(invocation) {
|
|
|
1254
1592
|
: {}),
|
|
1255
1593
|
discovered: files.length,
|
|
1256
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) };
|
|
1257
1599
|
if (dryRun) {
|
|
1258
1600
|
refuseProblems(plan.problems, "pages push --dry-run found problems and sent nothing");
|
|
1259
1601
|
onProgress("Dry run: nothing was sent.");
|
|
@@ -1266,8 +1608,10 @@ export async function pagesPush(invocation) {
|
|
|
1266
1608
|
wouldUpdate: planned.filter((page) => !page.unchanged && page.action === "updated").length,
|
|
1267
1609
|
...selection,
|
|
1268
1610
|
validated: planned.length,
|
|
1611
|
+
...staleGeneratedSourcesField(staleGenerated),
|
|
1269
1612
|
...reportedList("items", planned.map(plannedPageItem), ITEMS_BYTES),
|
|
1270
1613
|
},
|
|
1614
|
+
...warnings,
|
|
1271
1615
|
});
|
|
1272
1616
|
}
|
|
1273
1617
|
refuseProblems(plan.problems, "pages push sent nothing");
|
|
@@ -1292,6 +1636,10 @@ export async function pagesPush(invocation) {
|
|
|
1292
1636
|
shortDescription: page.description,
|
|
1293
1637
|
template: wireTemplate(page.document),
|
|
1294
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 } : {}),
|
|
1295
1643
|
};
|
|
1296
1644
|
// A create whose answer is lost may still have made the page. Recorded
|
|
1297
1645
|
// first, so a later push or plan that finds a page at this path refuses
|
|
@@ -1301,9 +1649,14 @@ export async function pagesPush(invocation) {
|
|
|
1301
1649
|
await writeManifest(config.workspaceDir, manifest);
|
|
1302
1650
|
}
|
|
1303
1651
|
attempting = page;
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
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
|
+
}
|
|
1307
1660
|
attempting = undefined;
|
|
1308
1661
|
manifestDirty = true;
|
|
1309
1662
|
if (page.action === "created") {
|
|
@@ -1312,10 +1665,11 @@ export async function pagesPush(invocation) {
|
|
|
1312
1665
|
}
|
|
1313
1666
|
applied.push({
|
|
1314
1667
|
file: page.file,
|
|
1315
|
-
path: summary.path,
|
|
1668
|
+
path: displayPagePath(summary.path),
|
|
1316
1669
|
pageId: summary.pageId,
|
|
1317
1670
|
action: page.action,
|
|
1318
1671
|
status: summary.status,
|
|
1672
|
+
...(page.author === "" ? {} : { author: page.author }),
|
|
1319
1673
|
});
|
|
1320
1674
|
// `entry?.` because a hand-edited manifest can hold a null in the list,
|
|
1321
1675
|
// and a raw TypeError here would collapse to an opaque `site.failed`
|
|
@@ -1323,6 +1677,10 @@ export async function pagesPush(invocation) {
|
|
|
1323
1677
|
// where a crash costs more than a refusal.
|
|
1324
1678
|
const existing = manifest.pages.find((entry) => entry?.pageId === summary.pageId);
|
|
1325
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 ?? "");
|
|
1326
1684
|
Object.assign(record, {
|
|
1327
1685
|
pageId: summary.pageId,
|
|
1328
1686
|
resourceId: summary.resourceId || record.resourceId || "",
|
|
@@ -1361,9 +1719,14 @@ export async function pagesPush(invocation) {
|
|
|
1361
1719
|
// Recorded for the pages whose push sends a date, so the next push
|
|
1362
1720
|
// can tell a date the site changed from one it already holds.
|
|
1363
1721
|
...(sentDisplayDate(page.templateType) ? { displayDate: summary.displayDate } : {}),
|
|
1722
|
+
...(heldAuthor === "" ? {} : { author: heldAuthor }),
|
|
1364
1723
|
},
|
|
1365
1724
|
pendingApproval: true,
|
|
1366
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;
|
|
1367
1730
|
if (existing === undefined) manifest.pages.push(record);
|
|
1368
1731
|
// The override this page's record permitted has now been taken, and
|
|
1369
1732
|
// the baseline above records what the site holds because of it.
|
|
@@ -1407,6 +1770,16 @@ export async function pagesPush(invocation) {
|
|
|
1407
1770
|
if (manifestDirty) await writeManifest(config.workspaceDir, manifest);
|
|
1408
1771
|
}
|
|
1409
1772
|
|
|
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
|
+
}
|
|
1410
1783
|
return successResult(VERB_PAGES_PUSH, siteId, {
|
|
1411
1784
|
pages: {
|
|
1412
1785
|
total: planned.length,
|
|
@@ -1418,8 +1791,18 @@ export async function pagesPush(invocation) {
|
|
|
1418
1791
|
...(unresolved.length > 0
|
|
1419
1792
|
? { unresolved: unresolved.length, ...reportedList("unresolvedItems", unresolved, LIST_BYTES) }
|
|
1420
1793
|
: {}),
|
|
1794
|
+
...staleGeneratedSourcesField(staleGenerated),
|
|
1421
1795
|
...reportedList("items", applied, ITEMS_BYTES),
|
|
1796
|
+
...(authorless.length > 0
|
|
1797
|
+
? {
|
|
1798
|
+
authorless: {
|
|
1799
|
+
total: authorless.length,
|
|
1800
|
+
...reportedList("items", authorless.map(({ file, path }) => ({ file, path })), LIST_BYTES),
|
|
1801
|
+
},
|
|
1802
|
+
}
|
|
1803
|
+
: {}),
|
|
1422
1804
|
},
|
|
1805
|
+
...warnings,
|
|
1423
1806
|
// `pages push` writes drafts. Nothing reaches an audience until `approve`
|
|
1424
1807
|
// stages them and `deploy` publishes the site.
|
|
1425
1808
|
// Nothing to approve when every page was unchanged and nothing was sent.
|