@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.
Files changed (49) hide show
  1. package/README.md +181 -16
  2. package/examples/riverbend-wellness/.taproot-site-media.json +23 -0
  3. package/examples/riverbend-wellness/pages/index.md +1 -17
  4. package/package.json +2 -2
  5. package/src/api.js +123 -1
  6. package/src/authors-contract.js +170 -0
  7. package/src/cli.js +355 -30
  8. package/src/constants.js +9 -0
  9. package/src/content/components.js +64 -6
  10. package/src/content/free-form-sections.js +21 -4
  11. package/src/content/link-warnings.js +109 -0
  12. package/src/content/markdown.js +161 -26
  13. package/src/content/tiptap-prosemirror.ts +66 -8
  14. package/src/content/validate-document.js +30 -6
  15. package/src/content/vocabulary.js +26 -0
  16. package/src/errors.js +22 -0
  17. package/src/espalier-shared.js +11 -0
  18. package/src/fixture-init.js +55 -3
  19. package/src/font-catalog.js +87 -0
  20. package/src/footer-contract.js +1 -1
  21. package/src/output.js +19 -1
  22. package/src/page-metadata.js +92 -0
  23. package/src/presentation-reference.js +21 -7
  24. package/src/problems.js +116 -0
  25. package/src/reference-help.js +157 -41
  26. package/src/session.js +13 -0
  27. package/src/staging-check.js +62 -0
  28. package/src/theme-proposals.js +393 -0
  29. package/src/theme-validation.js +31 -10
  30. package/src/typed-page-reference.js +97 -21
  31. package/src/typed-pages.js +196 -13
  32. package/src/verbs/approve.js +63 -14
  33. package/src/verbs/authors.js +167 -0
  34. package/src/verbs/deploy.js +5 -28
  35. package/src/verbs/index.js +22 -0
  36. package/src/verbs/media-upload.js +127 -30
  37. package/src/verbs/nav-push.js +7 -0
  38. package/src/verbs/pages-meta-set.js +303 -0
  39. package/src/verbs/pages-push.js +814 -224
  40. package/src/verbs/places.js +61 -0
  41. package/src/verbs/plan.js +595 -0
  42. package/src/verbs/pull.js +255 -20
  43. package/src/verbs/redirects-check.js +4 -10
  44. package/src/verbs/theme-push.js +68 -36
  45. package/src/verbs/validate.js +309 -217
  46. package/src/video-prepare.js +48 -12
  47. package/src/workspace-check.js +199 -0
  48. package/src/workspace-ledger.js +173 -0
  49. package/src/workspace.js +105 -10
package/src/workspace.js CHANGED
@@ -4,7 +4,19 @@ import { lstat, mkdir, open, readdir, unlink } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
 
6
6
  import { atomicWriteFile } from "./atomic-file.js";
7
+ import { parseAuthorReference } from "./authors-contract.js";
7
8
  import { hasAsciiControl, sanitizeDiagnostic, SiteAuthoringError } from "./errors.js";
9
+ import {
10
+ noteEntry,
11
+ noteExists,
12
+ noteFileDeleted,
13
+ noteFileMissing,
14
+ noteFileRead,
15
+ noteFileWritten,
16
+ noteStreamMissing,
17
+ noteStreamOpened,
18
+ noteWalk,
19
+ } from "./workspace-ledger.js";
8
20
 
9
21
  /**
10
22
  * The local workspace: what `pull` writes, what the push verbs read back, and
@@ -279,6 +291,9 @@ export const WORKSPACE_LIMITS = Object.freeze({
279
291
  // untouched. Generous for any real authoring workspace, and an explicit
280
292
  // refusal rather than an unbounded buffer for one that is not.
281
293
  pulledBodyBytes: 64 * 1024 * 1024,
294
+ // The site's authors and the members a page can name: a few hundred people
295
+ // at most, but written by the site and read back, so bounded by bytes.
296
+ authorsBytes: 1024 * 1024,
282
297
  files: 5_000,
283
298
  directoryDepth: 12,
284
299
  relativePathBytes: 1_024,
@@ -330,17 +345,31 @@ export function canonicalDocumentHash(document_) {
330
345
 
331
346
  /**
332
347
  * What a push would send for a page, as one hash: the source's bytes plus the
333
- * title, path and description that travel with it (for a `.pm.json` source they
334
- * come from the manifest, not the file). `pages push` skips a page whose key
348
+ * title, path, description and author that travel with it (for a `.pm.json`
349
+ * source they come from the manifest, not the file). `pages push` skips a page whose key
335
350
  * equals the one recorded when the workspace last agreed with the site, so an
336
351
  * unedited page is never re-sent (an update would turn an approved page back
337
352
  * into a draft). A Markdown source also folds in the hash of the document it
338
353
  * converts to, because its media references resolve through the media manifest:
339
354
  * re-uploading an image changes what is sent without changing the file.
340
355
  */
