@taprootio/site-authoring 0.14.1 → 0.15.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.
@@ -2,8 +2,9 @@ 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";
7
8
  import { isCanonicalUuid, SiteAuthoringError } from "../errors.js";
8
9
  import {
9
10
  FIXTURE_CONTRACT_VERSION,
@@ -14,42 +15,32 @@ import {
14
15
  FIXTURE_ROOT_FIELDS,
15
16
  } from "../fixture-contract.js";
16
17
  import { initializeFixture } from "../fixture-init.js";
17
- import { appearanceManifestEntry, FOOTER_SETTINGS_FILE, footerManifestEntry } from "../footer-workspace.js";
18
- import { readWorkspaceForms } from "../forms-workspace.js";
18
+ import { appearanceManifestEntry, footerManifestEntry } from "../footer-workspace.js";
19
19
  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";
20
+ import { CHECK_AREA, collectProblems, refuseProblems } from "../problems.js";
21
+ import { isRedirectMapRevision, REDIRECT_KIND_GONE, REDIRECTS_FILE_NAME } from "../redirects-contract.js";
27
22
  import { boundedList, successResult } from "../session.js";
28
- import { SETTINGS_GROUPS, SETTINGS_TYPE_SITE_PUBLISHING_PREFERENCES } from "../settings-catalog.js";
23
+ import { SETTINGS_GROUPS } from "../settings-catalog.js";
24
+ import { checkWorkspace } from "../workspace-check.js";
29
25
  import {
26
+ MANIFEST_FILE_NAME,
30
27
  MANIFEST_VERSION,
28
+ MEDIA_MANIFEST_FILE_NAME,
31
29
  NAVIGATION_FILE_NAME,
32
30
  normalizePagePath,
33
31
  PAGE_SOURCE_EXTENSIONS,
34
32
  PAGE_WORKSPACE_MODE_EDITABLE,
35
33
  PAGES_DIRECTORY,
36
34
  pageSourceFormat,
35
+ readManifest,
36
+ readMediaManifest,
37
37
  readWorkspaceJson,
38
38
  SETTINGS_DIRECTORY,
39
39
  walkWorkspaceFiles,
40
40
  WORKSPACE_LIMITS,
41
+ workspaceFileExists,
41
42
  } 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";
43
+ import { documentImageIds, isAuthorableTemplateType, placedVideoIds } from "../typed-pages.js";
53
44
 
54
45
  const MAXIMUM_REPORTED = 200;
55
46
  const FIXTURE_ROOT_KEYS = new Set(FIXTURE_ROOT_FIELDS);
@@ -436,10 +427,8 @@ function headerWidthHints(headerWidth, rootBandPages) {
436
427
  * The fixture's forms, when it carries any. The manifest binding and the files
437
428
  * must name the same keys: a form file the manifest does not mention would be
438
429
  * 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
430
  */
441
- async function validateFixtureForms(fixtureRoot, bound, onProgress) {
442
- const files = await readWorkspaceForms(fixtureRoot);
431
+ function requireFixtureFormsBound(bound, files) {
443
432
  if (bound === undefined) {
444
433
  if (files.length > 0) {
445
434
  fail(
@@ -448,7 +437,7 @@ async function validateFixtureForms(fixtureRoot, bound, onProgress) {
448
437
  "forms",
449
438
  );
450
439
  }
451
- return undefined;
440
+ return;
452
441
  }
453
442
  const items = isPlainObject(bound) && isPlainObject(bound.items) ? bound.items : undefined;
454
443
  const keys = items === undefined ? [] : Object.keys(items).sort();
@@ -479,163 +468,95 @@ async function validateFixtureForms(fixtureRoot, bound, onProgress) {
479
468
  "forms",
480
469
  );
481
470
  }
482
- onProgress(`Validated ${files.length} form file${files.length === 1 ? "" : "s"} against the shared field schema.`);
483
- return files;
484
471
  }
485
472
 
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
-
473
+ /** Every editable page source on disk, each one bound by the fixture manifest. */
474
+ async function fixturePageFileProblems(fixtureRoot, declaredFiles) {
475
+ const problems = [];
540
476
  const pageFiles = await walkWorkspaceFiles(fixtureRoot, PAGES_DIRECTORY, PAGE_SOURCE_EXTENSIONS);
541
- const manifestByFile = new Map(manifest.pages.map((entry) => [entry.file, entry]));
542
477
  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
- }
478
+ if (pageFiles.includes(file)) continue;
479
+ await collectProblems(problems, { area: CHECK_AREA.pages, file }, () =>
480
+ fail("fixture.page_missing", `The fixture manifest binds missing page source '${file}'.`, file));
481
+ }
482
+ for (const file of pageFiles.filter((candidate) => !declaredFiles.has(candidate))) {
483
+ await collectProblems(problems, { area: CHECK_AREA.pages, file }, () =>
484
+ fail("fixture.page_untracked", `Page source '${file}' is not bound by ${FIXTURE_MANIFEST_FILE_NAME}.`, file));
546
485
  }
547
- const untracked = pageFiles.find((file) => !declaredFiles.has(file));
548
- if (untracked !== undefined) {
486
+ return problems;
487
+ }
488
+
489
+ /**
490
+ * What only a fixture promises about a page that passed the shared checks:
491
+ * its source declares the path its manifest entry binds, and every image and
492
+ * video it places is one the fixture declares.
493
+ */
494
+ function requireFixturePageBound(page, entry, { imageIds, videoIds, deliveryOrigins }) {
495
+ const { file, document: document_ } = page;
496
+ if (page.pagePath !== normalizePagePath(entry.path)) {
549
497
  fail(
550
- "fixture.page_untracked",
551
- `Page source '${untracked}' is not bound by ${FIXTURE_MANIFEST_FILE_NAME}.`,
552
- untracked,
498
+ "fixture.page_path_mismatch",
499
+ `'${file}' declares path '${page.pagePath}' but its fixture manifest entry binds '${entry.path}'.`,
500
+ file,
553
501
  );
554
502
  }
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,
503
+ validatePageImageReferences(document_, file, imageIds, deliveryOrigins);
504
+ // Album images and the cover image are bare ids with no delivery URL, so
505
+ // the walk above cannot see them.
506
+ const unknownImage = documentImageIds(document_).find((imageId) => !imageIds.has(imageId));
507
+ if (unknownImage !== undefined) {
508
+ fail(
509
+ "fixture.image_reference_unknown",
510
+ `Fixture image '${unknownImage}' is not declared in fixture.imageIds.`,
562
511
  file,
563
- manifestEntry: entry,
564
- mediaManifest: { media: {} },
565
- content: invocation.content,
566
- });
567
- await validateWorkspacePageDocument({
568
- workspaceDir: fixtureRoot,
569
- siteId: manifest.siteId,
512
+ );
513
+ }
514
+ const unknownVideo = [...placedVideoIds(document_)].find((videoId) => !videoIds.has(videoId));
515
+ if (unknownVideo !== undefined) {
516
+ fail(
517
+ "fixture.video_reference_unknown",
518
+ `Fixture video '${unknownVideo}' is not declared in fixture.videoIds, so it is not a ready video of this site.`,
570
519
  file,
571
- document: page.document,
572
- content: invocation.content,
573
- getSharedThemeContexts: async () => sharedContexts,
520
+ );
521
+ }
522
+ }
523
+
524
+ /** The fixture manifest's own records of the presentation, navigation, and redirects it binds. */
525
+ function requireFixtureRecordsMatch(manifest, checked) {
526
+ const { presentation, navigation, redirects } = checked;
527
+ if (presentation !== undefined) {
528
+ const expectedAppearance = appearanceManifestEntry({
529
+ SETTING_TYPE_TAPROOT_STYLES: presentation.style,
530
+ SETTING_TYPE_BRAND: presentation.brand,
531
+ SETTING_TYPE_SITE_HEADER: presentation.header,
574
532
  });
575
- if (page.pagePath !== normalizePagePath(entry.path)) {
576
- fail(
577
- "fixture.page_path_mismatch",
578
- `'${file}' declares path '${page.pagePath}' but its fixture manifest entry binds '${entry.path}'.`,
579
- file,
580
- );
581
- }
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) {
533
+ if (manifest.appearance !== undefined && JSON.stringify(manifest.appearance) !== JSON.stringify(expectedAppearance)) {
597
534
  fail(
598
- "fixture.image_reference_unknown",
599
- `Fixture image '${unknownImage}' is not declared in fixture.imageIds.`,
600
- file,
535
+ "fixture.appearance_invalid",
536
+ "The fixture appearance metadata must match its settings image identities.",
537
+ "appearance",
601
538
  );
602
539
  }
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
- );
540
+ if (manifest.footer !== undefined) {
541
+ const expectedFooter = footerManifestEntry(presentation.publishing.footerSettings);
542
+ if (
543
+ !isPlainObject(manifest.footer) || Object.keys(manifest.footer).length !== Object.keys(expectedFooter).length
544
+ || Object.entries(expectedFooter).some(([key, value]) =>
545
+ JSON.stringify(manifest.footer[key]) !== JSON.stringify(value)
546
+ )
547
+ ) {
548
+ fail("fixture.footer_invalid", "The fixture footer metadata must match its footer document.", "footer");
549
+ }
610
550
  }
611
- validatedPages.push({ file, path: page.pagePath });
612
- if (pageUsesRootBand(page.document)) rootBandPages.push(file);
613
551
  }
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) {
552
+ if (navigation !== undefined && navigation.items !== manifest.navigation.items) {
624
553
  fail(
625
554
  "fixture.navigation_count_mismatch",
626
555
  `${NAVIGATION_FILE_NAME} contains ${navigation.items} item(s), but the fixture manifest records ${manifest.navigation.items}.`,
627
556
  "navigation.items",
628
557
  );
629
558
  }
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) {
559
+ if (redirects !== undefined && redirects.entries.length !== manifest.redirects.entries) {
639
560
  fail(
640
561
  "fixture.redirects_count_mismatch",
641
562
  `${REDIRECTS_FILE_NAME} contains ${redirects.entries.length} entr${
@@ -644,51 +565,144 @@ export async function validateFixture(invocation = {}) {
644
565
  "redirects.entries",
645
566
  );
646
567
  }
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
- }
568
+ }
569
+
570
+ function reportThemeWarnings(presentation, onProgress) {
571
+ if (presentation === undefined) return;
572
+ for (const warning of presentation.themes.warnings) onProgress(`Espalier warning: ${warning}`);
573
+ if (presentation.themes.warningsTruncated) {
574
+ const hidden = presentation.themes.warningCount - presentation.themes.warnings.length;
575
+ onProgress(`${hidden} more Espalier warning(s) not shown; resolve the ones above and run validate again to see them.`);
664
576
  }
665
- for (const entry of redirects.entries) {
666
- const key = entry.path.toLowerCase();
667
- if (fixturePagePaths.has(key)) {
577
+ }
578
+
579
+ /** The part of a success result both kinds of workspace share. */
580
+ function validatedSummary(checked, pages) {
581
+ const { presentation, navigation, redirects, forms } = checked;
582
+ const reportedPages = boundedList(pages, MAXIMUM_REPORTED);
583
+ return {
584
+ validated: {
585
+ ...(checked.settingsOnly ? {} : {
586
+ pages: {
587
+ total: pages.length,
588
+ items: reportedPages.items,
589
+ ...(reportedPages.truncated ? { itemsTruncated: true } : {}),
590
+ },
591
+ navigation: { items: navigation.items, maximumDepth: NAVIGATION_MAXIMUM_DEPTH },
592
+ ...(redirects === undefined ? {} : {
593
+ redirects: {
594
+ entries: redirects.entries.length,
595
+ gone: redirects.entries.filter((entry) => entry.kind === REDIRECT_KIND_GONE).length,
596
+ },
597
+ }),
598
+ ...(forms === undefined || forms.length === 0 ? {} : { forms: { count: forms.length } }),
599
+ }),
600
+ themes: 2,
601
+ appearanceSettings: presentation.scalarOperations.length,
602
+ footer: true,
603
+ },
604
+ warnings: {
605
+ items: presentation.themes.warnings,
606
+ count: presentation.themes.warningCount,
607
+ ...(presentation.themes.warningsTruncated ? { truncated: true } : {}),
608
+ },
609
+ };
610
+ }
611
+
612
+ function pageHints(checked, onProgress) {
613
+ if (checked.settingsOnly || checked.presentation === undefined) return [];
614
+ const rootBandPages = checked.pages.planned.filter((page) => pageUsesRootBand(page.document)).map((page) => page.file);
615
+ const hints = headerWidthHints(checked.presentation.header.headerWidth, rootBandPages);
616
+ for (const hint of hints) onProgress(`Hint: ${hint.message}`);
617
+ return hints;
618
+ }
619
+
620
+ export async function validateFixture(invocation = {}) {
621
+ if (invocation.init) return await initializeFixture(invocation, validateFixture);
622
+ const onProgress = typeof invocation.onProgress === "function" ? invocation.onProgress : () => {};
623
+ const cwd = invocation.cwd ?? process.cwd();
624
+ if (invocation.fixturePath === undefined) {
625
+ const config = await loadSiteConfig({ cwd, configPath: invocation.configPath });
626
+ if (!isCanonicalUuid(config.siteId)) {
668
627
  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`,
628
+ "config.site_missing",
629
+ "No site is selected, so the workspace's identity is unknown. Run 'taproot-site use <site>' first.",
630
+ "siteId",
672
631
  );
673
632
  }
633
+ return await validatePulledWorkspace(config.workspaceDir, config.siteId, invocation, onProgress);
634
+ }
635
+ const fixtureRoot = await resolveFixtureRoot(cwd, invocation.fixturePath);
636
+ if (
637
+ !await workspaceFileExists(fixtureRoot, FIXTURE_MANIFEST_FILE_NAME)
638
+ && await workspaceFileExists(fixtureRoot, MANIFEST_FILE_NAME)
639
+ ) {
640
+ const { siteId } = await readWorkspaceJson(fixtureRoot, MANIFEST_FILE_NAME, WORKSPACE_LIMITS.manifestBytes);
641
+ if (!isCanonicalUuid(siteId)) {
642
+ fail("workspace.manifest_invalid", `${MANIFEST_FILE_NAME} does not record a canonical site id.`, "siteId");
643
+ }
644
+ return await validatePulledWorkspace(fixtureRoot, siteId, invocation, onProgress);
674
645
  }
675
646
 
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
- },
647
+ onProgress(`Reading ${FIXTURE_MANIFEST_FILE_NAME}.`);
648
+ const contract = validateFixtureManifest(
649
+ await readWorkspaceJson(fixtureRoot, FIXTURE_MANIFEST_FILE_NAME, WORKSPACE_LIMITS.manifestBytes),
685
650
  );
