@taprootio/site-authoring 0.15.0 → 0.18.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,6 +5,7 @@ import { NAVIGATION_MAXIMUM_DEPTH } from "../api.js";
5
5
  import { loadSiteConfig } from "../config.js";
6
6
  import { VERB_VALIDATE } from "../constants.js";
7
7
  import { freeFormRootPresentation } from "../content/free-form-sections.js";
8
+ import { autolinkWarnings } from "../content/link-warnings.js";
8
9
  import { isCanonicalUuid, SiteAuthoringError } from "../errors.js";
9
10
  import {
10
11
  FIXTURE_CONTRACT_VERSION,
@@ -17,12 +18,14 @@ import {
17
18
  import { initializeFixture } from "../fixture-init.js";
18
19
  import { appearanceManifestEntry, footerManifestEntry } from "../footer-workspace.js";
19
20
  import { FORMS_DIRECTORY } from "../forms-contract.js";
21
+ import { authorWarnings, descriptionWarnings } from "../page-metadata.js";
20
22
  import { CHECK_AREA, collectProblems, refuseProblems } from "../problems.js";
21
23
  import { isRedirectMapRevision, REDIRECT_KIND_GONE, REDIRECTS_FILE_NAME } from "../redirects-contract.js";
22
24
  import { boundedList, successResult } from "../session.js";
23
25
  import { SETTINGS_GROUPS } from "../settings-catalog.js";
24
26
  import { checkWorkspace } from "../workspace-check.js";
25
27
  import {
28
+ displayPagePath,
26
29
  MANIFEST_FILE_NAME,
27
30
  MANIFEST_VERSION,
28
31
  MEDIA_MANIFEST_FILE_NAME,
@@ -30,6 +33,7 @@ import {
30
33
  normalizePagePath,
31
34
  PAGE_SOURCE_EXTENSIONS,
32
35
  PAGE_WORKSPACE_MODE_EDITABLE,
36
+ PAGE_WORKSPACE_MODE_METADATA_ONLY,
33
37
  PAGES_DIRECTORY,
34
38
  pageSourceFormat,
35
39
  readManifest,
@@ -39,6 +43,7 @@ import {
39
43
  walkWorkspaceFiles,
40
44
  WORKSPACE_LIMITS,
41
45
  workspaceFileExists,
46
+ SYSTEM_PAGE_NOT_FOUND_PATH,
42
47
  } from "../workspace.js";
43
48
  import { documentImageIds, isAuthorableTemplateType, placedVideoIds } from "../typed-pages.js";
44
49
 
@@ -289,6 +294,29 @@ function validateFixtureManifest(manifest) {
289
294
  for (const [index, entry] of manifest.pages.entries()) {
290
295
  const field = `pages[${index}]`;
291
296
  const pagePath = normalizePagePath(entry?.path);
297
+ // A page the fixture only names, such as a generated page navigation
298
+ // points at: it has identities and a path, and no source (TR01198).
299
+ if (isPlainObject(entry) && entry.workspaceMode === PAGE_WORKSPACE_MODE_METADATA_ONLY) {
300
+ // The home page and the 404 page are always editable sources.
301
+ if (
302
+ !isCanonicalUuid(entry.pageId) || !isCanonicalUuid(entry.resourceId) || pagePath === undefined
303
+ || Object.hasOwn(entry, "file") || pagePath === "" || pagePath === SYSTEM_PAGE_NOT_FOUND_PATH
304
+ ) {
305
+ fail(
306
+ "fixture.page_invalid",
307
+ `${field} is metadata-only, so it needs deterministic page/resource identities, a usable path other than `
308
+ + "the home page or the 404 page, and no file.",
309
+ field,
310
+ );
311
+ }
312
+ if (pageIds.has(entry.pageId) || resourceIds.has(entry.resourceId) || paths.has(pagePath)) {
313
+ fail("fixture.page_duplicate", `${field} duplicates a pageId, resourceId, or page path.`, field);
314
+ }
315
+ pageIds.add(entry.pageId);
316
+ resourceIds.add(entry.resourceId);
317
+ paths.add(pagePath);
318
+ continue;
319
+ }
292
320
  const extension = typeof entry?.file === "string"
293
321
  ? PAGE_SOURCE_EXTENSIONS.find((candidate) => entry.file.toLowerCase().endsWith(candidate))
294
322
  : undefined;
@@ -333,6 +361,9 @@ function validateFixtureManifest(manifest) {
333
361
  files.add(entry.file);
334
362
  paths.add(pagePath);
335
363
  }
364
+ if (files.size === 0) {
365
+ fail("fixture.pages_invalid", "The fixture manifest must bind one or more editable pages.", "pages");
366
+ }
336
367
  return { manifest, imageIds, videoIds, deliveryOrigins, pageIds, resourceIds, files, settingsEntityIds };
337
368
  }
338
369
 
@@ -691,7 +722,7 @@ export async function validateFixture(invocation = {}) {
691
722
  requireFixturePageBound(page, manifestByFile.get(page.file), { imageIds, videoIds, deliveryOrigins });
692
723
  return true;
693
724
  });
694
- if (bound) validatedPages.push({ file: page.file, path: page.pagePath });
725
+ if (bound) validatedPages.push({ file: page.file, path: displayPagePath(page.pagePath) });
695
726
  }
696
727
  await collectProblems(problems, { area: "fixture", file: FIXTURE_MANIFEST_FILE_NAME }, () => {
697
728
  requireFixtureRecordsMatch(manifest, checked);
@@ -701,6 +732,11 @@ export async function validateFixture(invocation = {}) {
701
732
  refuseProblems(problems, "validate found problems in the fixture");
702
733
 
703
734
  const hints = pageHints(checked, onProgress);
735
+ const warnings = {
736
+ ...autolinkWarnings(checked.pages?.planned, onProgress),
737
+ ...descriptionWarnings(checked.pages?.planned, onProgress),
738
+ ...authorWarnings(checked.pages?.planned, onProgress),
739
+ };
704
740
  onProgress(
705
741
  `Validated ${validatedPages.length} page(s), ${checked.navigation.items} navigation item(s), two themes, appearance, and footer without credentials or mutation.`,
706
742
  );
@@ -715,6 +751,7 @@ export async function validateFixture(invocation = {}) {
715
751
  },
716
752
  ...validatedSummary(checked, validatedPages),
717
753
  hints,
754
+ ...warnings,
718
755
  proves: [
719
756
  "fixture structure and bounded files",
720
757
  "page content and named theme contexts",
@@ -756,8 +793,15 @@ async function validatePulledWorkspace(workspaceDir, siteId, invocation, onProgr
756
793
 
757
794
  const pages = checked.settingsOnly
758
795
  ? []
759
- : checked.pages.planned.map((page) => ({ file: page.file, path: page.pagePath }));
796
+ : checked.pages.planned.map((page) => ({ file: page.file, path: displayPagePath(page.pagePath) }));
760
797
  const hints = pageHints(checked, onProgress);
798
+ const warnings = checked.settingsOnly
799
+ ? {}
800
+ : {
801
+ ...autolinkWarnings(checked.pages.planned, onProgress),
802
+ ...descriptionWarnings(checked.pages.planned, onProgress),
803
+ ...authorWarnings(checked.pages.planned, onProgress),
804
+ };
761
805
  onProgress(
762
806
  checked.settingsOnly
763
807
  ? "Validated the workspace's two themes, appearance, and footer without credentials or mutation."
@@ -769,6 +813,7 @@ async function validatePulledWorkspace(workspaceDir, siteId, invocation, onProgr
769
813
  workspace: { manifest: MANIFEST_FILE_NAME, settingsOnly: checked.settingsOnly },
770
814
  ...validatedSummary(checked, pages),
771
815
  hints,
816
+ ...warnings,
772
817
  proves: [
773
818
  "every page source: metadata, Markdown conversion, media references, content vocabulary, and section contexts",
774
819
  "page paths, templates, and system pages against the pages the pull recorded",
package/src/workspace.js CHANGED
@@ -4,6 +4,7 @@ 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";
8
9
  import {
9
10
  noteEntry,
@@ -290,6 +291,9 @@ export const WORKSPACE_LIMITS = Object.freeze({
290
291
  // untouched. Generous for any real authoring workspace, and an explicit
291
292
  // refusal rather than an unbounded buffer for one that is not.
292
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,
293
297
  files: 5_000,
294
298
  directoryDepth: 12,
295
299
  relativePathBytes: 1_024,
@@ -341,17 +345,31 @@ export function canonicalDocumentHash(document_) {
341
345
 
342
346
  /**
343
347
  * What a push would send for a page, as one hash: the source's bytes plus the
344
- * title, path and description that travel with it (for a `.pm.json` source they
345
- * 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
346
350
  * equals the one recorded when the workspace last agreed with the site, so an
347
351
  * unedited page is never re-sent (an update would turn an approved page back
348
352
  * into a draft). A Markdown source also folds in the hash of the document it
349
353
  * converts to, because its media references resolve through the media manifest:
350
354
  * re-uploading an image changes what is sent without changing the file.
351
355
  */
352
- 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.
353
361
  return workspaceContentHash(
354
- 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
+ ),
355
373
  );
356
374
  }
357
375
 
@@ -359,6 +377,12 @@ function normalizeBaselineHash(value) {
359
377
  return typeof value === "string" && WORKSPACE_CONTENT_HASH.test(value) ? value : undefined;
360
378
  }
361
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
+
362
386
  // A calendar date or "" (no date). Anything else is a damaged baseline member.
363
387
  function normalizeBaselineDisplayDate(value) {
364
388
  return typeof value === "string" && (value === "" || /^\d{4}-\d{2}-\d{2}$/u.test(value)) ? value : undefined;
@@ -375,7 +399,7 @@ function normalizeBaselineDisplayDate(value) {
375
399
  * dropped here instead of becoming a path refusal inside the verb that exists
376
400
  * to repair the manifest.
377
401
  */
378
- function isDiscoverablePageSource(file) {
402
+ export function isDiscoverablePageSource(file) {
379
403
  if (typeof file !== "string" || !file.startsWith(`${PAGES_DIRECTORY}/`)) return false;
380
404
  const segments = file.split("/");
381
405
  return segments.length > 1
@@ -434,9 +458,10 @@ export function pageSourceRegistry(manifest) {
434
458
  const contentKey = normalizeBaselineHash(declared?.contentKey);
435
459
  const revision = normalizePageBodyRevision(declared?.revision);
436
460
  const displayDate = normalizeBaselineDisplayDate(declared?.displayDate);
461
+ const author = normalizeAuthorValue(declared?.author);
437
462
  const baseline =
438
463
  remoteHash === undefined && sourceHash === undefined && revision === undefined && displayDate === undefined
439
- && contentKey === undefined
464
+ && contentKey === undefined && author === undefined
440
465
  ? undefined
441
466
  : {
442
467
  ...(remoteHash === undefined ? {} : { remoteHash }),
@@ -444,12 +469,31 @@ export function pageSourceRegistry(manifest) {
444
469
  ...(contentKey === undefined ? {} : { contentKey }),
445
470
  ...(revision === undefined ? {} : { revision }),
446
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 }),
447
476
  };
448
477
  const record = {
449
478
  pageId: entry.pageId,
450
479
  file: entry.file,
451
480
  sourceFormat,
452
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
+ },
453
497
  baselineDiscarded: declared !== undefined && baseline === undefined,
454
498
  formatMismatch: entry.sourceFormat !== undefined && entry.sourceFormat !== sourceFormat,
455
499
  duplicateSource: false,
@@ -1150,6 +1194,14 @@ export function normalizePagePath(value) {
1150
1194
  return trimmed;
1151
1195
  }
1152
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
+
1153
1205
  /**
1154
1206
  * Chooses the workspace file name for a pulled page. The server's path is
1155
1207
  * preferred because it is what an author recognizes, but it is server data