@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.
- package/README.md +181 -16
- 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 +123 -1
- package/src/authors-contract.js +170 -0
- package/src/cli.js +355 -30
- package/src/constants.js +9 -0
- package/src/content/components.js +64 -6
- package/src/content/free-form-sections.js +21 -4
- package/src/content/link-warnings.js +109 -0
- 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/fixture-init.js +55 -3
- package/src/font-catalog.js +87 -0
- package/src/footer-contract.js +1 -1
- package/src/output.js +19 -1
- package/src/page-metadata.js +92 -0
- package/src/presentation-reference.js +21 -7
- package/src/problems.js +116 -0
- package/src/reference-help.js +157 -41
- package/src/session.js +13 -0
- package/src/staging-check.js +62 -0
- package/src/theme-proposals.js +393 -0
- package/src/theme-validation.js +31 -10
- package/src/typed-page-reference.js +97 -21
- package/src/typed-pages.js +196 -13
- package/src/verbs/approve.js +63 -14
- package/src/verbs/authors.js +167 -0
- package/src/verbs/deploy.js +5 -28
- package/src/verbs/index.js +22 -0
- package/src/verbs/media-upload.js +127 -30
- package/src/verbs/nav-push.js +7 -0
- package/src/verbs/pages-meta-set.js +303 -0
- package/src/verbs/pages-push.js +814 -224
- package/src/verbs/places.js +61 -0
- package/src/verbs/plan.js +595 -0
- package/src/verbs/pull.js +255 -20
- package/src/verbs/redirects-check.js +4 -10
- package/src/verbs/theme-push.js +68 -36
- package/src/verbs/validate.js +309 -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 +105 -10
package/src/verbs/validate.js
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
-
|
|
22
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
544
|
-
|
|
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
|
|
548
|
-
|
|
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.
|
|
551
|
-
`
|
|
552
|
-
|
|
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
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
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
|
-
|
|
572
|
-
|
|
573
|
-
|
|
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 (
|
|
564
|
+
if (manifest.appearance !== undefined && JSON.stringify(manifest.appearance) !== JSON.stringify(expectedAppearance)) {
|
|
576
565
|
fail(
|
|
577
|
-
"fixture.
|
|
578
|
-
|
|
579
|
-
|
|
566
|
+
"fixture.appearance_invalid",
|
|
567
|
+
"The fixture appearance metadata must match its settings image identities.",
|
|
568
|
+
"appearance",
|
|
580
569
|
);
|
|
581
570
|
}
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
666
|
-
|
|
667
|
-
|
|
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
|
-
"
|
|
670
|
-
|
|
671
|
-
|
|
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(
|
|
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
|
-
},
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|