651
+ const { manifest, imageIds, videoIds, deliveryOrigins, pageIds, resourceIds, files: declaredFiles } = contract;
652
+
653
+ const problems = await fixturePageFileProblems(fixtureRoot, declaredFiles);
654
+ // The fixture's own media manifest, as in a real workspace, so Markdown media
655
+ // paths resolve; none means no media. One that cannot be read is a fixture
656
+ // problem, and its pages then resolve no media paths.
657
+ const mediaManifest = await collectProblems(
658
+ problems,
659
+ { area: "fixture", file: MEDIA_MANIFEST_FILE_NAME },
660
+ async () => {
661
+ try {
662
+ return await readMediaManifest(fixtureRoot, manifest.siteId);
663
+ } catch (error) {
664
+ if (error?.code !== "workspace.manifest_site_mismatch") throw error;
665
+ throw new SiteAuthoringError(
666
+ "fixture.media_manifest_site",
667
+ `The fixture's ${MEDIA_MANIFEST_FILE_NAME} must record the fixture's siteId, ${manifest.siteId}.`,
668
+ { field: "siteId" },
669
+ );
670
+ }
671
+ },
672
+ ) ?? { media: {} };
673
+ const checked = await checkWorkspace({
674
+ workspaceDir: fixtureRoot,
675
+ siteId: manifest.siteId,
676
+ manifest,
677
+ mediaManifest,
678
+ binding: { knownImageIds: imageIds, settingsEntityIds: contract.settingsEntityIds, pageIds, resourceIds },
679
+ content: invocation.content,
680
+ onProgress,
681
+ });
682
+ problems.push(...checked.problems);
683
+ reportThemeWarnings(checked.presentation, onProgress);
686
684
 
