@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.
@@ -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) return { targeted, sources: [], selected: [], planned: [], unresolved: [], problems };
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
- const sentDescription = typeof description === "string" ? description : "";
1104
- const contentKey = pageContentKey(
1105
- source.sourceHash,
1106
- { title, path: pagePath, description: sentDescription },
1107
- source.sourceFormat === PAGE_SOURCE_FORMAT_MARKDOWN ? canonicalDocumentHash(document_) : "",
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
- // The source and everything sent with it match what the site last agreed
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 || "/", action: page.unchanged ? "unchanged" : page.action };
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
- const summary = page.action === "created"
1305
- ? await createPage(client, { siteId, ...fields })
1306
- : await updatePage(client, page.pageId, { pageId: page.pageId, ...fields });
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.