@taprootio/site-authoring 0.14.0 → 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.
- package/README.md +62 -10
- package/examples/riverbend-wellness/.taproot-site-media.json +23 -0
- package/examples/riverbend-wellness/pages/index.md +1 -17
- package/package.json +2 -2
- package/src/api.js +59 -1
- package/src/cli.js +147 -22
- package/src/constants.js +6 -0
- package/src/content/components.js +64 -6
- package/src/content/free-form-sections.js +21 -4
- package/src/content/markdown.js +161 -26
- package/src/content/tiptap-prosemirror.ts +66 -8
- package/src/content/validate-document.js +30 -6
- package/src/content/vocabulary.js +26 -0
- package/src/errors.js +22 -0
- package/src/espalier-shared.js +11 -0
- package/src/font-catalog.js +87 -0
- package/src/footer-contract.js +1 -1
- package/src/output.js +19 -1
- package/src/presentation-reference.js +21 -7
- package/src/problems.js +116 -0
- package/src/reference-help.js +100 -34
- package/src/session.js +13 -0
- package/src/theme-proposals.js +393 -0
- package/src/theme-validation.js +31 -10
- package/src/typed-page-reference.js +11 -3
- package/src/typed-pages.js +31 -9
- package/src/verbs/deploy.js +1 -0
- package/src/verbs/index.js +13 -0
- package/src/verbs/media-upload.js +127 -30
- package/src/verbs/nav-push.js +7 -0
- package/src/verbs/pages-push.js +406 -199
- package/src/verbs/places.js +61 -0
- package/src/verbs/plan.js +585 -0
- package/src/verbs/theme-push.js +68 -36
- package/src/verbs/validate.js +264 -217
- package/src/video-prepare.js +48 -12
- package/src/workspace-check.js +199 -0
- package/src/workspace-ledger.js +173 -0
- package/src/workspace.js +47 -4
package/src/verbs/validate.js
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
487
|
-
|
|
488
|
-
const
|
|
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 (
|
|
544
|
-
|
|
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
|
-
|
|
548
|
-
|
|
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.
|
|
551
|
-
`
|
|
552
|
-
|
|
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
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
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
|
-
|
|
572
|
-
|
|
573
|
-
|
|
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 (
|
|
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.
|
|
599
|
-
|
|
600
|
-
|
|
535
|
+
"fixture.appearance_invalid",
|
|
536
|
+
"The fixture appearance metadata must match its settings image identities.",
|
|
537
|
+
"appearance",
|
|
601
538
|
);
|
|
602
539
|
}
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
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
|
-
|
|
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
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
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
|
-
|
|
666
|
-
|
|
667
|
-
|
|
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
|
-
"
|
|
670
|
-
|
|
671
|
-
|
|
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(
|
|
677
|
-
|
|
678
|
-
await readWorkspaceJson(fixtureRoot,
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
package/src/video-prepare.js
CHANGED
|
@@ -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
|
|
66
|
-
const audio =
|
|
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
|
}
|