687
- const forms = await validateFixtureForms(fixtureRoot, manifest.forms, onProgress);
685
+ const manifestByFile = new Map(manifest.pages.map((entry) => [entry.file, entry]));
686
+ const validatedPages = [];
687
+ for (const page of checked.pages?.planned ?? []) {
688
+ // A source the manifest does not bind is already reported as untracked.
689
+ if (!manifestByFile.has(page.file)) continue;
690
+ const bound = await collectProblems(problems, { area: CHECK_AREA.pages, file: page.file }, () => {
691
+ requireFixturePageBound(page, manifestByFile.get(page.file), { imageIds, videoIds, deliveryOrigins });
692
+ return true;
693
+ });
694
+ if (bound) validatedPages.push({ file: page.file, path: page.pagePath });
695
+ }
696
+ await collectProblems(problems, { area: "fixture", file: FIXTURE_MANIFEST_FILE_NAME }, () => {
697
+ requireFixtureRecordsMatch(manifest, checked);
698
+ // A forms folder that failed to read is already its own problem.
699
+ if (checked.forms !== undefined) requireFixtureFormsBound(manifest.forms, checked.forms);
700
+ });
701
+ refuseProblems(problems, "validate found problems in the fixture");
688
702
 
689
- const reportedPages = boundedList(validatedPages, MAXIMUM_REPORTED);
703
+ const hints = pageHints(checked, onProgress);
690
704
  onProgress(
691
- `Validated ${validatedPages.length} page(s), ${navigation.items} navigation item(s), two themes, appearance, and footer without credentials or mutation.`,
705
+ `Validated ${validatedPages.length} page(s), ${checked.navigation.items} navigation item(s), two themes, appearance, and footer without credentials or mutation.`,
692
706
  );
