@taprootio/site-authoring 0.27.1 → 0.29.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 +11 -12
- package/examples/riverbend-wellness/settings/site-publishing-preferences.json +8 -0
- package/package.json +1 -1
- package/src/changelog.json +37 -0
- package/src/cli.js +3 -3
- package/src/content/components.js +386 -20
- package/src/content/free-form-section-registry.json +37 -1
- package/src/footer-contract.js +59 -0
- package/src/footer-draft-hash.js +14 -1
- package/src/presentation-reference.js +23 -1
- package/src/reference-help.js +13 -6
- package/src/site-checks.js +25 -2
- package/src/social-link-url.js +160 -0
- package/src/social-platforms.json +16 -0
- package/src/typed-page-reference.js +7 -14
- package/src/typed-pages.js +11 -29
- package/src/verbs/pages-push.js +34 -11
- package/src/verbs/places.js +2 -2
- package/src/verbs/pull.js +1 -1
|
@@ -4,7 +4,10 @@
|
|
|
4
4
|
// copy — the generator's Playwright Node process, for one — cannot load this
|
|
5
5
|
// pipeline at all once a component module is in the graph.
|
|
6
6
|
import { BUILT_IN_IMAGE_TEXTURES } from "@taprootio/espalier/shared/image-texture-registry";
|
|
7
|
+
import registry from "./free-form-section-registry.json" with { type: "json" };
|
|
7
8
|
import { hasAsciiControl } from "../errors.js";
|
|
9
|
+
import { isSocialLinkUrl, SOCIAL_LINK_URL_MAX_LENGTH, trimSocialLinkInput } from "../social-link-url.js";
|
|
10
|
+
import socialPlatforms from "../social-platforms.json" with { type: "json" };
|
|
8
11
|
import {
|
|
9
12
|
CONTENT_ERROR_CODES as CODES,
|
|
10
13
|
CONTENT_LIMITS,
|
|
@@ -61,14 +64,28 @@ const boundedStr = (maximumLength, options = {}) => str({ maximumLength, ...opti
|
|
|
61
64
|
const bool = () => Object.freeze({ kind: "boolean" });
|
|
62
65
|
const num = (options = {}) => Object.freeze({ kind: "number", ...options });
|
|
63
66
|
const enumOf = (...values) => Object.freeze({ kind: "enum", values: Object.freeze(values) });
|
|
64
|
-
|
|
67
|
+
/** An array of `item`; `minItems`/`maxItems` bound it below the global cap (TR01322). */
|
|
68
|
+
const list = (item, options = {}) => Object.freeze({ kind: "array", item, ...options });
|
|
65
69
|
const shape = (fields, options = {}) => Object.freeze({ kind: "object", fields: Object.freeze(fields), ...options });
|
|
66
|
-
|
|
67
|
-
|
|
70
|
+
/**
|
|
71
|
+
* A processed-image record or null. `required` refuses null, `altRequired`
|
|
72
|
+
* refuses a blank alt and `dimensionsRequired` demands positive width and
|
|
73
|
+
* height, for a component whose images are the content (TR01322).
|
|
74
|
+
*/
|
|
75
|
+
const image = (options = {}) => Object.freeze({ kind: "image", ...options });
|
|
76
|
+
/**
|
|
77
|
+
* A safe URL; `https: true` is the social-link rule (absolute https to a plain
|
|
78
|
+
* domain, TR01324), which also makes the field required and refuses an empty
|
|
79
|
+
* value, so every reader of `required` agrees.
|
|
80
|
+
*/
|
|
81
|
+
const safeUrl = (options = {}) =>
|
|
82
|
+
Object.freeze({ kind: "safe-url", ...(options.https ? { required: true, allowEmpty: false } : {}), ...options });
|
|
68
83
|
/** A CSS length the published renderer accepts as a per-block override; `allowNone` adds `none` (a max height). */
|
|
69
84
|
const cssLength = (options = {}) => Object.freeze({ kind: "css-length", ...options });
|
|
70
85
|
const textureName = () => Object.freeze({ kind: "texture-name", maximumLength: 64 });
|
|
71
86
|
const withOmission = (spec, whenOmitted) => Object.freeze({ ...spec, whenOmitted: Object.freeze(whenOmitted) });
|
|
87
|
+
/** The same spec with a description `help component` prints beside its constraints. */
|
|
88
|
+
const described = (spec, description) => Object.freeze({ ...spec, description });
|
|
72
89
|
|
|
73
90
|
/**
|
|
74
91
|
* `ComponentImageData`. `src` and `urls` are the only required-by-key fields
|
|
@@ -362,12 +379,143 @@ const FORM = Object.freeze({
|
|
|
362
379
|
formKey: str({ required: true, nonWhitespace: true }),
|
|
363
380
|
});
|
|
364
381
|
|
|
382
|
+
/**
|
|
383
|
+
* The image field table an image spec validates against. The shared table
|
|
384
|
+
* leaves alt and dimensions optional; a spec that requires them swaps in
|
|
385
|
+
* stricter entries at the same positions so canonical order is unchanged.
|
|
386
|
+
*/
|
|
387
|
+
function imageFieldsFor(spec) {
|
|
388
|
+
if (!spec.altRequired && !spec.dimensionsRequired) return COMPONENT_IMAGE_FIELDS;
|
|
389
|
+
return Object.freeze({
|
|
390
|
+
...COMPONENT_IMAGE_FIELDS,
|
|
391
|
+
...(spec.dimensionsRequired
|
|
392
|
+
? {
|
|
393
|
+
width: num({ integer: true, minimum: 1, required: true }),
|
|
394
|
+
height: num({ integer: true, minimum: 1, required: true }),
|
|
395
|
+
}
|
|
396
|
+
: {}),
|
|
397
|
+
...(spec.altRequired
|
|
398
|
+
? {
|
|
399
|
+
alt: str({
|
|
400
|
+
required: true,
|
|
401
|
+
nonWhitespace: true,
|
|
402
|
+
// What the author can act on: in Markdown the alt is the media manifest's, not the fence's.
|
|
403
|
+
blankDetail: "alt text that names the picture or its destination; in Markdown the alt comes from the "
|
|
404
|
+
+ "media manifest entry the image path names",
|
|
405
|
+
}),
|
|
406
|
+
}
|
|
407
|
+
: {}),
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** The registry owns the row heights; the help text and the renderer derive from the same map. */
|
|
412
|
+
const IMAGE_ROW_HEIGHTS = registry.components["image-row"].heights;
|
|
413
|
+
const IMAGE_ROW_HEIGHT_HELP = `Each image's height: ${
|
|
414
|
+
Object.entries(IMAGE_ROW_HEIGHTS)
|
|
415
|
+
.map(([name, rem]) => `${name} ${rem} (${Number.parseFloat(rem) * 16}px)`)
|
|
416
|
+
.join(", ")
|
|
417
|
+
} at a 16px root; width follows each image's own shape, never cropped or framed.`;
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* `ImageRowData` (TR01322). A row of pictures carries no other text, so every
|
|
421
|
+
* image needs alt text and the dimensions the row sizes it by.
|
|
422
|
+
*/
|
|
423
|
+
const IMAGE_ROW = Object.freeze({
|
|
424
|
+
items: list(
|
|
425
|
+
shape({
|
|
426
|
+
image: image({ required: true, altRequired: true, dimensionsRequired: true }),
|
|
427
|
+
url: safeUrl(),
|
|
428
|
+
}),
|
|
429
|
+
{ minItems: 1, maxItems: registry.components["image-row"].maxItems, required: true },
|
|
430
|
+
),
|
|
431
|
+
height: described(enumOf(...Object.keys(IMAGE_ROW_HEIGHTS)), IMAGE_ROW_HEIGHT_HELP),
|
|
432
|
+
align: described(
|
|
433
|
+
enumOf("start", "center", "end"),
|
|
434
|
+
"Where the images sit when they do not fill the row: start is the left edge and end the right edge in a "
|
|
435
|
+
+ "left-to-right page.",
|
|
436
|
+
),
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* `ProductGridData` (TR01323). The registry owns the text bounds, the item cap
|
|
441
|
+
* and the crop shape; the generator's readiness check and the editor read the
|
|
442
|
+
* same entry, so no side can drift. Blank optional text counts as absent on
|
|
443
|
+
* every side; the cross-field hook below decides the button pair and the
|
|
444
|
+
* price note.
|
|
445
|
+
*/
|
|
446
|
+
const PRODUCT_GRID_ENTRY = registry.components["product-grid"];
|
|
447
|
+
const PRODUCT_GRID_LIMITS = PRODUCT_GRID_ENTRY.limits;
|
|
448
|
+
const PRODUCT_GRID_BUTTON_TEXT_NOTE = "Keep it short; long text is cut off in narrow columns.";
|
|
449
|
+
const PRODUCT_GRID = Object.freeze({
|
|
450
|
+
items: list(
|
|
451
|
+
shape({
|
|
452
|
+
image: image(),
|
|
453
|
+
title: boundedStr(PRODUCT_GRID_LIMITS.title, { required: true, nonWhitespace: true }),
|
|
454
|
+
price: described(boundedStr(PRODUCT_GRID_LIMITS.price), "Free text, such as \"$145\", \"From $30\" or \"Free\"."),
|
|
455
|
+
priceNote: described(boundedStr(PRODUCT_GRID_LIMITS.priceNote), "Shown beside the price, such as \"per month\"; refused without a price."),
|
|
456
|
+
badge: boundedStr(PRODUCT_GRID_LIMITS.badge),
|
|
457
|
+
description: boundedStr(PRODUCT_GRID_LIMITS.description),
|
|
458
|
+
features: list(
|
|
459
|
+
described(boundedStr(PRODUCT_GRID_LIMITS.feature, { nonWhitespace: true }), "One line each; a line break inside a feature is refused."),
|
|
460
|
+
{ maxItems: PRODUCT_GRID_LIMITS.features },
|
|
461
|
+
),
|
|
462
|
+
buttonText: described(
|
|
463
|
+
boundedStr(PRODUCT_GRID_LIMITS.buttonText),
|
|
464
|
+
`${PRODUCT_GRID_BUTTON_TEXT_NOTE} Needs a buttonUrl, and a buttonUrl needs it.`,
|
|
465
|
+
),
|
|
466
|
+
buttonUrl: safeUrl(),
|
|
467
|
+
highlighted: bool(),
|
|
468
|
+
cropState: CROP_STATE,
|
|
469
|
+
}),
|
|
470
|
+
{ minItems: 1, maxItems: PRODUCT_GRID_ENTRY.maxItems, required: true },
|
|
471
|
+
),
|
|
472
|
+
columns: num({ values: Object.freeze([2, 3, 4]) }),
|
|
473
|
+
density: described(enumOf("standard", "compact"), "compact tightens the card padding and gaps; nothing is hidden."),
|
|
474
|
+
borderWidth: num({
|
|
475
|
+
integer: true,
|
|
476
|
+
minimum: 0,
|
|
477
|
+
description: "Pixel border around each card; 0 boxes nothing. A highlighted card always draws at least 2px.",
|
|
478
|
+
}),
|
|
479
|
+
});
|
|
480
|
+
|
|
365
481
|
function deepFreeze(value) {
|
|
366
482
|
if (value === null || typeof value !== "object" || Object.isFrozen(value)) return value;
|
|
367
483
|
for (const child of Object.values(value)) deepFreeze(child);
|
|
368
484
|
return Object.freeze(value);
|
|
369
485
|
}
|
|
370
486
|
|
|
487
|
+
const HTTPS_URL_DESCRIPTION =
|
|
488
|
+
`an absolute https URL without credentials, at most ${SOCIAL_LINK_URL_MAX_LENGTH} characters, to a domain name of unaccented letters, digits and hyphens`;
|
|
489
|
+
const UNSUPPORTED_DOMAIN_NOTE =
|
|
490
|
+
"Domain names with accented or non-English letters are not supported here; use a footer bottom link for that site.";
|
|
491
|
+
const SOCIAL_LINKS_ENTRY = registry.components["social-links"];
|
|
492
|
+
const SOCIAL_PLATFORM_KEYS = socialPlatforms.platforms.map((platform) => platform.key);
|
|
493
|
+
const SOCIAL_LINKS_SIZE_HELP = `The icon's edge: ${
|
|
494
|
+
Object.entries(SOCIAL_LINKS_ENTRY.sizes).map(([name, rem]) => `${name} ${rem} (${Number.parseFloat(rem) * 16}px)`).join(", ")
|
|
495
|
+
} at a 16px root.`;
|
|
496
|
+
/** Social links (TR01324): the registry entry owns the cap, the label length and the sizes; the shared rule owns the address. */
|
|
497
|
+
const SOCIAL_LINKS = Object.freeze({
|
|
498
|
+
items: list(
|
|
499
|
+
shape({
|
|
500
|
+
platform: described(
|
|
501
|
+
Object.freeze({ ...enumOf(...SOCIAL_PLATFORM_KEYS), required: true }),
|
|
502
|
+
`The platform whose icon the link shows: ${
|
|
503
|
+
socialPlatforms.platforms.map((platform) => `${platform.key} (${platform.name})`).join(", ")
|
|
504
|
+
}. other is any website and needs a label.`,
|
|
505
|
+
),
|
|
506
|
+
url: safeUrl({ https: true }),
|
|
507
|
+
label: described(
|
|
508
|
+
boundedStr(SOCIAL_LINKS_ENTRY.limits.label),
|
|
509
|
+
"The name screen readers announce instead of the platform name, shown beside the icon when showLabels is true; required for other, and a blank one is refused.",
|
|
510
|
+
),
|
|
511
|
+
}),
|
|
512
|
+
{ minItems: 1, maxItems: SOCIAL_LINKS_ENTRY.maxItems, required: true },
|
|
513
|
+
),
|
|
514
|
+
size: described(enumOf(...Object.keys(SOCIAL_LINKS_ENTRY.sizes)), SOCIAL_LINKS_SIZE_HELP),
|
|
515
|
+
align: described(enumOf("start", "center", "end"), "Where the row sits across the page: start, center or end."),
|
|
516
|
+
showLabels: described(bool(), "Show each link's text beside its icon; when false the text is read by screen readers only."),
|
|
517
|
+
});
|
|
518
|
+
|
|
371
519
|
function component(fields, displayName, summary, defaultData, accessibility, example, authoring) {
|
|
372
520
|
return deepFreeze({
|
|
373
521
|
fields,
|
|
@@ -499,6 +647,8 @@ const COMPONENT_DEFINITIONS = Object.freeze({
|
|
|
499
647
|
"Each linked item needs a title that remains meaningful as link text.",
|
|
500
648
|
"Icon images need descriptive alt text when they convey information; otherwise use an empty alt value.",
|
|
501
649
|
"Item title and description are plain text: an item's url makes the whole item the link, and no link can be placed inside the text. Put linked prose in a paragraph beside the grid.",
|
|
650
|
+
"iconSize sets the icon box: small 3rem (48px), medium 6rem (96px), large 10rem (160px), xlarge 16rem (256px) at a 16px root.",
|
|
651
|
+
"Icons are cropped to a square and framed by the site's image border. For badges, logos or any picture that must keep its shape, use the image-row component instead.",
|
|
502
652
|
],
|
|
503
653
|
{
|
|
504
654
|
items: [
|
|
@@ -700,6 +850,112 @@ const COMPONENT_DEFINITIONS = Object.freeze({
|
|
|
700
850
|
markdownExample: "```component:video-embed\n{\"url\": \"https://youtu.be/dQw4w9WgXcQ\", \"title\": \"A walk through the studio before your first class.\"}\n```",
|
|
701
851
|
},
|
|
702
852
|
),
|
|
853
|
+
"image-row": component(
|
|
854
|
+
IMAGE_ROW,
|
|
855
|
+
"Image Row",
|
|
856
|
+
"A wrapping row of pictures at one height, each with alt text and an optional link, never cropped or framed.",
|
|
857
|
+
{ height: "medium", align: "start" },
|
|
858
|
+
[
|
|
859
|
+
"Every image needs alt text: a row of pictures carries no other text. For a linked image the alt is the link's name, so name the destination or action, such as \"Download on the App Store\"; for an unlinked image, describe the picture.",
|
|
860
|
+
"In Markdown the alt comes from the media manifest entry the image path names; a blank alt is refused.",
|
|
861
|
+
"Each image keeps its own shape at the chosen height and is never cropped or framed; an image wider than the row shrinks to fit. Width and height are required so the row can size the image; media upload records them.",
|
|
862
|
+
"Links open in the same tab, as card-grid and feature-grid links do.",
|
|
863
|
+
],
|
|
864
|
+
{
|
|
865
|
+
items: [
|
|
866
|
+
{
|
|
867
|
+
image: {
|
|
868
|
+
imageId: "6b2d7c4e-1f3a-4b5c-9d8e-7f6a5b4c3d2e",
|
|
869
|
+
src: "https://static.example.test/site/img/app-store-badge.webp",
|
|
870
|
+
urls: [{ minWidth: 240, url: "https://static.example.test/site/img/app-store-badge-240.webp" }],
|
|
871
|
+
width: 1200,
|
|
872
|
+
height: 400,
|
|
873
|
+
alt: "Download on the App Store",
|
|
874
|
+
},
|
|
875
|
+
url: "https://apps.apple.com/app/id123456789",
|
|
876
|
+
},
|
|
877
|
+
{
|
|
878
|
+
image: {
|
|
879
|
+
imageId: "7c3e8d5f-2a4b-4c6d-8e9f-6a5b4c3d2e1f",
|
|
880
|
+
src: "https://static.example.test/site/img/google-play-badge.webp",
|
|
881
|
+
urls: [{ minWidth: 240, url: "https://static.example.test/site/img/google-play-badge-240.webp" }],
|
|
882
|
+
width: 1350,
|
|
883
|
+
height: 400,
|
|
884
|
+
alt: "Get it on Google Play",
|
|
885
|
+
},
|
|
886
|
+
url: "https://play.google.com/store/apps/details?id=example",
|
|
887
|
+
},
|
|
888
|
+
],
|
|
889
|
+
height: "small",
|
|
890
|
+
align: "start",
|
|
891
|
+
},
|
|
892
|
+
),
|
|
893
|
+
"product-grid": component(
|
|
894
|
+
PRODUCT_GRID,
|
|
895
|
+
"Product Grid",
|
|
896
|
+
"Equal-height cards for things to buy or book: a title, free-text price and price note, badge, description, features and one button each, linking to any checkout.",
|
|
897
|
+
{ columns: 3, density: "standard", borderWidth: 0 },
|
|
898
|
+
[
|
|
899
|
+
"A button's visible text is its accessible name, and the product's title heads the same list item, so \"Buy now\" on every card reads with its product. Say what the button does, such as \"Buy now\" or \"Book a trial\".",
|
|
900
|
+
PRODUCT_GRID_BUTTON_TEXT_NOTE,
|
|
901
|
+
"buttonText and buttonUrl go together: text without an address and an address without text are both refused. Links open in the same tab, as card-grid and feature-grid links do.",
|
|
902
|
+
"price is free text (\"From $30\", \"Free\"); priceNote is shown beside it and refused without a price.",
|
|
903
|
+
"An image is optional and cropped to 16:9. Its alt may be blank when the title already says what the picture shows; in Markdown the alt comes from the media manifest entry.",
|
|
904
|
+
"highlighted draws a border of at least 2px (the borderWidth when that is larger) and the badge in the site's action color to mark one card, such as the most popular plan. The grid displays products only: no cart, payments or inventory.",
|
|
905
|
+
],
|
|
906
|
+
{
|
|
907
|
+
items: [
|
|
908
|
+
{
|
|
909
|
+
image: null,
|
|
910
|
+
title: "Intro offer",
|
|
911
|
+
price: "$49",
|
|
912
|
+
priceNote: "for two weeks",
|
|
913
|
+
badge: "New students",
|
|
914
|
+
description: "Unlimited classes for your first two weeks.",
|
|
915
|
+
features: ["Any class, any time", "No commitment"],
|
|
916
|
+
buttonText: "Buy now",
|
|
917
|
+
buttonUrl: "https://pay.example.test/intro",
|
|
918
|
+
highlighted: false,
|
|
919
|
+
},
|
|
920
|
+
{
|
|
921
|
+
image: null,
|
|
922
|
+
title: "Monthly unlimited",
|
|
923
|
+
price: "$145",
|
|
924
|
+
priceNote: "per month",
|
|
925
|
+
badge: "Most popular",
|
|
926
|
+
description: "Every class on the schedule, plus the sauna.",
|
|
927
|
+
features: ["Unlimited classes", "Sauna included", "Pause any time"],
|
|
928
|
+
buttonText: "Join now",
|
|
929
|
+
buttonUrl: "https://pay.example.test/monthly",
|
|
930
|
+
highlighted: true,
|
|
931
|
+
},
|
|
932
|
+
],
|
|
933
|
+
columns: 3,
|
|
934
|
+
density: "standard",
|
|
935
|
+
borderWidth: 0,
|
|
936
|
+
},
|
|
937
|
+
),
|
|
938
|
+
"social-links": component(
|
|
939
|
+
SOCIAL_LINKS,
|
|
940
|
+
"Social Links",
|
|
941
|
+
"A row of links to the site's social profiles, each with its platform's icon, an https address and an optional label.",
|
|
942
|
+
{ size: "medium", align: "start", showLabels: false },
|
|
943
|
+
[
|
|
944
|
+
"A link's text is its accessible name: the label if set, else the platform's name. With showLabels false the text is visually hidden and read by screen readers only, so the name is the same either way.",
|
|
945
|
+
"Link only to accounts you control; each platform's brand rules apply to its mark. " + UNSUPPORTED_DOMAIN_NOTE,
|
|
946
|
+
"platform other is any website and needs a label, such as \"Book online\". A named platform's link carries rel=\"me\"; a Website link carries no rel.",
|
|
947
|
+
],
|
|
948
|
+
{
|
|
949
|
+
items: [
|
|
950
|
+
{ platform: "instagram", url: "https://www.instagram.com/shywellness" },
|
|
951
|
+
{ platform: "facebook", url: "https://www.facebook.com/shywellness", label: "SHY Wellness on Facebook" },
|
|
952
|
+
{ platform: "other", url: "https://shywellness.example/book", label: "Book online" },
|
|
953
|
+
],
|
|
954
|
+
size: "medium",
|
|
955
|
+
align: "start",
|
|
956
|
+
showLabels: false,
|
|
957
|
+
},
|
|
958
|
+
),
|
|
703
959
|
});
|
|
704
960
|
|
|
705
961
|
export const COMPONENT_TYPES = Object.freeze(Object.keys(COMPONENT_DEFINITIONS));
|
|
@@ -754,8 +1010,18 @@ function referenceSchema(spec) {
|
|
|
754
1010
|
...(spec.patternSource !== undefined
|
|
755
1011
|
? { pattern: spec.patternSource, description: `Must be ${spec.patternDescription}.` }
|
|
756
1012
|
: {}),
|
|
1013
|
+
// A `described()` string says what the text is for (TR01323).
|
|
1014
|
+
...(spec.description ? { description: spec.description } : {}),
|
|
757
1015
|
};
|
|
758
1016
|
case "safe-url":
|
|
1017
|
+
if (spec.https) {
|
|
1018
|
+
return {
|
|
1019
|
+
type: "string",
|
|
1020
|
+
format: "https-url",
|
|
1021
|
+
maxLength: SOCIAL_LINK_URL_MAX_LENGTH,
|
|
1022
|
+
description: `An ${HTTPS_URL_DESCRIPTION.slice(3)}. ${UNSUPPORTED_DOMAIN_NOTE}`,
|
|
1023
|
+
};
|
|
1024
|
+
}
|
|
759
1025
|
return {
|
|
760
1026
|
type: "string",
|
|
761
1027
|
format: spec.allowEmpty === false ? "safe-url" : "safe-url-or-empty",
|
|
@@ -783,7 +1049,11 @@ function referenceSchema(spec) {
|
|
|
783
1049
|
case "uuid":
|
|
784
1050
|
return { type: "string", format: "lowercase-uuid" };
|
|
785
1051
|
case "enum":
|
|
786
|
-
return {
|
|
1052
|
+
return {
|
|
1053
|
+
type: "string",
|
|
1054
|
+
enum: [...spec.values],
|
|
1055
|
+
...(spec.description ? { description: spec.description } : {}),
|
|
1056
|
+
};
|
|
787
1057
|
case "number": {
|
|
788
1058
|
const schema = { type: spec.integer ? "integer" : "number" };
|
|
789
1059
|
if (spec.values) schema.enum = [...spec.values];
|
|
@@ -796,7 +1066,12 @@ function referenceSchema(spec) {
|
|
|
796
1066
|
return schema;
|
|
797
1067
|
}
|
|
798
1068
|
case "array":
|
|
799
|
-
return {
|
|
1069
|
+
return {
|
|
1070
|
+
type: "array",
|
|
1071
|
+
...(spec.minItems !== undefined ? { minItems: spec.minItems } : {}),
|
|
1072
|
+
maxItems: spec.maxItems ?? MAX_ITEMS,
|
|
1073
|
+
items: referenceSchema(spec.item),
|
|
1074
|
+
};
|
|
800
1075
|
case "object":
|
|
801
1076
|
return {
|
|
802
1077
|
type: spec.nullable ? ["object", "null"] : "object",
|
|
@@ -805,15 +1080,16 @@ function referenceSchema(spec) {
|
|
|
805
1080
|
};
|
|
806
1081
|
case "image":
|
|
807
1082
|
return {
|
|
808
|
-
type: ["object", "null"],
|
|
1083
|
+
type: spec.required ? "object" : ["object", "null"],
|
|
809
1084
|
// What a Markdown component fence takes instead (TR01187).
|
|
810
|
-
markdownType: ["string", "null"],
|
|
811
|
-
description:
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
1085
|
+
markdownType: spec.required ? "string" : ["string", "null"],
|
|
1086
|
+
description: `In Markdown, a media path recorded by media upload (such as "media/photo.webp")${
|
|
1087
|
+
spec.required ? "" : ", or null"
|
|
1088
|
+
}; pages push stores the image record. A .pm.json document carries that record: keep imageId, src, and urls keys so the server can rewrite delivery URLs.${
|
|
1089
|
+
spec.altRequired ? " Alt text is required." : ""
|
|
1090
|
+
}${spec.dimensionsRequired ? " Width and height are required." : ""}`,
|
|
815
1091
|
additionalProperties: false,
|
|
816
|
-
properties: referenceProperties(
|
|
1092
|
+
properties: referenceProperties(imageFieldsFor(spec)),
|
|
817
1093
|
};
|
|
818
1094
|
default:
|
|
819
1095
|
throw new TypeError(`Unknown component schema kind '${spec.kind}'.`);
|
|
@@ -866,10 +1142,13 @@ function describe(spec) {
|
|
|
866
1142
|
case "object":
|
|
867
1143
|
return "an object";
|
|
868
1144
|
case "image":
|
|
869
|
-
return
|
|
1145
|
+
return spec.required
|
|
1146
|
+
? "a media path in Markdown or an image object in .pm.json"
|
|
1147
|
+
: "a media path in Markdown, an image object in .pm.json, or null";
|
|
870
1148
|
case "uuid":
|
|
871
1149
|
return "a canonical lowercase UUID";
|
|
872
1150
|
case "safe-url":
|
|
1151
|
+
if (spec.https) return HTTPS_URL_DESCRIPTION;
|
|
873
1152
|
return `${
|
|
874
1153
|
spec.allowEmpty === false ? "a" : "an empty string or a"
|
|
875
1154
|
} safe HTTP(S), mailto, tel, non-protocol-relative root path, fragment, query, or relative URL; backslashes and ASCII control characters are rejected`;
|
|
@@ -896,12 +1175,19 @@ function validateValue(spec, value, path, errors) {
|
|
|
896
1175
|
switch (spec.kind) {
|
|
897
1176
|
case "string":
|
|
898
1177
|
if (typeof value !== "string") fail("a string");
|
|
899
|
-
else if (spec.nonWhitespace && value.trim() === "")
|
|
1178
|
+
else if (spec.nonWhitespace && value.trim() === "") {
|
|
1179
|
+
fail(spec.blankDetail ?? "a string containing non-whitespace text");
|
|
1180
|
+
}
|
|
900
1181
|
else if (spec.maximumLength !== undefined && value.length > spec.maximumLength) {
|
|
901
1182
|
fail(`a string of at most ${spec.maximumLength} characters`);
|
|
902
1183
|
} else if (spec.pattern !== undefined && !spec.pattern.test(value)) fail(spec.patternDescription);
|
|
903
1184
|
return;
|
|
904
1185
|
case "safe-url":
|
|
1186
|
+
if (spec.https) {
|
|
1187
|
+
// Judged exactly as written: a hand-written document is not trimmed.
|
|
1188
|
+
if (!isSocialLinkUrl(value)) fail(describe(spec));
|
|
1189
|
+
return;
|
|
1190
|
+
}
|
|
905
1191
|
if (
|
|
906
1192
|
typeof value !== "string"
|
|
907
1193
|
|| (value === "" && spec.allowEmpty === false)
|
|
@@ -961,8 +1247,13 @@ function validateValue(spec, value, path, errors) {
|
|
|
961
1247
|
fail("an array");
|
|
962
1248
|
return;
|
|
963
1249
|
}
|
|
964
|
-
if (value.length
|
|
965
|
-
fail(`at
|
|
1250
|
+
if (spec.minItems !== undefined && value.length < spec.minItems) {
|
|
1251
|
+
fail(`at least ${spec.minItems} entries`);
|
|
1252
|
+
return;
|
|
1253
|
+
}
|
|
1254
|
+
const maxItems = spec.maxItems ?? MAX_ITEMS;
|
|
1255
|
+
if (value.length > maxItems) {
|
|
1256
|
+
fail(`at most ${maxItems} entries`);
|
|
966
1257
|
return;
|
|
967
1258
|
}
|
|
968
1259
|
value.forEach((entry, index) => validateValue(spec.item, entry, `${path}/${index}`, errors));
|
|
@@ -977,13 +1268,17 @@ function validateValue(spec, value, path, errors) {
|
|
|
977
1268
|
return;
|
|
978
1269
|
}
|
|
979
1270
|
case "image": {
|
|
980
|
-
// `ComponentImageData | null` — null is the documented "no image" value
|
|
981
|
-
|
|
1271
|
+
// `ComponentImageData | null` — null is the documented "no image" value
|
|
1272
|
+
// unless the component's images are its content.
|
|
1273
|
+
if (value === null) {
|
|
1274
|
+
if (spec.required) fail("an image object");
|
|
1275
|
+
return;
|
|
1276
|
+
}
|
|
982
1277
|
if (!isPlainObject(value)) {
|
|
983
|
-
fail("an image object or null");
|
|
1278
|
+
fail(spec.required ? "an image object" : "an image object or null");
|
|
984
1279
|
return;
|
|
985
1280
|
}
|
|
986
|
-
validateFields(
|
|
1281
|
+
validateFields(imageFieldsFor(spec), value, path, errors);
|
|
987
1282
|
return;
|
|
988
1283
|
}
|
|
989
1284
|
default:
|
|
@@ -1114,6 +1409,77 @@ export function validateComponentBlock(componentType, componentData, basePath) {
|
|
|
1114
1409
|
}
|
|
1115
1410
|
}
|
|
1116
1411
|
}
|
|
1412
|
+
if (componentType === "product-grid" && Array.isArray(parsed.items)) {
|
|
1413
|
+
// Blank text is absent; a whitespace-only address is already refused by
|
|
1414
|
+
// `safeUrl` above, so trimming it here adds no second error (TR01323).
|
|
1415
|
+
for (const [index, item] of parsed.items.entries()) {
|
|
1416
|
+
if (!isPlainObject(item)) continue;
|
|
1417
|
+
// The editor keeps one feature per line, so a feature is one line.
|
|
1418
|
+
if (Array.isArray(item.features)) {
|
|
1419
|
+
for (const [featureIndex, feature] of item.features.entries()) {
|
|
1420
|
+
if (typeof feature === "string" && /[\r\n]/u.test(feature)) {
|
|
1421
|
+
errors.push(contentError(
|
|
1422
|
+
`${dataPath}/items/${index}/features/${featureIndex}`,
|
|
1423
|
+
CODES.componentData,
|
|
1424
|
+
"A feature is one line; put the rest in another feature or the description.",
|
|
1425
|
+
));
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
// A wrong-type field or a whitespace-only address was refused by
|
|
1430
|
+
// `validateFields` above; a second error at the same path would say
|
|
1431
|
+
// nothing new, so each rule skips a pair with a wrong-type field and
|
|
1432
|
+
// the other rule still runs, as the generator's readiness check does.
|
|
1433
|
+
const wrongType = (field) => Object.hasOwn(item, field) && typeof item[field] !== "string";
|
|
1434
|
+
if (!wrongType("buttonText") && !wrongType("buttonUrl")) {
|
|
1435
|
+
const text = typeof item.buttonText === "string" ? item.buttonText.trim() : "";
|
|
1436
|
+
const rawUrl = typeof item.buttonUrl === "string" ? item.buttonUrl : "";
|
|
1437
|
+
const url = rawUrl.trim();
|
|
1438
|
+
const urlAlreadyRefused = rawUrl.length > 0 && url.length === 0;
|
|
1439
|
+
if (text.length > 0 && url.length === 0 && !urlAlreadyRefused) {
|
|
1440
|
+
errors.push(contentError(
|
|
1441
|
+
`${dataPath}/items/${index}/buttonUrl`,
|
|
1442
|
+
CODES.componentData,
|
|
1443
|
+
"A product with button text needs a buttonUrl, or clear the text.",
|
|
1444
|
+
));
|
|
1445
|
+
}
|
|
1446
|
+
if (url.length > 0 && text.length === 0) {
|
|
1447
|
+
errors.push(contentError(
|
|
1448
|
+
`${dataPath}/items/${index}/buttonText`,
|
|
1449
|
+
CODES.componentData,
|
|
1450
|
+
"A product with a buttonUrl needs buttonText, or clear the address.",
|
|
1451
|
+
));
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
if (!wrongType("price") && !wrongType("priceNote")) {
|
|
1455
|
+
const price = typeof item.price === "string" ? item.price.trim() : "";
|
|
1456
|
+
const note = typeof item.priceNote === "string" ? item.priceNote.trim() : "";
|
|
1457
|
+
if (note.length > 0 && price.length === 0) {
|
|
1458
|
+
errors.push(contentError(
|
|
1459
|
+
`${dataPath}/items/${index}/price`,
|
|
1460
|
+
CODES.componentData,
|
|
1461
|
+
"A product with a priceNote needs a price, or clear the note.",
|
|
1462
|
+
));
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
if (componentType === "social-links" && Array.isArray(parsed.items)) {
|
|
1468
|
+
// One error per label path: a blank label first, else a Website link
|
|
1469
|
+
// without one. A wrong-type label or platform was refused above (TR01324).
|
|
1470
|
+
for (const [index, item] of parsed.items.entries()) {
|
|
1471
|
+
if (!isPlainObject(item)) continue;
|
|
1472
|
+
if ((Object.hasOwn(item, "label") && typeof item.label !== "string") || typeof item.platform !== "string") continue;
|
|
1473
|
+
const label = typeof item.label === "string" ? item.label : "";
|
|
1474
|
+
const path = `${dataPath}/items/${index}/label`;
|
|
1475
|
+
// The shared whitespace set, so no layer's own trim admits a label another refuses.
|
|
1476
|
+
if (label !== "" && trimSocialLinkInput(label) === "") {
|
|
1477
|
+
errors.push(contentError(path, CODES.componentData, "A label must have text, or leave it out."));
|
|
1478
|
+
} else if (item.platform === "other" && trimSocialLinkInput(label) === "") {
|
|
1479
|
+
errors.push(contentError(path, CODES.componentData, "A Website link needs a label."));
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1117
1483
|
if (
|
|
1118
1484
|
componentType === "video-embed" && typeof parsed.provider === "string" && typeof parsed.videoId === "string"
|
|
1119
1485
|
&& (parsed.provider === "youtube" || parsed.provider === "vimeo")
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version":
|
|
2
|
+
"version": 13,
|
|
3
3
|
"section": {
|
|
4
4
|
"nodeType": "section",
|
|
5
5
|
"label": "Section",
|
|
@@ -569,6 +569,42 @@
|
|
|
569
569
|
"label": "Video embed",
|
|
570
570
|
"measure": "wide",
|
|
571
571
|
"rootPlacement": "flow"
|
|
572
|
+
},
|
|
573
|
+
"image-row": {
|
|
574
|
+
"label": "Image Row",
|
|
575
|
+
"measure": "wide",
|
|
576
|
+
"rootPlacement": "flow",
|
|
577
|
+
"maxItems": 12,
|
|
578
|
+
"heights": {
|
|
579
|
+
"small": "2.5rem",
|
|
580
|
+
"medium": "4rem",
|
|
581
|
+
"large": "6rem"
|
|
582
|
+
}
|
|
583
|
+
},
|
|
584
|
+
"product-grid": {
|
|
585
|
+
"label": "Product Grid",
|
|
586
|
+
"measure": "wide",
|
|
587
|
+
"rootPlacement": "flow",
|
|
588
|
+
"maxItems": 100,
|
|
589
|
+
"limits": {
|
|
590
|
+
"title": 80,
|
|
591
|
+
"price": 40,
|
|
592
|
+
"priceNote": 60,
|
|
593
|
+
"badge": 40,
|
|
594
|
+
"description": 240,
|
|
595
|
+
"feature": 80,
|
|
596
|
+
"features": 8,
|
|
597
|
+
"buttonText": 40
|
|
598
|
+
},
|
|
599
|
+
"imageAspectRatio": { "width": 16, "height": 9 }
|
|
600
|
+
},
|
|
601
|
+
"social-links": {
|
|
602
|
+
"label": "Social Links",
|
|
603
|
+
"measure": "wide",
|
|
604
|
+
"rootPlacement": "flow",
|
|
605
|
+
"maxItems": 12,
|
|
606
|
+
"limits": { "label": 120 },
|
|
607
|
+
"sizes": { "small": "1.5rem", "medium": "2rem", "large": "3rem" }
|
|
572
608
|
}
|
|
573
609
|
},
|
|
574
610
|
"integrationPlacement": {
|