341
- export function pageContentKey(sourceHash, { title, path, description }, documentHash = "") {
356
+ export function pageContentKey(sourceHash, { title, path, description, author }, documentHash = "") {
357
+ // The author joins the key only when there is one (TR01196). Every page
358
+ // without an author keeps the key it has always had, so an existing workspace
359
+ // does not see each of its pages as changed and resend them all, which would
360
+ // turn approved pages back into drafts.
342
361
  return workspaceContentHash(
343
- Buffer.from(JSON.stringify([sourceHash, title ?? "", path ?? "", description ?? "", documentHash]), "utf8"),
362
+ Buffer.from(
363
+ JSON.stringify([
364
+ sourceHash,
365
+ title ?? "",
366
+ path ?? "",
367
+ description ?? "",
368
+ documentHash,
369
+ ...(typeof author === "string" && author !== "" ? [author] : []),
370
+ ]),
371
+ "utf8",
372
+ ),
344
373
  );
345
374
  }
346
375
 
@@ -348,6 +377,12 @@ function normalizeBaselineHash(value) {
348
377
  return typeof value === "string" && WORKSPACE_CONTENT_HASH.test(value) ? value : undefined;
349
378
  }
350
379
 
380
+ // A folded author reference or "" (no author); anything else is a damaged member.
381
+ function normalizeAuthorValue(value) {
382
+ if (value === undefined || value === "") return value;
383
+ return parseAuthorReference(value)?.value;
384
+ }
385
+
351
386
  // A calendar date or "" (no date). Anything else is a damaged baseline member.
352
387
  function normalizeBaselineDisplayDate(value) {
353
388
  return typeof value === "string" && (value === "" || /^\d{4}-\d{2}-\d{2}$/u.test(value)) ? value : undefined;
@@ -364,7 +399,7 @@ function normalizeBaselineDisplayDate(value) {
364
399
  * dropped here instead of becoming a path refusal inside the verb that exists
365
400
  * to repair the manifest.
366
401
  */
367
- function isDiscoverablePageSource(file) {
402
+ export function isDiscoverablePageSource(file) {
368
403
  if (typeof file !== "string" || !file.startsWith(`${PAGES_DIRECTORY}/`)) return false;
369
404
  const segments = file.split("/");
370
405
  return segments.length > 1
@@ -423,9 +458,10 @@ export function pageSourceRegistry(manifest) {
423
458
  const contentKey = normalizeBaselineHash(declared?.contentKey);
424
459
  const revision = normalizePageBodyRevision(declared?.revision);
425
460
  const displayDate = normalizeBaselineDisplayDate(declared?.displayDate);
461
+ const author = normalizeAuthorValue(declared?.author);
426
462
  const baseline =
427
463
  remoteHash === undefined && sourceHash === undefined && revision === undefined && displayDate === undefined
428
- && contentKey === undefined
464
+ && contentKey === undefined && author === undefined
429
465
  ? undefined
430
466
  : {
431
467
  ...(remoteHash === undefined ? {} : { remoteHash }),
@@ -433,12 +469,31 @@ export function pageSourceRegistry(manifest) {
433
469
  ...(contentKey === undefined ? {} : { contentKey }),
434
470
  ...(revision === undefined ? {} : { revision }),
435
471
  ...(displayDate === undefined ? {} : { displayDate }),
472
+ // The author the site held for the page when this workspace last
473
+ // reconciled with it: what a page's own author reference is
474
+ // compared with before anything is sent (TR01196).
475
+ ...(author === undefined || author === "" ? {} : { author }),
436
476
  };
437
477
  const record = {
438
478
  pageId: entry.pageId,
439
479
  file: entry.file,
440
480
  sourceFormat,
441
481
  baseline,
482
+ // What a generated page was when this workspace last pulled it, kept so a
483
+ // pull that cannot read its body does not forget it (TR01195).
484
+ ...(entry.generated !== null && typeof entry.generated === "object" && !Array.isArray(entry.generated)
485
+ ? { generated: entry.generated }
486
+ : {}),
487
+ // What this workspace says the page's metadata is; a .pm.json page's only
488
+ // record of it (TR01194).
489
+ metadata: {
490
+ title: typeof entry.title === "string" ? entry.title : "",
491
+ path: typeof entry.path === "string" ? entry.path : "",
492
+ description: typeof entry.description === "string" ? entry.description : "",
493
+ // The author reference the site holds for the page, or the one a
494
+ // `pages meta set --author` is waiting to send (TR01196).
495
+ author: normalizeAuthorValue(entry.author) ?? "",
496
+ },
442
497
  baselineDiscarded: declared !== undefined && baseline === undefined,
443
498
  formatMismatch: entry.sourceFormat !== undefined && entry.sourceFormat !== sourceFormat,
444
499
  duplicateSource: false,
@@ -713,6 +768,7 @@ export async function ensureWorkspaceDirectory(workspaceDir, relativeDirectory =
713
768
  export async function readWorkspaceFile(workspaceDir, relativePath, maximumBytes) {
714
769
  const filePath = resolveWorkspacePath(workspaceDir, relativePath);
715
770
  if (!await requireRealDirectoryChain(workspaceDir, relativePath)) {
771
+ noteFileMissing(relativePath);
716
772
  throw new SiteAuthoringError(
717
773
  "workspace.file_missing",
718
774
  `Workspace file '${relativePath}' does not exist.`,
@@ -744,10 +800,13 @@ export async function readWorkspaceFile(workspaceDir, relativePath, maximumBytes
744
800
  if (bytesRead === 0) break;
745
801
  total += bytesRead;
746
802
  }
747
- return buffer.subarray(0, total);
803
+ const contents = buffer.subarray(0, total);
804
+ noteFileRead(relativePath, contents);
805
+ return contents;
748
806
  } catch (error) {
749
807
  if (error instanceof SiteAuthoringError) throw error;
750
808
  if (error && typeof error === "object" && error.code === "ENOENT") {
809
+ noteFileMissing(relativePath);
751
810
  throw new SiteAuthoringError(
752
811
  "workspace.file_missing",
753
812
  `Workspace file '${relativePath}' does not exist.`,
@@ -778,6 +837,7 @@ const STREAM_HEADER_BYTES = 4096;
778
837
  export async function openWorkspaceFileStream(workspaceDir, relativePath, maximumBytes) {
779
838
  const filePath = resolveWorkspacePath(workspaceDir, relativePath);
780
839
  if (!await requireRealDirectoryChain(workspaceDir, relativePath)) {
840
+ noteStreamMissing(relativePath);
781
841
  throw new SiteAuthoringError(
782
842
  "workspace.file_missing",
783
843
  `Workspace file '${relativePath}' does not exist.`,
@@ -812,6 +872,7 @@ export async function openWorkspaceFileStream(workspaceDir, relativePath, maximu
812
872
  total += bytesRead;
813
873
  }
814
874
  const opened = handle;
875
+ noteStreamOpened(relativePath, byteLength, modifiedMilliseconds, header.subarray(0, total));
815
876
  return {
816
877
  byteLength,
817
878
  modifiedMilliseconds,
@@ -850,6 +911,7 @@ export async function openWorkspaceFileStream(workspaceDir, relativePath, maximu
850
911
  await handle?.close().catch(() => {});
851
912
  if (error instanceof SiteAuthoringError) throw error;
852
913
  if (error && typeof error === "object" && error.code === "ENOENT") {
914
+ noteStreamMissing(relativePath);
853
915
  throw new SiteAuthoringError(
854
916
  "workspace.file_missing",
855
917
  `Workspace file '${relativePath}' does not exist.`,
@@ -885,6 +947,12 @@ export async function readWorkspaceJson(workspaceDir, relativePath, maximumBytes
885
947
  * read outside the workspace.
886
948
  */
887
949
  export async function inspectWorkspaceEntry(workspaceDir, relativePath) {
950
+ const kind = await classifyWorkspaceEntry(workspaceDir, relativePath);
951
+ noteEntry(relativePath, kind);
952
+ return kind;
953
+ }
954
+
955
+ async function classifyWorkspaceEntry(workspaceDir, relativePath) {
888
956
  const target = resolveWorkspacePath(workspaceDir, relativePath);
889
957
  // The leaf `lstat` below refuses a linked final component; this refuses a
890
958
  // linked one anywhere above it, which is how `media/link/sub` would otherwise
@@ -929,6 +997,12 @@ export async function inspectWorkspaceEntry(workspaceDir, relativePath) {
929
997
  * missing.
930
998
  */
931
999
  export async function workspaceFileExists(workspaceDir, relativePath) {
1000
+ const exists = await regularFileExists(workspaceDir, relativePath);
1001
+ noteExists(relativePath, exists);
1002
+ return exists;
1003
+ }
1004
+
1005
+ async function regularFileExists(workspaceDir, relativePath) {
932
1006
  const filePath = resolveWorkspacePath(workspaceDir, relativePath);
933
1007
  if (!await requireRealDirectoryChain(workspaceDir, relativePath)) return false;
934
1008
  let stats;
@@ -982,6 +1056,7 @@ export async function writeWorkspaceFile(workspaceDir, relativePath, contents, {
982
1056
  ),
983
1057
  },
984
1058
  });
1059
+ noteFileWritten(relativePath, contents);
985
1060
  return relativePath;
986
1061
  }
987
1062
 
@@ -998,7 +1073,7 @@ export async function writeWorkspaceJson(workspaceDir, relativePath, value) {
998
1073
  * exists to prevent.
999
1074
  */
1000
1075
  export async function deleteWorkspaceFile(workspaceDir, relativePath) {
1001
- if (!await workspaceFileExists(workspaceDir, relativePath)) return false;
1076
+ if (!await regularFileExists(workspaceDir, relativePath)) return false;
1002
1077
  try {
1003
1078
  await unlink(resolveWorkspacePath(workspaceDir, relativePath));
1004
1079
  } catch (error) {
@@ -1009,6 +1084,7 @@ export async function deleteWorkspaceFile(workspaceDir, relativePath) {
1009
1084
  { field: relativePath },
1010
1085
  );
1011
1086
  }
1087
+ noteFileDeleted(relativePath);
1012
1088
  return true;
1013
1089
  }
1014
1090
 
@@ -1040,8 +1116,12 @@ export async function walkWorkspaceFiles(
1040
1116
  extensions,
1041
1117
  { segmentPattern = SAFE_SEGMENT, segmentDescription = "letters, digits, '.', '_', and '-'" } = {},
1042
1118
  ) {
1119
+ const filter = `${extensions.join(",")}|${segmentPattern}`;
1043
1120
  const rootPath = resolveWorkspacePath(workspaceDir, relativeRoot);
1044
- if (!await requireRealDirectoryChain(workspaceDir, relativeRoot, { includeLeaf: true })) return [];
1121
+ if (!await requireRealDirectoryChain(workspaceDir, relativeRoot, { includeLeaf: true })) {
1122
+ noteWalk(relativeRoot, filter, []);
1123
+ return [];
1124
+ }
1045
1125
  const collected = [];
1046
1126
  const queue = [{ directory: rootPath, relative: relativeRoot, depth: 0 }];
1047
1127
  while (queue.length > 0) {
@@ -1088,7 +1168,9 @@ export async function walkWorkspaceFiles(
1088
1168
  }
1089
1169
  }
1090
1170
  }
1091
- return collected.sort();
1171
+ collected.sort();
1172
+ noteWalk(relativeRoot, filter, collected);
1173
+ return collected;
1092
1174
  }
1093
1175
 
1094
1176
  /**
@@ -1112,6 +1194,14 @@ export function normalizePagePath(value) {
1112
1194
  return trimmed;
1113
1195
  }
1114
1196
 
1197
+ /**
1198
+ * A page path as results show it: the home page is '/', everything else as
1199
+ * stored. The manifest and the wire keep the empty root path.
1200
+ */
1201
+ export function displayPagePath(value) {
1202
+ return (normalizePagePath(value) ?? value) || "/";
1203
+ }
1204
+
1115
1205
  /**
1116
1206
  * Chooses the workspace file name for a pulled page. The server's path is
1117
1207
  * preferred because it is what an author recognizes, but it is server data
@@ -1324,6 +1414,11 @@ export async function readMediaManifest(workspaceDir, expectedSiteId) {
1324
1414
  };
1325
1415
  }
1326
1416
 
1417
+ /** The alt text a media record keeps when its file is uploaded again: the author's own, or none. */
1418
+ export function recordedAlt(entry) {
1419
+ return typeof entry?.alt === "string" ? entry.alt : "";
1420
+ }
1421
+
1327
1422
  export async function writeMediaManifest(workspaceDir, mediaManifest) {
1328
1423
  return await writeWorkspaceJson(workspaceDir, MEDIA_MANIFEST_FILE_NAME, mediaManifest);
1329
1424
  }