693
707
  return successResult(VERB_VALIDATE, manifest.siteId, {
694
708
  offline: true,
@@ -699,27 +713,7 @@ export async function validateFixture(invocation = {}) {
699
713
  videoIds: videoIds.size,
700
714
  deliveryOrigins: deliveryOrigins.size,
701
715
  },
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
- },
716
+ ...validatedSummary(checked, validatedPages),
723
717
  hints,
724
718
  proves: [
725
719
  "fixture structure and bounded files",
@@ -739,3 +733,56 @@ export async function validateFixture(invocation = {}) {
739
733
  nextStep: "Run a real pull and authorized preview before approval or deployment.",
740
734
  });
741
735
  }
736
+
737
+ /**
738
+ * `validate` over a workspace `pull` wrote (TR01002). The same checks a push
739
+ * runs before it sends anything, for every page and setting at once, against
740
+ * what the manifests recorded at the pull — no credential and no request.
741
+ */
742
+ async function validatePulledWorkspace(workspaceDir, siteId, invocation, onProgress) {
743
+ onProgress(`Reading ${MANIFEST_FILE_NAME}.`);
744
+ const manifest = await readManifest(workspaceDir, siteId);
745
+ const mediaManifest = await readMediaManifest(workspaceDir, siteId);
746
+ const checked = await checkWorkspace({
747
+ workspaceDir,
748
+ siteId,
749
+ manifest,
750
+ mediaManifest,
751
+ content: invocation.content,
752
+ onProgress,
753
+ });
754
+ reportThemeWarnings(checked.presentation, onProgress);
755
+ refuseProblems(checked.problems, "validate found problems in the workspace");
756
+
757
+ const pages = checked.settingsOnly
758
+ ? []
759
+ : checked.pages.planned.map((page) => ({ file: page.file, path: page.pagePath }));
760
+ const hints = pageHints(checked, onProgress);
761
+ onProgress(
762
+ checked.settingsOnly
763
+ ? "Validated the workspace's two themes, appearance, and footer without credentials or mutation."
764
+ : `Validated ${pages.length} page(s), ${checked.navigation.items} navigation item(s), two themes, appearance, and `
765
+ + "footer without credentials or mutation.",
766
+ );
767
+ return successResult(VERB_VALIDATE, siteId, {
768
+ offline: true,
769
+ workspace: { manifest: MANIFEST_FILE_NAME, settingsOnly: checked.settingsOnly },
770
+ ...validatedSummary(checked, pages),
771
+ hints,
772
+ proves: [
773
+ "every page source: metadata, Markdown conversion, media references, content vocabulary, and section contexts",
774
+ "page paths, templates, and system pages against the pages the pull recorded",
775
+ "navigation shape and page references the pull recorded",
776
+ "redirect-map shape, normalization, chains, loops, and workspace page paths",
777
+ "complete theme, appearance, header, brand, and footer semantics",
778
+ "form files against the shared field schema",
779
+ ],
780
+ doesNotProve: [
781
+ "credential authorization or live site ownership",
782
+ "pages, revisions, or videos that changed on the site since the pull",
783
+ "server normalization and pull round trips",
784
+ "preview or published rendering",
785
+ ],
786
+ nextStep: "Run 'taproot-site plan' to check the workspace against the live site and see the ordered apply.",
787
+ });
788
+ }
@@ -62,19 +62,10 @@ export async function prepareVideoFile({ filePath, byteLength, maxRemuxBytes = D
62
62
  const input = new Input({ source: new FilePathSource(filePath), formats: ALL_FORMATS });
63
63
  try {
64
64
  const format = await input.getFormat();
65
- const video = await input.getPrimaryVideoTrack();
66
- const audio = await input.getPrimaryAudioTrack();
67
- // The server refuses a file if any track is not H.264 or AAC, so the first track that is
68
- // not is the one declared and named. A track whose codec mediabunny cannot name is
69
- // "unknown", not absent: an absent audio track is fine, a silent copy of one that exists is not.
70
- const videoCodec = await declaredCodec(await input.getVideoTracks(), isH264);
71
- const audioCodec = await declaredCodec(await input.getAudioTracks(), isAac);
65
+ const tracks = await inspectTracks(input);
66
+ const { video, audio, videoCodec, audioCodec, primaryVideo, primaryAudio } = tracks;
72
67
  const described = { videoCodec, audioCodec, remuxed: null };
73
-
74
- // A remux keeps only the primary tracks, so they are what must be accepted for it to help.
75
- const primaryVideo = video ? await codecOf(video) : "";
76
- const primaryAudio = audio ? await codecOf(audio) : "";
77
- if (!isH264(primaryVideo) || !isAac(primaryAudio)) return described;
68
+ if (!primariesAccepted(tracks)) return described;
78
69
 
79
70
  const everyTrackAccepted = isH264(videoCodec) && isAac(audioCodec);
80
71
  if (everyTrackAccepted && format.mimeType === "video/mp4" && await indexComesFirst(filePath)) return described;
@@ -97,6 +88,51 @@ export async function prepareVideoFile({ filePath, byteLength, maxRemuxBytes = D
97
88
  }
98
89
  }
99
90
 
91
+ /**
92
+ * Whether the upload would send this file in a form the server accepts (TR00823): H.264
93
+ * and AAC as it is, or primaries a remux can keep within the ceiling. Read only; `plan`
94
+ * uses it so an unsupported video blocks before anything is uploaded. A remux that fails
95
+ * at upload time is still refused then.
96
+ */
97
+ export async function videoAcceptance({ filePath, byteLength, maxRemuxBytes = DEFAULT_MAX_REMUX_BYTES }) {
98
+ const input = new Input({ source: new FilePathSource(filePath), formats: ALL_FORMATS });
99
+ try {
100
+ const tracks = await inspectTracks(input);
101
+ const { videoCodec, audioCodec } = tracks;
102
+ const asItIs = isH264(videoCodec) && isAac(audioCodec);
103
+ const remuxable = primariesAccepted(tracks) && byteLength <= maxRemuxBytes;
104
+ return { accepted: asItIs || remuxable, videoCodec, audioCodec };
105
+ } catch {
106
+ return { accepted: false, videoCodec: "", audioCodec: "" };
107
+ } finally {
108
+ input.dispose();
109
+ }
110
+ }
111
+
112
+ /**
113
+ * The server refuses a file if any track is not H.264 or AAC, so the first track that is
114
+ * not is the one declared and named. A track whose codec mediabunny cannot name is
115
+ * "unknown", not absent: an absent audio track is fine, a silent copy of one that exists is
116
+ * not. A remux keeps only the primary tracks, so they are what must be accepted for it to help.
117
+ */
118
+ async function inspectTracks(input) {
119
+ const video = await input.getPrimaryVideoTrack();
120
+ const audio = await input.getPrimaryAudioTrack();
121
+ return {
122
+ video,
123
+ audio,
124
+ videoCodec: await declaredCodec(await input.getVideoTracks(), isH264),
125
+ audioCodec: await declaredCodec(await input.getAudioTracks(), isAac),
126
+ primaryVideo: video ? await codecOf(video) : "",
127
+ primaryAudio: audio ? await codecOf(audio) : "",
128
+ };
129
+ }
130
+
131
+ /** Whether a remux, which keeps only the primary tracks, would produce a file the server accepts. */
132
+ function primariesAccepted({ primaryVideo, primaryAudio }) {
133
+ return isH264(primaryVideo) && isAac(primaryAudio);
134
+ }
135
+
100
136
  async function codecOf(track) {
101
137
  return (await track.getCodecParameterString()) ?? "unknown";
102
138
  }