@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
@@ -2,8 +2,10 @@ import { lstat, realpath } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
 
4
4
  import { NAVIGATION_MAXIMUM_DEPTH } from "../api.js";
5
+ import { loadSiteConfig } from "../config.js";
5
6
  import { VERB_VALIDATE } from "../constants.js";
6
- import { freeFormRootPresentation, sharedThemeContextNames } from "../content/free-form-sections.js";
7
+ import { freeFormRootPresentation } from "../content/free-form-sections.js";
8
+ import { autolinkWarnings } from "../content/link-warnings.js";
7
9
  import { isCanonicalUuid, SiteAuthoringError } from "../errors.js";
8
10
  import {
9
11
  FIXTURE_CONTRACT_VERSION,
@@ -14,42 +16,36 @@ import {
14
16
  FIXTURE_ROOT_FIELDS,
15
17
  } from "../fixture-contract.js";
16
18
  import { initializeFixture } from "../fixture-init.js";
17
- import { appearanceManifestEntry, FOOTER_SETTINGS_FILE, footerManifestEntry } from "../footer-workspace.js";
18
- import { readWorkspaceForms } from "../forms-workspace.js";
19
+ import { appearanceManifestEntry, footerManifestEntry } from "../footer-workspace.js";
19
20
  import { FORMS_DIRECTORY } from "../forms-contract.js";
20
- import {
21
- isRedirectMapRevision,
22
- normalizeRedirectPath,
23
- REDIRECT_KIND_GONE,
24
- REDIRECTS_FILE_NAME,
25
- validateRedirectsDocument,
26
- } from "../redirects-contract.js";
21
+ import { authorWarnings, descriptionWarnings } from "../page-metadata.js";
22
+ import { CHECK_AREA, collectProblems, refuseProblems } from "../problems.js";
23
+ import { isRedirectMapRevision, REDIRECT_KIND_GONE, REDIRECTS_FILE_NAME } from "../redirects-contract.js";
27
24
  import { boundedList, successResult } from "../session.js";
28
- import { SETTINGS_GROUPS, SETTINGS_TYPE_SITE_PUBLISHING_PREFERENCES } from "../settings-catalog.js";
25
+ import { SETTINGS_GROUPS } from "../settings-catalog.js";
26
+ import { checkWorkspace } from "../workspace-check.js";
29
27
  import {
28
+ displayPagePath,
29
+ MANIFEST_FILE_NAME,
30
30
  MANIFEST_VERSION,
31
+ MEDIA_MANIFEST_FILE_NAME,
31
32
  NAVIGATION_FILE_NAME,
32
33
  normalizePagePath,
33
34
  PAGE_SOURCE_EXTENSIONS,
34
35
  PAGE_WORKSPACE_MODE_EDITABLE,
36
+ PAGE_WORKSPACE_MODE_METADATA_ONLY,
35
37
  PAGES_DIRECTORY,
36
38
  pageSourceFormat,
39
+ readManifest,
40
+ readMediaManifest,
37
41
  readWorkspaceJson,
38
42
  SETTINGS_DIRECTORY,
39
43
  walkWorkspaceFiles,
40
44
  WORKSPACE_LIMITS,
45
+ workspaceFileExists,
46
+ SYSTEM_PAGE_NOT_FOUND_PATH,
41
47
  } from "../workspace.js";
42
- import { validateFooterWorkspaceDocument } from "./footer-push.js";
43
- import { validateNavigationWorkspaceDocument } from "./nav-push.js";
44
- import { validateWorkspacePageDocument, validateWorkspacePageSource } from "./pages-push.js";
45
- import { validateThemeWorkspace } from "./theme-push.js";
46
- import {
47
- documentImageIds,
48
- documentTemplate,
49
- isAuthorableTemplateType,
50
- PAGE_TEMPLATES,
51
- placedVideoIds,
52
- } from "../typed-pages.js";
48
+ import { documentImageIds, isAuthorableTemplateType, placedVideoIds } from "../typed-pages.js";
53
49
 
54
50
  const MAXIMUM_REPORTED = 200;
55
51
  const FIXTURE_ROOT_KEYS = new Set(FIXTURE_ROOT_FIELDS);
@@ -298,6 +294,29 @@ function validateFixtureManifest(manifest) {
298
294
  for (const [index, entry] of manifest.pages.entries()) {
299
295
  const field = `pages[${index}]`;
300
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
+ }
301
320
  const extension = typeof entry?.file === "string"
302
321
  ? PAGE_SOURCE_EXTENSIONS.find((candidate) => entry.file.toLowerCase().endsWith(candidate))
303
322
  : undefined;
@@ -342,6 +361,9 @@ function validateFixtureManifest(manifest) {
342
361
  files.add(entry.file);
343
362
  paths.add(pagePath);
344
363
  }
364
+ if (files.size === 0) {
365
+ fail("fixture.pages_invalid", "The fixture manifest must bind one or more editable pages.", "pages");
366
+ }
345
367
  return { manifest, imageIds, videoIds, deliveryOrigins, pageIds, resourceIds, files, settingsEntityIds };
346
368
  }
347
369
 
@@ -436,10 +458,8 @@ function headerWidthHints(headerWidth, rootBandPages) {
436
458
  * The fixture's forms, when it carries any. The manifest binding and the files
437
459
  * must name the same keys: a form file the manifest does not mention would be
438
460
  * validated by no one, and a key with no file would be a baseline for nothing.
439
- * Returns undefined for a fixture with no forms.
440
461
  */
441
- async function validateFixtureForms(fixtureRoot, bound, onProgress) {
442
- const files = await readWorkspaceForms(fixtureRoot);
462
+ function requireFixtureFormsBound(bound, files) {
443
463
  if (bound === undefined) {
444
464
  if (files.length > 0) {
445
465
  fail(
@@ -448,7 +468,7 @@ async function validateFixtureForms(fixtureRoot, bound, onProgress) {
448
468
  "forms",
449
469
  );
450
470
  }
451
- return undefined;
471
+ return;
452
472
  }
453
473
  const items = isPlainObject(bound) && isPlainObject(bound.items) ? bound.items : undefined;
454
474
  const keys = items === undefined ? [] : Object.keys(items).sort();
@@ -479,163 +499,95 @@ async function validateFixtureForms(fixtureRoot, bound, onProgress) {
479
499
  "forms",
480
500
  );
481
501
  }
482
- onProgress(`Validated ${files.length} form file${files.length === 1 ? "" : "s"} against the shared field schema.`);
483
- return files;
484
502
  }
485
503
 
486
- export async function validateFixture(invocation = {}) {
487
- if (invocation.init) return await initializeFixture(invocation, validateFixture);
488
- const onProgress = typeof invocation.onProgress === "function" ? invocation.onProgress : () => {};
489
- const fixtureRoot = await resolveFixtureRoot(invocation.cwd ?? process.cwd(), invocation.fixturePath);
490
- onProgress(`Reading ${FIXTURE_MANIFEST_FILE_NAME}.`);
491
- const contract = validateFixtureManifest(
492
- await readWorkspaceJson(fixtureRoot, FIXTURE_MANIFEST_FILE_NAME, WORKSPACE_LIMITS.manifestBytes),
493
- );
494
- const {
495
- manifest,
496
- imageIds,
497
- videoIds,
498
- deliveryOrigins,
499
- pageIds,
500
- resourceIds,
501
- files: declaredFiles,
502
- settingsEntityIds,
503
- } = contract;
504
-
505
- onProgress("Validating the complete light/dark theme, appearance, header, brand, and footer colors.");
506
- const presentation = await validateThemeWorkspace(fixtureRoot, manifest.siteId, imageIds, settingsEntityIds);
507
- const expectedAppearance = appearanceManifestEntry({
508
- SETTING_TYPE_TAPROOT_STYLES: presentation.style,
509
- SETTING_TYPE_BRAND: presentation.brand,
510
- SETTING_TYPE_SITE_HEADER: presentation.header,
511
- });
512
- if (manifest.appearance !== undefined && JSON.stringify(manifest.appearance) !== JSON.stringify(expectedAppearance)) {
513
- fail(
514
- "fixture.appearance_invalid",
515
- "The fixture appearance metadata must match its settings image identities.",
516
- "appearance",
517
- );
518
- }
519
- if (manifest.footer !== undefined) {
520
- const expectedFooter = footerManifestEntry(presentation.publishing.footerSettings);
521
- if (
522
- !isPlainObject(manifest.footer) || Object.keys(manifest.footer).length !== Object.keys(expectedFooter).length
523
- || Object.entries(expectedFooter).some(([key, value]) =>
524
- JSON.stringify(manifest.footer[key]) !== JSON.stringify(value)
525
- )
526
- ) {
527
- fail("fixture.footer_invalid", "The fixture footer metadata must match its footer document.", "footer");
528
- }
529
- }
530
- for (const warning of presentation.themes.warnings) onProgress(`Espalier warning: ${warning}`);
531
- if (presentation.themes.warningsTruncated) {
532
- const hidden = presentation.themes.warningCount - presentation.themes.warnings.length;
533
- onProgress(`${hidden} more Espalier warning(s) not shown; resolve the ones above and run validate again to see them.`);
534
- }
535
- const sharedContexts = sharedThemeContextNames(
536
- presentation.style.lightTheme,
537
- presentation.style.darkTheme,
538
- );
539
-
504
+ /** Every editable page source on disk, each one bound by the fixture manifest. */
505
+ async function fixturePageFileProblems(fixtureRoot, declaredFiles) {
506
+ const problems = [];
540
507
  const pageFiles = await walkWorkspaceFiles(fixtureRoot, PAGES_DIRECTORY, PAGE_SOURCE_EXTENSIONS);
541
- const manifestByFile = new Map(manifest.pages.map((entry) => [entry.file, entry]));
542
508
  for (const file of declaredFiles) {
543
- if (!pageFiles.includes(file)) {
544
- fail("fixture.page_missing", `The fixture manifest binds missing page source '${file}'.`, file);
545
- }
509
+ if (pageFiles.includes(file)) continue;
510
+ await collectProblems(problems, { area: CHECK_AREA.pages, file }, () =>
511
+ fail("fixture.page_missing", `The fixture manifest binds missing page source '${file}'.`, file));
546
512
  }
547
- const untracked = pageFiles.find((file) => !declaredFiles.has(file));
548
- if (untracked !== undefined) {
513
+ for (const file of pageFiles.filter((candidate) => !declaredFiles.has(candidate))) {
514
+ await collectProblems(problems, { area: CHECK_AREA.pages, file }, () =>
515
+ fail("fixture.page_untracked", `Page source '${file}' is not bound by ${FIXTURE_MANIFEST_FILE_NAME}.`, file));
516
+ }
517
+ return problems;
518
+ }
519
+
520
+ /**
521
+ * What only a fixture promises about a page that passed the shared checks:
522
+ * its source declares the path its manifest entry binds, and every image and
523
+ * video it places is one the fixture declares.
524
+ */
525
+ function requireFixturePageBound(page, entry, { imageIds, videoIds, deliveryOrigins }) {
526
+ const { file, document: document_ } = page;
527
+ if (page.pagePath !== normalizePagePath(entry.path)) {
549
528
  fail(
550
- "fixture.page_untracked",
551
- `Page source '${untracked}' is not bound by ${FIXTURE_MANIFEST_FILE_NAME}.`,
552
- untracked,
529
+ "fixture.page_path_mismatch",
530
+ `'${file}' declares path '${page.pagePath}' but its fixture manifest entry binds '${entry.path}'.`,
531
+ file,
553
532
  );
554
533
  }
555
- const validatedPages = [];
556
- const rootBandPages = [];
557
- for (const file of pageFiles) {
558
- onProgress(`Validating '${file}'.`);
559
- const entry = manifestByFile.get(file);
560
- const page = await validateWorkspacePageSource({
561
- workspaceDir: fixtureRoot,
534
+ validatePageImageReferences(document_, file, imageIds, deliveryOrigins);
535
+ // Album images and the cover image are bare ids with no delivery URL, so
536
+ // the walk above cannot see them.
537
+ const unknownImage = documentImageIds(document_).find((imageId) => !imageIds.has(imageId));
538
+ if (unknownImage !== undefined) {
539
+ fail(
540
+ "fixture.image_reference_unknown",
541
+ `Fixture image '${unknownImage}' is not declared in fixture.imageIds.`,
562
542
  file,
563
- manifestEntry: entry,
564
- mediaManifest: { media: {} },
565
- content: invocation.content,
566
- });
567
- await validateWorkspacePageDocument({
568
- workspaceDir: fixtureRoot,
569
- siteId: manifest.siteId,
543
+ );
544
+ }
545
+ const unknownVideo = [...placedVideoIds(document_)].find((videoId) => !videoIds.has(videoId));
546
+ if (unknownVideo !== undefined) {
547
+ fail(
548
+ "fixture.video_reference_unknown",
549
+ `Fixture video '${unknownVideo}' is not declared in fixture.videoIds, so it is not a ready video of this site.`,
570
550
  file,
571
- document: page.document,
572
- content: invocation.content,
573
- getSharedThemeContexts: async () => sharedContexts,
551
+ );
552
+ }
553
+ }
554
+
555
+ /** The fixture manifest's own records of the presentation, navigation, and redirects it binds. */
556
+ function requireFixtureRecordsMatch(manifest, checked) {
557
+ const { presentation, navigation, redirects } = checked;
558
+ if (presentation !== undefined) {
559
+ const expectedAppearance = appearanceManifestEntry({
560
+ SETTING_TYPE_TAPROOT_STYLES: presentation.style,
561
+ SETTING_TYPE_BRAND: presentation.brand,
562
+ SETTING_TYPE_SITE_HEADER: presentation.header,
574
563
  });
575
- if (page.pagePath !== normalizePagePath(entry.path)) {
564
+ if (manifest.appearance !== undefined && JSON.stringify(manifest.appearance) !== JSON.stringify(expectedAppearance)) {
576
565
  fail(
577
- "fixture.page_path_mismatch",
578
- `'${file}' declares path '${page.pagePath}' but its fixture manifest entry binds '${entry.path}'.`,
579
- file,
566
+ "fixture.appearance_invalid",
567
+ "The fixture appearance metadata must match its settings image identities.",
568
+ "appearance",
580
569
  );
581
570
  }
582
- // The manifest states which template the page has, and the source must be
583
- // one: a recipe document bound as an article would push as the wrong type.
584
- if (PAGE_TEMPLATES[documentTemplate(page.document)].wireType !== entry.templateType) {
585
- fail(
586
- "fixture.page_template_mismatch",
587
- `'${file}' is a ${documentTemplate(page.document)} source, but its fixture manifest entry binds `
588
- + `${entry.templateType}.`,
589
- file,
590
- );
591
- }
592
- validatePageImageReferences(page.document, file, imageIds, deliveryOrigins);
593
- // Album images and the cover image are bare ids with no delivery URL, so
594
- // the walk above cannot see them.
595
- const unknownImage = documentImageIds(page.document).find((imageId) => !imageIds.has(imageId));
596
- if (unknownImage !== undefined) {
597
- fail(
598
- "fixture.image_reference_unknown",
599
- `Fixture image '${unknownImage}' is not declared in fixture.imageIds.`,
600
- file,
601
- );
602
- }
603
- const unknownVideo = [...placedVideoIds(page.document)].find((videoId) => !videoIds.has(videoId));
604
- if (unknownVideo !== undefined) {
605
- fail(
606
- "fixture.video_reference_unknown",
607
- `Fixture video '${unknownVideo}' is not declared in fixture.videoIds, so it is not a ready video of this site.`,
608
- file,
609
- );
571
+ if (manifest.footer !== undefined) {
572
+ const expectedFooter = footerManifestEntry(presentation.publishing.footerSettings);
573
+ if (
574
+ !isPlainObject(manifest.footer) || Object.keys(manifest.footer).length !== Object.keys(expectedFooter).length
575
+ || Object.entries(expectedFooter).some(([key, value]) =>
576
+ JSON.stringify(manifest.footer[key]) !== JSON.stringify(value)
577
+ )
578
+ ) {
579
+ fail("fixture.footer_invalid", "The fixture footer metadata must match its footer document.", "footer");
580
+ }
610
581
  }
611
- validatedPages.push({ file, path: page.pagePath });
612
- if (pageUsesRootBand(page.document)) rootBandPages.push(file);
613
582
  }
614
- const hints = headerWidthHints(presentation.header.headerWidth, rootBandPages);
615
- for (const hint of hints) onProgress(`Hint: ${hint.message}`);
616
-
617
- onProgress("Validating navigation shape and local PAGE targets.");
618
- const navigation = validateNavigationWorkspaceDocument(
619
- await readWorkspaceJson(fixtureRoot, NAVIGATION_FILE_NAME, WORKSPACE_LIMITS.navigationBytes),
620
- manifest.siteId,
621
- { pageIds, resourceIds },
622
- );
623
- if (navigation.items !== manifest.navigation.items) {
583
+ if (navigation !== undefined && navigation.items !== manifest.navigation.items) {
624
584
  fail(
625
585
  "fixture.navigation_count_mismatch",
626
586
  `${NAVIGATION_FILE_NAME} contains ${navigation.items} item(s), but the fixture manifest records ${manifest.navigation.items}.`,
627
587
  "navigation.items",
628
588
  );
629
589
  }
630
-
631
- onProgress("Validating the redirect map: paths, targets, statuses, chains, and loops.");
632
- const redirectsDocument = await readWorkspaceJson(
633
- fixtureRoot,
634
- REDIRECTS_FILE_NAME,
635
- WORKSPACE_LIMITS.redirectsBytes,
636
- );
637
- const redirects = validateRedirectsDocument(redirectsDocument, manifest.siteId);
638
- if (redirects.entries.length !== manifest.redirects.entries) {
590
+ if (redirects !== undefined && redirects.entries.length !== manifest.redirects.entries) {
639
591
  fail(
640
592
  "fixture.redirects_count_mismatch",
641
593
  `${REDIRECTS_FILE_NAME} contains ${redirects.entries.length} entr${
@@ -644,51 +596,149 @@ export async function validateFixture(invocation = {}) {
644
596
  "redirects.entries",
645
597
  );
646
598
  }
647
- // A fixture is self-contained, so the occupancy rule the site enforces can be
648
- // checked here too: a source the fixture's own pages occupy would be refused
649
- // at the API, and saying so offline is the whole point of this verb. Paths
650
- // compare case-insensitively, as the site's citext path column does, and a
651
- // refusal names the entry where the author wrote it: the validated map is
652
- // sorted by path, so its index is not the document's.
653
- const fixturePagePaths = new Set(
654
- manifest.pages.map((entry) =>
655
- (`/${normalizePagePath(entry.path) ?? ""}`.replace(/\/+$/u, "") || "/").toLowerCase()
656
- ),
657
- );
658
- const authoredIndexByPath = new Map();
659
- for (const [index, entry] of redirectsDocument.entries.entries()) {
660
- const path = normalizeRedirectPath(entry?.path);
661
- if (path !== undefined && !authoredIndexByPath.has(path.toLowerCase())) {
662
- authoredIndexByPath.set(path.toLowerCase(), index);
663
- }
599
+ }
600
+
601
+ function reportThemeWarnings(presentation, onProgress) {
602
+ if (presentation === undefined) return;
603
+ for (const warning of presentation.themes.warnings) onProgress(`Espalier warning: ${warning}`);
604
+ if (presentation.themes.warningsTruncated) {
605
+ const hidden = presentation.themes.warningCount - presentation.themes.warnings.length;
606
+ onProgress(`${hidden} more Espalier warning(s) not shown; resolve the ones above and run validate again to see them.`);
664
607
  }
665
- for (const entry of redirects.entries) {
666
- const key = entry.path.toLowerCase();
667
- if (fixturePagePaths.has(key)) {
608
+ }
609
+
610
+ /** The part of a success result both kinds of workspace share. */
611
+ function validatedSummary(checked, pages) {
612
+ const { presentation, navigation, redirects, forms } = checked;
613
+ const reportedPages = boundedList(pages, MAXIMUM_REPORTED);
614
+ return {
615
+ validated: {
616
+ ...(checked.settingsOnly ? {} : {
617
+ pages: {
618
+ total: pages.length,
619
+ items: reportedPages.items,
620
+ ...(reportedPages.truncated ? { itemsTruncated: true } : {}),
621
+ },
622
+ navigation: { items: navigation.items, maximumDepth: NAVIGATION_MAXIMUM_DEPTH },
623
+ ...(redirects === undefined ? {} : {
624
+ redirects: {
625
+ entries: redirects.entries.length,
626
+ gone: redirects.entries.filter((entry) => entry.kind === REDIRECT_KIND_GONE).length,
627
+ },
628
+ }),
629
+ ...(forms === undefined || forms.length === 0 ? {} : { forms: { count: forms.length } }),
630
+ }),
631
+ themes: 2,
632
+ appearanceSettings: presentation.scalarOperations.length,
633
+ footer: true,
634
+ },
635
+ warnings: {
636
+ items: presentation.themes.warnings,
637
+ count: presentation.themes.warningCount,
638
+ ...(presentation.themes.warningsTruncated ? { truncated: true } : {}),
639
+ },
640
+ };
641
+ }
642
+
643
+ function pageHints(checked, onProgress) {
644
+ if (checked.settingsOnly || checked.presentation === undefined) return [];
645
+ const rootBandPages = checked.pages.planned.filter((page) => pageUsesRootBand(page.document)).map((page) => page.file);
646
+ const hints = headerWidthHints(checked.presentation.header.headerWidth, rootBandPages);
647
+ for (const hint of hints) onProgress(`Hint: ${hint.message}`);
648
+ return hints;
649
+ }
650
+
651
+ export async function validateFixture(invocation = {}) {
652
+ if (invocation.init) return await initializeFixture(invocation, validateFixture);
653
+ const onProgress = typeof invocation.onProgress === "function" ? invocation.onProgress : () => {};
654
+ const cwd = invocation.cwd ?? process.cwd();
655
+ if (invocation.fixturePath === undefined) {
656
+ const config = await loadSiteConfig({ cwd, configPath: invocation.configPath });
657
+ if (!isCanonicalUuid(config.siteId)) {
668
658
  fail(
669
- "fixture.redirect_path_occupied",
670
- `Redirect source '${entry.path}' is a page in this fixture, so it cannot also be a redirect source.`,
671
- `entries[${authoredIndexByPath.get(key) ?? 0}].path`,
659
+ "config.site_missing",
660
+ "No site is selected, so the workspace's identity is unknown. Run 'taproot-site use <site>' first.",
661
+ "siteId",
672
662
  );
673
663
  }
664
+ return await validatePulledWorkspace(config.workspaceDir, config.siteId, invocation, onProgress);
665
+ }
666
+ const fixtureRoot = await resolveFixtureRoot(cwd, invocation.fixturePath);
667
+ if (
668
+ !await workspaceFileExists(fixtureRoot, FIXTURE_MANIFEST_FILE_NAME)
669
+ && await workspaceFileExists(fixtureRoot, MANIFEST_FILE_NAME)
670
+ ) {
671
+ const { siteId } = await readWorkspaceJson(fixtureRoot, MANIFEST_FILE_NAME, WORKSPACE_LIMITS.manifestBytes);
672
+ if (!isCanonicalUuid(siteId)) {
673
+ fail("workspace.manifest_invalid", `${MANIFEST_FILE_NAME} does not record a canonical site id.`, "siteId");
674
+ }
675
+ return await validatePulledWorkspace(fixtureRoot, siteId, invocation, onProgress);
674
676
  }
675
677
 
676
- onProgress("Validating the closed footer document and local page/image targets.");
677
- validateFooterWorkspaceDocument(
678
- await readWorkspaceJson(fixtureRoot, FOOTER_SETTINGS_FILE, WORKSPACE_LIMITS.settingsBytes),
679
- manifest.siteId,
680
- {
681
- expectedEntityId: settingsEntityIds.get(SETTINGS_TYPE_SITE_PUBLISHING_PREFERENCES),
682
- knownPageResourceIds: resourceIds,
683
- knownImageIds: imageIds,
684
- },
678
+ onProgress(`Reading ${FIXTURE_MANIFEST_FILE_NAME}.`);
679
+ const contract = validateFixtureManifest(
680
+ await readWorkspaceJson(fixtureRoot, FIXTURE_MANIFEST_FILE_NAME, WORKSPACE_LIMITS.manifestBytes),
685
681
  );
682
+ const { manifest, imageIds, videoIds, deliveryOrigins, pageIds, resourceIds, files: declaredFiles } = contract;
683
+
684
+ const problems = await fixturePageFileProblems(fixtureRoot, declaredFiles);
685
+ // The fixture's own media manifest, as in a real workspace, so Markdown media
686
+ // paths resolve; none means no media. One that cannot be read is a fixture
687
+ // problem, and its pages then resolve no media paths.
688
+ const mediaManifest = await collectProblems(
689
+ problems,
690
+ { area: "fixture", file: MEDIA_MANIFEST_FILE_NAME },
691
+ async () => {
692
+ try {
693
+ return await readMediaManifest(fixtureRoot, manifest.siteId);
694
+ } catch (error) {
695
+ if (error?.code !== "workspace.manifest_site_mismatch") throw error;
696
+ throw new SiteAuthoringError(
697
+ "fixture.media_manifest_site",
698
+ `The fixture's ${MEDIA_MANIFEST_FILE_NAME} must record the fixture's siteId, ${manifest.siteId}.`,
699
+ { field: "siteId" },
700
+ );
701
+ }
702
+ },
703
+ ) ?? { media: {} };
704
+ const checked = await checkWorkspace({
705
+ workspaceDir: fixtureRoot,
706
+ siteId: manifest.siteId,
707
+ manifest,
708
+ mediaManifest,
709
+ binding: { knownImageIds: imageIds, settingsEntityIds: contract.settingsEntityIds, pageIds, resourceIds },
710
+ content: invocation.content,
711
+ onProgress,
712
+ });
713
+ problems.push(...checked.problems);
714
+ reportThemeWarnings(checked.presentation, onProgress);
686
715
 
687
- const forms = await validateFixtureForms(fixtureRoot, manifest.forms, onProgress);
716
+ const manifestByFile = new Map(manifest.pages.map((entry) => [entry.file, entry]));
717
+ const validatedPages = [];
718
+ for (const page of checked.pages?.planned ?? []) {
719
+ // A source the manifest does not bind is already reported as untracked.
720
+ if (!manifestByFile.has(page.file)) continue;
721
+ const bound = await collectProblems(problems, { area: CHECK_AREA.pages, file: page.file }, () => {
722
+ requireFixturePageBound(page, manifestByFile.get(page.file), { imageIds, videoIds, deliveryOrigins });
723
+ return true;
724
+ });
725
+ if (bound) validatedPages.push({ file: page.file, path: displayPagePath(page.pagePath) });
726
+ }
727
+ await collectProblems(problems, { area: "fixture", file: FIXTURE_MANIFEST_FILE_NAME }, () => {
728
+ requireFixtureRecordsMatch(manifest, checked);
729
+ // A forms folder that failed to read is already its own problem.
730
+ if (checked.forms !== undefined) requireFixtureFormsBound(manifest.forms, checked.forms);
731
+ });
732
+ refuseProblems(problems, "validate found problems in the fixture");
688
733
 
689
- const reportedPages = boundedList(validatedPages, MAXIMUM_REPORTED);
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
+ };
690
740
  onProgress(
691
- `Validated ${validatedPages.length} page(s), ${navigation.items} navigation item(s), two themes, appearance, and footer without credentials or mutation.`,
741
+ `Validated ${validatedPages.length} page(s), ${checked.navigation.items} navigation item(s), two themes, appearance, and footer without credentials or mutation.`,
692
742
  );
693
743
  return successResult(VERB_VALIDATE, manifest.siteId, {
694
744
  offline: true,
@@ -699,28 +749,9 @@ export async function validateFixture(invocation = {}) {
699
749
  videoIds: videoIds.size,
700
750
  deliveryOrigins: deliveryOrigins.size,
701
751
  },
702
- validated: {
703
- pages: {
704
- total: validatedPages.length,
705
- items: reportedPages.items,
706
- ...(reportedPages.truncated ? { itemsTruncated: true } : {}),
707
- },
708
- navigation: { items: navigation.items, maximumDepth: NAVIGATION_MAXIMUM_DEPTH },
709
- redirects: {
710
- entries: redirects.entries.length,
711
- gone: redirects.entries.filter((entry) => entry.kind === REDIRECT_KIND_GONE).length,
712
- },
713
- ...(forms === undefined ? {} : { forms: { count: forms.length } }),
714
- themes: 2,
715
- appearanceSettings: presentation.scalarOperations.length,
716
- footer: true,
717
- },
718
- warnings: {
719
- items: presentation.themes.warnings,
720
- count: presentation.themes.warningCount,
721
- ...(presentation.themes.warningsTruncated ? { truncated: true } : {}),
722
- },
752
+ ...validatedSummary(checked, validatedPages),
723
753
  hints,
754
+ ...warnings,
724
755
  proves: [
725
756
  "fixture structure and bounded files",
726
757
  "page content and named theme contexts",
@@ -739,3 +770,64 @@ export async function validateFixture(invocation = {}) {
739
770
  nextStep: "Run a real pull and authorized preview before approval or deployment.",
740
771
  });
741
772
  }
773
+
774
+ /**
775
+ * `validate` over a workspace `pull` wrote (TR01002). The same checks a push
776
+ * runs before it sends anything, for every page and setting at once, against
777
+ * what the manifests recorded at the pull — no credential and no request.
778
+ */
779
+ async function validatePulledWorkspace(workspaceDir, siteId, invocation, onProgress) {
780
+ onProgress(`Reading ${MANIFEST_FILE_NAME}.`);
781
+ const manifest = await readManifest(workspaceDir, siteId);
782
+ const mediaManifest = await readMediaManifest(workspaceDir, siteId);
783
+ const checked = await checkWorkspace({
784
+ workspaceDir,
785
+ siteId,
786
+ manifest,
787
+ mediaManifest,
788
+ content: invocation.content,
789
+ onProgress,
790
+ });
791
+ reportThemeWarnings(checked.presentation, onProgress);
792
+ refuseProblems(checked.problems, "validate found problems in the workspace");
793
+
794
+ const pages = checked.settingsOnly
795
+ ? []
796
+ : checked.pages.planned.map((page) => ({ file: page.file, path: displayPagePath(page.pagePath) }));
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
+ };
805
+ onProgress(
806
+ checked.settingsOnly
807
+ ? "Validated the workspace's two themes, appearance, and footer without credentials or mutation."
808
+ : `Validated ${pages.length} page(s), ${checked.navigation.items} navigation item(s), two themes, appearance, and `
809
+ + "footer without credentials or mutation.",
810
+ );
811
+ return successResult(VERB_VALIDATE, siteId, {
812
+ offline: true,
813
+ workspace: { manifest: MANIFEST_FILE_NAME, settingsOnly: checked.settingsOnly },
814
+ ...validatedSummary(checked, pages),
815
+ hints,
816
+ ...warnings,
817
+ proves: [
818
+ "every page source: metadata, Markdown conversion, media references, content vocabulary, and section contexts",
819
+ "page paths, templates, and system pages against the pages the pull recorded",
820
+ "navigation shape and page references the pull recorded",
821
+ "redirect-map shape, normalization, chains, loops, and workspace page paths",
822
+ "complete theme, appearance, header, brand, and footer semantics",
823
+ "form files against the shared field schema",
824
+ ],
825
+ doesNotProve: [
826
+ "credential authorization or live site ownership",
827
+ "pages, revisions, or videos that changed on the site since the pull",
828
+ "server normalization and pull round trips",
829
+ "preview or published rendering",
830
+ ],
831
+ nextStep: "Run 'taproot-site plan' to check the workspace against the live site and see the ordered apply.",
832
+ });
833
+ }