@taprootio/site-authoring 0.28.0 → 0.30.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.
@@ -18,6 +18,14 @@
18
18
  "pageResourceId": "a0000000-0000-4000-8000-000000000013"
19
19
  }
20
20
  ],
21
+ "socialLinks": [
22
+ {
23
+ "id": "a0000000-0000-4000-8000-000000000048",
24
+ "platform": "instagram",
25
+ "url": "https://instagram.example.test/riverbendwellness",
26
+ "label": "Riverbend on Instagram"
27
+ }
28
+ ],
21
29
  "light": {
22
30
  "backgroundColor": "--esp-color-layer-1",
23
31
  "textColor": "--esp-color-text",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taprootio/site-authoring",
3
- "version": "0.28.0",
3
+ "version": "0.30.0",
4
4
  "description": "Taproot site authoring CLI: sign in once in the browser, then pull, validate, push, preview, approve, and deploy a site from the terminal",
5
5
  "type": "module",
6
6
  "license": "ISC",
@@ -1,5 +1,35 @@
1
1
  {
2
2
  "entries": [
3
+ {
4
+ "version": "0.30.0",
5
+ "note": "A card-grid can show each card's image whole at a set height, aligned and unframed, with chosen space on any side of the image, so a logo no longer needs a wide padded file.",
6
+ "behavior": [
7
+ "A card-grid takes imageFit (cover, the default, or contain), imageHeight (small 2.5rem/40px, medium 4rem/64px, large 6rem/96px at a 16px root), imageAlign (start, center or end) and imageSpacing (an object with any of top, bottom, start and end, each none, small, medium or text). With contain the whole image shows at imageHeight, never cropped or framed, and an image wider than its card shrinks to fit; each card image needs a nonzero width and height, which media upload records. imageHeight and imageAlign are refused unless imageFit is contain. A side left out of imageSpacing keeps its default for the fit: cover has none on every side; contain has small above, none below and text (the padding around the card's text) at start and end. See help component card-grid.",
8
+ "A card-grid image now sits flush with the top of its card, because the article image margin no longer reaches it. A site gets this on its next publish.",
9
+ "The help reference version is 49."
10
+ ],
11
+ "help": [
12
+ "components",
13
+ "component"
14
+ ]
15
+ },
16
+ {
17
+ "version": "0.29.0",
18
+ "note": "Pages gain an image-row component for linked pictures at a set height and a product-grid component for priced cards with a button each; feature-grid help states its icon sizes and square crop, and published feature-grid icons now render at the documented sizes.",
19
+ "behavior": [
20
+ "A free-form page may hold an image-row component: one to twelve pictures at one height (small 2.5rem/40px, medium 4rem/64px, large 6rem/96px at a 16px root), each with required alt text, required width and height, and an optional link. Pictures keep their own shape, are never cropped or framed, and one wider than the row shrinks to fit. See help component image-row.",
21
+ "help component feature-grid states the icon box sizes (small 3rem/48px, medium 6rem/96px, large 10rem/160px, xlarge 16rem/256px at a 16px root) and that icons are cropped to a square and framed by the site's image border. Published feature-grid icons now render at those sizes; before this release they filled the item's width with a 2em margin, so re-check existing grids after the runtime update.",
22
+ "A free-form page may hold a product-grid component: one to a hundred equal-height cards for things to buy or book, each with a title, free-text price and note, badge, description, up to eight feature lines, an optional 16:9 image and one button that links to any checkout; columns 2 to 4, standard or compact density, a border width, and a highlighted card drawn in the site's action color. Site checks now read buttonUrl and linkUrl addresses, so a card-grid, cta or product-grid link to a missing page is reported. See help component product-grid.",
23
+ "A free-form page may hold a social-links component: one to twelve links, each a platform from a fixed list (Instagram, Facebook, Yelp, YouTube, TikTok, LinkedIn, X, Threads, Bluesky, Mastodon, Pinterest, or Website for any other site) with an https address and an optional label the link reads to screen readers; icons at small 1.5rem, medium 2rem or large 3rem, aligned start, center or end (the align field), with labels shown or hidden. The address must be absolute https, without credentials, to a domain of unaccented letters, digits and hyphens. See help component social-links.",
24
+ "Footer settings gain socialLinks: up to twelve links with an id, a platform key, an https address and an optional label, published as an icon row between the bottom copy and the utility links. The footer draft hash moves to footer-draft-v4, so every footer draft hash issued before this release is stale once: pull the footer again before pushing it. See help footer.",
25
+ "The help reference version is 48."
26
+ ],
27
+ "help": [
28
+ "components",
29
+ "component",
30
+ "footer"
31
+ ]
32
+ },
3
33
  {
4
34
  "version": "0.28.0",
5
35
  "note": "Taproot no longer generates /places, year and month, or tag pages, so a generated page's data drops those fields. One /published page lists every month, a folder page reaches search only with an introduction or three cards, and a review's location byline opens Google Maps.",
@@ -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
- const list = (item) => Object.freeze({ kind: "array", item });
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
- const image = () => Object.freeze({ kind: "image" });
67
- const safeUrl = (options = {}) => Object.freeze({ kind: "safe-url", ...options });
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
@@ -230,6 +247,22 @@ const LATEST_POSTS = Object.freeze({
230
247
  bannerScheme: enumOf("auto", "light", "dark"),
231
248
  });
232
249
 
250
+ /** The registry owns the row heights; the help text and the renderer derive from the same map. */
251
+ const IMAGE_ROW_HEIGHTS = registry.components["image-row"].heights;
252
+ const IMAGE_ROW_HEIGHT_SIZES = Object.entries(IMAGE_ROW_HEIGHTS)
253
+ .map(([name, rem]) => `${name} ${rem} (${Number.parseFloat(rem) * 16}px)`)
254
+ .join(", ");
255
+ const IMAGE_ROW_HEIGHT_HELP =
256
+ `Each image's height: ${IMAGE_ROW_HEIGHT_SIZES} at a 16px root; width follows each image's own shape, never cropped or framed.`;
257
+
258
+ /** The registry owns the card image spacings too; each side takes the same four names (TR01326). */
259
+ const CARD_IMAGE_SPACINGS = registry.components["card-grid"].imageSpacings;
260
+ const CARD_IMAGE_SPACING_NAMES = Object.keys(CARD_IMAGE_SPACINGS);
261
+ const CARD_IMAGE_SPACING_VALUES =
262
+ `none (0), small (${CARD_IMAGE_SPACINGS.small}), medium (${CARD_IMAGE_SPACINGS.medium}) or text (the same padding as around the card's text)`;
263
+ const CARD_IMAGE_SPACING_SIDE_HELP = (side, defaults) =>
264
+ `Space on the ${side} of each card image: ${CARD_IMAGE_SPACING_VALUES}. Left out, it is ${defaults}.`;
265
+
233
266
  /** `CardGridData`. `cropState` is the one genuinely optional field in types.ts. */
234
267
  const CARD_GRID = Object.freeze({
235
268
  cards: withOmission(
@@ -245,6 +278,36 @@ const CARD_GRID = Object.freeze({
245
278
  columns: num({ values: Object.freeze([2, 3]) }),
246
279
  presentation: enumOf("cards", "editorial", "featured", "poster"),
247
280
  imageAspect: enumOf("auto", "landscape", "square", "portrait"),
281
+ imageFit: withOmission(
282
+ described(
283
+ enumOf("cover", "contain"),
284
+ "cover fills the card and crops each image to imageAspect; contain shows the whole image at imageHeight, never cropped or framed, and needs each image's width and height.",
285
+ ),
286
+ { kind: "value", value: "cover" },
287
+ ),
288
+ imageHeight: described(
289
+ enumOf(...Object.keys(IMAGE_ROW_HEIGHTS)),
290
+ `${IMAGE_ROW_HEIGHT_HELP} Only with imageFit contain, where it is medium when left out; refused otherwise.`,
291
+ ),
292
+ imageAlign: described(
293
+ enumOf("start", "center", "end"),
294
+ "Where each whole image sits across its card, not the text: start is the left edge and end the right edge in a left-to-right page. Only with imageFit contain, where it is start when left out; refused otherwise.",
295
+ ),
296
+ imageSpacing: described(
297
+ shape({
298
+ top: described(enumOf(...CARD_IMAGE_SPACING_NAMES), CARD_IMAGE_SPACING_SIDE_HELP("top", "none with cover and small with contain")),
299
+ bottom: described(enumOf(...CARD_IMAGE_SPACING_NAMES), CARD_IMAGE_SPACING_SIDE_HELP("bottom", "none")),
300
+ start: described(
301
+ enumOf(...CARD_IMAGE_SPACING_NAMES),
302
+ CARD_IMAGE_SPACING_SIDE_HELP("left edge (start)", "none with cover and text with contain"),
303
+ ),
304
+ end: described(
305
+ enumOf(...CARD_IMAGE_SPACING_NAMES),
306
+ CARD_IMAGE_SPACING_SIDE_HELP("right edge (end)", "none with cover and text with contain"),
307
+ ),
308
+ }),
309
+ "Space around each card image; name only the sides to change, and a side left out keeps its default for the fit. With imageFit contain, start moves an image aligned to start and end moves one aligned to end; otherwise they only limit how wide the image can be.",
310
+ ),
248
311
  interaction: enumOf("none", "lift", "zoom", "caption-reveal"),
249
312
  borderWidth: num({
250
313
  integer: true,
@@ -362,12 +425,135 @@ const FORM = Object.freeze({
362
425
  formKey: str({ required: true, nonWhitespace: true }),
363
426
  });
364
427
 
428
+ /**
429
+ * The image field table an image spec validates against. The shared table
430
+ * leaves alt and dimensions optional; a spec that requires them swaps in
431
+ * stricter entries at the same positions so canonical order is unchanged.
432
+ */
433
+ function imageFieldsFor(spec) {
434
+ if (!spec.altRequired && !spec.dimensionsRequired) return COMPONENT_IMAGE_FIELDS;
435
+ return Object.freeze({
436
+ ...COMPONENT_IMAGE_FIELDS,
437
+ ...(spec.dimensionsRequired
438
+ ? {
439
+ width: num({ integer: true, minimum: 1, required: true }),
440
+ height: num({ integer: true, minimum: 1, required: true }),
441
+ }
442
+ : {}),
443
+ ...(spec.altRequired
444
+ ? {
445
+ alt: str({
446
+ required: true,
447
+ nonWhitespace: true,
448
+ // What the author can act on: in Markdown the alt is the media manifest's, not the fence's.
449
+ blankDetail: "alt text that names the picture or its destination; in Markdown the alt comes from the "
450
+ + "media manifest entry the image path names",
451
+ }),
452
+ }
453
+ : {}),
454
+ });
455
+ }
456
+
457
+ /**
458
+ * `ImageRowData` (TR01322). A row of pictures carries no other text, so every
459
+ * image needs alt text and the dimensions the row sizes it by.
460
+ */
461
+ const IMAGE_ROW = Object.freeze({
462
+ items: list(
463
+ shape({
464
+ image: image({ required: true, altRequired: true, dimensionsRequired: true }),
465
+ url: safeUrl(),
466
+ }),
467
+ { minItems: 1, maxItems: registry.components["image-row"].maxItems, required: true },
468
+ ),
469
+ height: described(enumOf(...Object.keys(IMAGE_ROW_HEIGHTS)), IMAGE_ROW_HEIGHT_HELP),
470
+ align: described(
471
+ enumOf("start", "center", "end"),
472
+ "Where the images sit when they do not fill the row: start is the left edge and end the right edge in a "
473
+ + "left-to-right page.",
474
+ ),
475
+ });
476
+
477
+ /**
478
+ * `ProductGridData` (TR01323). The registry owns the text bounds, the item cap
479
+ * and the crop shape; the generator's readiness check and the editor read the
480
+ * same entry, so no side can drift. Blank optional text counts as absent on
481
+ * every side; the cross-field hook below decides the button pair and the
482
+ * price note.
483
+ */
484
+ const PRODUCT_GRID_ENTRY = registry.components["product-grid"];
485
+ const PRODUCT_GRID_LIMITS = PRODUCT_GRID_ENTRY.limits;
486
+ const PRODUCT_GRID_BUTTON_TEXT_NOTE = "Keep it short; long text is cut off in narrow columns.";
487
+ const PRODUCT_GRID = Object.freeze({
488
+ items: list(
489
+ shape({
490
+ image: image(),
491
+ title: boundedStr(PRODUCT_GRID_LIMITS.title, { required: true, nonWhitespace: true }),
492
+ price: described(boundedStr(PRODUCT_GRID_LIMITS.price), "Free text, such as \"$145\", \"From $30\" or \"Free\"."),
493
+ priceNote: described(boundedStr(PRODUCT_GRID_LIMITS.priceNote), "Shown beside the price, such as \"per month\"; refused without a price."),
494
+ badge: boundedStr(PRODUCT_GRID_LIMITS.badge),
495
+ description: boundedStr(PRODUCT_GRID_LIMITS.description),
496
+ features: list(
497
+ described(boundedStr(PRODUCT_GRID_LIMITS.feature, { nonWhitespace: true }), "One line each; a line break inside a feature is refused."),
498
+ { maxItems: PRODUCT_GRID_LIMITS.features },
499
+ ),
500
+ buttonText: described(
501
+ boundedStr(PRODUCT_GRID_LIMITS.buttonText),
502
+ `${PRODUCT_GRID_BUTTON_TEXT_NOTE} Needs a buttonUrl, and a buttonUrl needs it.`,
503
+ ),
504
+ buttonUrl: safeUrl(),
505
+ highlighted: bool(),
506
+ cropState: CROP_STATE,
507
+ }),
508
+ { minItems: 1, maxItems: PRODUCT_GRID_ENTRY.maxItems, required: true },
509
+ ),
510
+ columns: num({ values: Object.freeze([2, 3, 4]) }),
511
+ density: described(enumOf("standard", "compact"), "compact tightens the card padding and gaps; nothing is hidden."),
512
+ borderWidth: num({
513
+ integer: true,
514
+ minimum: 0,
515
+ description: "Pixel border around each card; 0 boxes nothing. A highlighted card always draws at least 2px.",
516
+ }),
517
+ });
518
+
365
519
  function deepFreeze(value) {
366
520
  if (value === null || typeof value !== "object" || Object.isFrozen(value)) return value;
367
521
  for (const child of Object.values(value)) deepFreeze(child);
368
522
  return Object.freeze(value);
369
523
  }
370
524
 
525
+ const HTTPS_URL_DESCRIPTION =
526
+ `an absolute https URL without credentials, at most ${SOCIAL_LINK_URL_MAX_LENGTH} characters, to a domain name of unaccented letters, digits and hyphens`;
527
+ const UNSUPPORTED_DOMAIN_NOTE =
528
+ "Domain names with accented or non-English letters are not supported here; use a footer bottom link for that site.";
529
+ const SOCIAL_LINKS_ENTRY = registry.components["social-links"];
530
+ const SOCIAL_PLATFORM_KEYS = socialPlatforms.platforms.map((platform) => platform.key);
531
+ const SOCIAL_LINKS_SIZE_HELP = `The icon's edge: ${
532
+ Object.entries(SOCIAL_LINKS_ENTRY.sizes).map(([name, rem]) => `${name} ${rem} (${Number.parseFloat(rem) * 16}px)`).join(", ")
533
+ } at a 16px root.`;
534
+ /** Social links (TR01324): the registry entry owns the cap, the label length and the sizes; the shared rule owns the address. */
535
+ const SOCIAL_LINKS = Object.freeze({
536
+ items: list(
537
+ shape({
538
+ platform: described(
539
+ Object.freeze({ ...enumOf(...SOCIAL_PLATFORM_KEYS), required: true }),
540
+ `The platform whose icon the link shows: ${
541
+ socialPlatforms.platforms.map((platform) => `${platform.key} (${platform.name})`).join(", ")
542
+ }. other is any website and needs a label.`,
543
+ ),
544
+ url: safeUrl({ https: true }),
545
+ label: described(
546
+ boundedStr(SOCIAL_LINKS_ENTRY.limits.label),
547
+ "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.",
548
+ ),
549
+ }),
550
+ { minItems: 1, maxItems: SOCIAL_LINKS_ENTRY.maxItems, required: true },
551
+ ),
552
+ size: described(enumOf(...Object.keys(SOCIAL_LINKS_ENTRY.sizes)), SOCIAL_LINKS_SIZE_HELP),
553
+ align: described(enumOf("start", "center", "end"), "Where the row sits across the page: start, center or end."),
554
+ showLabels: described(bool(), "Show each link's text beside its icon; when false the text is read by screen readers only."),
555
+ });
556
+
371
557
  function component(fields, displayName, summary, defaultData, accessibility, example, authoring) {
372
558
  return deepFreeze({
373
559
  fields,
@@ -499,6 +685,8 @@ const COMPONENT_DEFINITIONS = Object.freeze({
499
685
  "Each linked item needs a title that remains meaningful as link text.",
500
686
  "Icon images need descriptive alt text when they convey information; otherwise use an empty alt value.",
501
687
  "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.",
688
+ "iconSize sets the icon box: small 3rem (48px), medium 6rem (96px), large 10rem (160px), xlarge 16rem (256px) at a 16px root.",
689
+ "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, or a card-grid with imageFit contain when each needs a card.",
502
690
  ],
503
691
  {
504
692
  items: [
@@ -563,6 +751,14 @@ const COMPONENT_DEFINITIONS = Object.freeze({
563
751
  "Every linked card needs a title that describes its destination.",
564
752
  "Card images need descriptive alt text unless they are decorative.",
565
753
  "Card title and description are plain text: a card's linkUrl makes the whole card the link, and no link can be placed inside the text. Put linked prose in a paragraph beside the grid.",
754
+ "Images are cropped when imageFit is cover, the default: each is cut to the fixed imageAspect, while imageAspect auto follows the picture's own shape up to the page's height cap. imageFit contain never crops; it shows the whole image.",
755
+ "For a logo or mark use imageFit contain with a tightly cropped file that has no built-in padding, and set the size with imageHeight and the space around it with imageSpacing, not by padding the file.",
756
+ `imageHeight sets the height of a whole image: ${IMAGE_ROW_HEIGHT_SIZES} at a 16px root. Width follows the image's shape, and an image wider than its card shrinks to fit.`,
757
+ "imageHeight and imageAlign are refused with cover, and when imageFit is left out, which means cover. With contain, imageHeight is medium and imageAlign is start when left out. imageAlign moves the image only, never the text.",
758
+ `imageSpacing is an object with any of top, bottom, start and end, each ${CARD_IMAGE_SPACING_VALUES}. It works with either fit.`,
759
+ "A side left out of imageSpacing keeps its default, so name only the sides to change. Defaults for cover: none on every side. Defaults for contain: top small, bottom none, start and end text.",
760
+ "imageFit contain needs each card image's width and height, which media upload records; a card image with a missing or zero size is refused.",
761
+ "A card with a saved crop shows that crop under contain; replace the image with the whole file to show all of it. The Zoom interaction has no effect on a whole image.",
566
762
  ],
567
763
  {
568
764
  cards: [
@@ -700,6 +896,112 @@ const COMPONENT_DEFINITIONS = Object.freeze({
700
896
  markdownExample: "```component:video-embed\n{\"url\": \"https://youtu.be/dQw4w9WgXcQ\", \"title\": \"A walk through the studio before your first class.\"}\n```",
701
897
  },
702
898
  ),
899
+ "image-row": component(
900
+ IMAGE_ROW,
901
+ "Image Row",
902
+ "A wrapping row of pictures at one height, each with alt text and an optional link, never cropped or framed.",
903
+ { height: "medium", align: "start" },
904
+ [
905
+ "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.",
906
+ "In Markdown the alt comes from the media manifest entry the image path names; a blank alt is refused.",
907
+ "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.",
908
+ "Links open in the same tab, as card-grid and feature-grid links do.",
909
+ ],
910
+ {
911
+ items: [
912
+ {
913
+ image: {
914
+ imageId: "6b2d7c4e-1f3a-4b5c-9d8e-7f6a5b4c3d2e",
915
+ src: "https://static.example.test/site/img/app-store-badge.webp",
916
+ urls: [{ minWidth: 240, url: "https://static.example.test/site/img/app-store-badge-240.webp" }],
917
+ width: 1200,
918
+ height: 400,
919
+ alt: "Download on the App Store",
920
+ },
921
+ url: "https://apps.apple.com/app/id123456789",
922
+ },
923
+ {
924
+ image: {
925
+ imageId: "7c3e8d5f-2a4b-4c6d-8e9f-6a5b4c3d2e1f",
926
+ src: "https://static.example.test/site/img/google-play-badge.webp",
927
+ urls: [{ minWidth: 240, url: "https://static.example.test/site/img/google-play-badge-240.webp" }],
928
+ width: 1350,
929
+ height: 400,
930
+ alt: "Get it on Google Play",
931
+ },
932
+ url: "https://play.google.com/store/apps/details?id=example",
933
+ },
934
+ ],
935
+ height: "small",
936
+ align: "start",
937
+ },
938
+ ),
939
+ "product-grid": component(
940
+ PRODUCT_GRID,
941
+ "Product Grid",
942
+ "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.",
943
+ { columns: 3, density: "standard", borderWidth: 0 },
944
+ [
945
+ "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\".",
946
+ PRODUCT_GRID_BUTTON_TEXT_NOTE,
947
+ "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.",
948
+ "price is free text (\"From $30\", \"Free\"); priceNote is shown beside it and refused without a price.",
949
+ "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.",
950
+ "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.",
951
+ ],
952
+ {
953
+ items: [
954
+ {
955
+ image: null,
956
+ title: "Intro offer",
957
+ price: "$49",
958
+ priceNote: "for two weeks",
959
+ badge: "New students",
960
+ description: "Unlimited classes for your first two weeks.",
961
+ features: ["Any class, any time", "No commitment"],
962
+ buttonText: "Buy now",
963
+ buttonUrl: "https://pay.example.test/intro",
964
+ highlighted: false,
965
+ },
966
+ {
967
+ image: null,
968
+ title: "Monthly unlimited",
969
+ price: "$145",
970
+ priceNote: "per month",
971
+ badge: "Most popular",
972
+ description: "Every class on the schedule, plus the sauna.",
973
+ features: ["Unlimited classes", "Sauna included", "Pause any time"],
974
+ buttonText: "Join now",
975
+ buttonUrl: "https://pay.example.test/monthly",
976
+ highlighted: true,
977
+ },
978
+ ],
979
+ columns: 3,
980
+ density: "standard",
981
+ borderWidth: 0,
982
+ },
983
+ ),
984
+ "social-links": component(
985
+ SOCIAL_LINKS,
986
+ "Social Links",
987
+ "A row of links to the site's social profiles, each with its platform's icon, an https address and an optional label.",
988
+ { size: "medium", align: "start", showLabels: false },
989
+ [
990
+ "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.",
991
+ "Link only to accounts you control; each platform's brand rules apply to its mark. " + UNSUPPORTED_DOMAIN_NOTE,
992
+ "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.",
993
+ ],
994
+ {
995
+ items: [
996
+ { platform: "instagram", url: "https://www.instagram.com/shywellness" },
997
+ { platform: "facebook", url: "https://www.facebook.com/shywellness", label: "SHY Wellness on Facebook" },
998
+ { platform: "other", url: "https://shywellness.example/book", label: "Book online" },
999
+ ],
1000
+ size: "medium",
1001
+ align: "start",
1002
+ showLabels: false,
1003
+ },
1004
+ ),
703
1005
  });
704
1006
 
705
1007
  export const COMPONENT_TYPES = Object.freeze(Object.keys(COMPONENT_DEFINITIONS));
@@ -754,8 +1056,18 @@ function referenceSchema(spec) {
754
1056
  ...(spec.patternSource !== undefined
755
1057
  ? { pattern: spec.patternSource, description: `Must be ${spec.patternDescription}.` }
756
1058
  : {}),
1059
+ // A `described()` string says what the text is for (TR01323).
1060
+ ...(spec.description ? { description: spec.description } : {}),
757
1061
  };
758
1062
  case "safe-url":
1063
+ if (spec.https) {
1064
+ return {
1065
+ type: "string",
1066
+ format: "https-url",
1067
+ maxLength: SOCIAL_LINK_URL_MAX_LENGTH,
1068
+ description: `An ${HTTPS_URL_DESCRIPTION.slice(3)}. ${UNSUPPORTED_DOMAIN_NOTE}`,
1069
+ };
1070
+ }
759
1071
  return {
760
1072
  type: "string",
761
1073
  format: spec.allowEmpty === false ? "safe-url" : "safe-url-or-empty",
@@ -783,7 +1095,11 @@ function referenceSchema(spec) {
783
1095
  case "uuid":
784
1096
  return { type: "string", format: "lowercase-uuid" };
785
1097
  case "enum":
786
- return { type: "string", enum: [...spec.values] };
1098
+ return {
1099
+ type: "string",
1100
+ enum: [...spec.values],
1101
+ ...(spec.description ? { description: spec.description } : {}),
1102
+ };
787
1103
  case "number": {
788
1104
  const schema = { type: spec.integer ? "integer" : "number" };
789
1105
  if (spec.values) schema.enum = [...spec.values];
@@ -796,24 +1112,31 @@ function referenceSchema(spec) {
796
1112
  return schema;
797
1113
  }
798
1114
  case "array":
799
- return { type: "array", maxItems: MAX_ITEMS, items: referenceSchema(spec.item) };
1115
+ return {
1116
+ type: "array",
1117
+ ...(spec.minItems !== undefined ? { minItems: spec.minItems } : {}),
1118
+ maxItems: spec.maxItems ?? MAX_ITEMS,
1119
+ items: referenceSchema(spec.item),
1120
+ };
800
1121
  case "object":
801
1122
  return {
802
1123
  type: spec.nullable ? ["object", "null"] : "object",
803
1124
  additionalProperties: false,
804
1125
  properties: referenceProperties(spec.fields),
1126
+ ...(spec.description ? { description: spec.description } : {}),
805
1127
  };
806
1128
  case "image":
807
1129
  return {
808
- type: ["object", "null"],
1130
+ type: spec.required ? "object" : ["object", "null"],
809
1131
  // What a Markdown component fence takes instead (TR01187).
810
- markdownType: ["string", "null"],
811
- description:
812
- "In Markdown, a media path recorded by media upload (such as \"media/photo.webp\"), or null; pages push "
813
- + "stores the image record. A .pm.json document carries that record: keep imageId, src, and urls keys so "
814
- + "the server can rewrite delivery URLs.",
1132
+ markdownType: spec.required ? "string" : ["string", "null"],
1133
+ description: `In Markdown, a media path recorded by media upload (such as "media/photo.webp")${
1134
+ spec.required ? "" : ", or null"
1135
+ }; 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.${
1136
+ spec.altRequired ? " Alt text is required." : ""
1137
+ }${spec.dimensionsRequired ? " Width and height are required." : ""}`,
815
1138
  additionalProperties: false,
816
- properties: referenceProperties(COMPONENT_IMAGE_FIELDS),
1139
+ properties: referenceProperties(imageFieldsFor(spec)),
817
1140
  };
818
1141
  default:
819
1142
  throw new TypeError(`Unknown component schema kind '${spec.kind}'.`);
@@ -866,10 +1189,13 @@ function describe(spec) {
866
1189
  case "object":
867
1190
  return "an object";
868
1191
  case "image":
869
- return "a media path in Markdown, an image object in .pm.json, or null";
1192
+ return spec.required
1193
+ ? "a media path in Markdown or an image object in .pm.json"
1194
+ : "a media path in Markdown, an image object in .pm.json, or null";
870
1195
  case "uuid":
871
1196
  return "a canonical lowercase UUID";
872
1197
  case "safe-url":
1198
+ if (spec.https) return HTTPS_URL_DESCRIPTION;
873
1199
  return `${
874
1200
  spec.allowEmpty === false ? "a" : "an empty string or a"
875
1201
  } safe HTTP(S), mailto, tel, non-protocol-relative root path, fragment, query, or relative URL; backslashes and ASCII control characters are rejected`;
@@ -896,12 +1222,19 @@ function validateValue(spec, value, path, errors) {
896
1222
  switch (spec.kind) {
897
1223
  case "string":
898
1224
  if (typeof value !== "string") fail("a string");
899
- else if (spec.nonWhitespace && value.trim() === "") fail("a string containing non-whitespace text");
1225
+ else if (spec.nonWhitespace && value.trim() === "") {
1226
+ fail(spec.blankDetail ?? "a string containing non-whitespace text");
1227
+ }
900
1228
  else if (spec.maximumLength !== undefined && value.length > spec.maximumLength) {
901
1229
  fail(`a string of at most ${spec.maximumLength} characters`);
902
1230
  } else if (spec.pattern !== undefined && !spec.pattern.test(value)) fail(spec.patternDescription);
903
1231
  return;
904
1232
  case "safe-url":
1233
+ if (spec.https) {
1234
+ // Judged exactly as written: a hand-written document is not trimmed.
1235
+ if (!isSocialLinkUrl(value)) fail(describe(spec));
1236
+ return;
1237
+ }
905
1238
  if (
906
1239
  typeof value !== "string"
907
1240
  || (value === "" && spec.allowEmpty === false)
@@ -961,8 +1294,13 @@ function validateValue(spec, value, path, errors) {
961
1294
  fail("an array");
962
1295
  return;
963
1296
  }
964
- if (value.length > MAX_ITEMS) {
965
- fail(`at most ${MAX_ITEMS} entries`);
1297
+ if (spec.minItems !== undefined && value.length < spec.minItems) {
1298
+ fail(`at least ${spec.minItems} entries`);
1299
+ return;
1300
+ }
1301
+ const maxItems = spec.maxItems ?? MAX_ITEMS;
1302
+ if (value.length > maxItems) {
1303
+ fail(`at most ${maxItems} entries`);
966
1304
  return;
967
1305
  }
968
1306
  value.forEach((entry, index) => validateValue(spec.item, entry, `${path}/${index}`, errors));
@@ -977,13 +1315,17 @@ function validateValue(spec, value, path, errors) {
977
1315
  return;
978
1316
  }
979
1317
  case "image": {
980
- // `ComponentImageData | null` — null is the documented "no image" value.
981
- if (value === null) return;
1318
+ // `ComponentImageData | null` — null is the documented "no image" value
1319
+ // unless the component's images are its content.
1320
+ if (value === null) {
1321
+ if (spec.required) fail("an image object");
1322
+ return;
1323
+ }
982
1324
  if (!isPlainObject(value)) {
983
- fail("an image object or null");
1325
+ fail(spec.required ? "an image object" : "an image object or null");
984
1326
  return;
985
1327
  }
986
- validateFields(COMPONENT_IMAGE_FIELDS, value, path, errors);
1328
+ validateFields(imageFieldsFor(spec), value, path, errors);
987
1329
  return;
988
1330
  }
989
1331
  default:
@@ -1046,6 +1388,44 @@ export function expandComponentAuthoringShorthand(componentType, source) {
1046
1388
  return { source: JSON.stringify({ provider: target.provider, videoId: target.videoId, ...rest }) };
1047
1389
  }
1048
1390
 
1391
+ /**
1392
+ * The card grid's image rules that need two fields (TR01326): imageHeight and
1393
+ * imageAlign only mean something with `contain`, and a contained image is
1394
+ * sized from its stored width and height. The published renderer's readiness
1395
+ * check applies the same rules at the same paths.
1396
+ */
1397
+ function validateCardImageFit(parsed, dataPath, errors) {
1398
+ const fit = parsed.imageFit;
1399
+ // An invalid imageFit was already reported by `validateFields`.
1400
+ if (fit !== undefined && fit !== "cover" && fit !== "contain") return;
1401
+ if (fit !== "contain") {
1402
+ for (const field of ["imageHeight", "imageAlign"]) {
1403
+ if (parsed[field] === undefined) continue;
1404
+ errors.push(contentError(
1405
+ `${dataPath}/${field}`,
1406
+ CODES.componentData,
1407
+ `${field} applies only when imageFit is "contain"; remove ${field}, or set imageFit to "contain".`,
1408
+ ));
1409
+ }
1410
+ return;
1411
+ }
1412
+ if (!Array.isArray(parsed.cards)) return;
1413
+ for (const [index, card] of parsed.cards.entries()) {
1414
+ if (!isPlainObject(card) || !isPlainObject(card.image)) continue;
1415
+ for (const dimension of ["width", "height"]) {
1416
+ const value = card.image[dimension];
1417
+ // A wrong-type dimension was already reported by `validateFields`.
1418
+ if (value !== undefined && typeof value !== "number") continue;
1419
+ if (value !== undefined && value !== 0) continue;
1420
+ errors.push(contentError(
1421
+ `${dataPath}/cards/${index}/image/${dimension}`,
1422
+ CODES.componentData,
1423
+ `With imageFit "contain" a card image needs its ${dimension} in pixels; upload the image again with media upload, or set imageFit to "cover".`,
1424
+ ));
1425
+ }
1426
+ }
1427
+ }
1428
+
1049
1429
  /**
1050
1430
  * Validates a `componentBlock`'s `componentType` and `componentData` pair.
1051
1431
  *
@@ -1097,6 +1477,7 @@ export function validateComponentBlock(componentType, componentData, basePath) {
1097
1477
  const fields = isComponentType(componentType) ? COMPONENT_SHAPES[componentType] : undefined;
1098
1478
  if (!fields) return errors;
1099
1479
  validateFields(fields, parsed, dataPath, errors);
1480
+ if (componentType === "card-grid") validateCardImageFit(parsed, dataPath, errors);
1100
1481
  if (componentType === "card-grid" && Array.isArray(parsed.cards)) {
1101
1482
  for (const [index, card] of parsed.cards.entries()) {
1102
1483
  if (
@@ -1114,6 +1495,77 @@ export function validateComponentBlock(componentType, componentData, basePath) {
1114
1495
  }
1115
1496
  }
1116
1497
  }
1498
+ if (componentType === "product-grid" && Array.isArray(parsed.items)) {
1499
+ // Blank text is absent; a whitespace-only address is already refused by
1500
+ // `safeUrl` above, so trimming it here adds no second error (TR01323).
1501
+ for (const [index, item] of parsed.items.entries()) {
1502
+ if (!isPlainObject(item)) continue;
1503
+ // The editor keeps one feature per line, so a feature is one line.
1504
+ if (Array.isArray(item.features)) {
1505
+ for (const [featureIndex, feature] of item.features.entries()) {
1506
+ if (typeof feature === "string" && /[\r\n]/u.test(feature)) {
1507
+ errors.push(contentError(
1508
+ `${dataPath}/items/${index}/features/${featureIndex}`,
1509
+ CODES.componentData,
1510
+ "A feature is one line; put the rest in another feature or the description.",
1511
+ ));
1512
+ }
1513
+ }
1514
+ }
1515
+ // A wrong-type field or a whitespace-only address was refused by
1516
+ // `validateFields` above; a second error at the same path would say
1517
+ // nothing new, so each rule skips a pair with a wrong-type field and
1518
+ // the other rule still runs, as the generator's readiness check does.
1519
+ const wrongType = (field) => Object.hasOwn(item, field) && typeof item[field] !== "string";
1520
+ if (!wrongType("buttonText") && !wrongType("buttonUrl")) {
1521
+ const text = typeof item.buttonText === "string" ? item.buttonText.trim() : "";
1522
+ const rawUrl = typeof item.buttonUrl === "string" ? item.buttonUrl : "";
1523
+ const url = rawUrl.trim();
1524
+ const urlAlreadyRefused = rawUrl.length > 0 && url.length === 0;
1525
+ if (text.length > 0 && url.length === 0 && !urlAlreadyRefused) {
1526
+ errors.push(contentError(
1527
+ `${dataPath}/items/${index}/buttonUrl`,
1528
+ CODES.componentData,
1529
+ "A product with button text needs a buttonUrl, or clear the text.",
1530
+ ));
1531
+ }
1532
+ if (url.length > 0 && text.length === 0) {
1533
+ errors.push(contentError(
1534
+ `${dataPath}/items/${index}/buttonText`,
1535
+ CODES.componentData,
1536
+ "A product with a buttonUrl needs buttonText, or clear the address.",
1537
+ ));
1538
+ }
1539
+ }
1540
+ if (!wrongType("price") && !wrongType("priceNote")) {
1541
+ const price = typeof item.price === "string" ? item.price.trim() : "";
1542
+ const note = typeof item.priceNote === "string" ? item.priceNote.trim() : "";
1543
+ if (note.length > 0 && price.length === 0) {
1544
+ errors.push(contentError(
1545
+ `${dataPath}/items/${index}/price`,
1546
+ CODES.componentData,
1547
+ "A product with a priceNote needs a price, or clear the note.",
1548
+ ));
1549
+ }
1550
+ }
1551
+ }
1552
+ }
1553
+ if (componentType === "social-links" && Array.isArray(parsed.items)) {
1554
+ // One error per label path: a blank label first, else a Website link
1555
+ // without one. A wrong-type label or platform was refused above (TR01324).
1556
+ for (const [index, item] of parsed.items.entries()) {
1557
+ if (!isPlainObject(item)) continue;
1558
+ if ((Object.hasOwn(item, "label") && typeof item.label !== "string") || typeof item.platform !== "string") continue;
1559
+ const label = typeof item.label === "string" ? item.label : "";
1560
+ const path = `${dataPath}/items/${index}/label`;
1561
+ // The shared whitespace set, so no layer's own trim admits a label another refuses.
1562
+ if (label !== "" && trimSocialLinkInput(label) === "") {
1563
+ errors.push(contentError(path, CODES.componentData, "A label must have text, or leave it out."));
1564
+ } else if (item.platform === "other" && trimSocialLinkInput(label) === "") {
1565
+ errors.push(contentError(path, CODES.componentData, "A Website link needs a label."));
1566
+ }
1567
+ }
1568
+ }
1117
1569
  if (
1118
1570
  componentType === "video-embed" && typeof parsed.provider === "string" && typeof parsed.videoId === "string"
1119
1571
  && (parsed.provider === "youtube" || parsed.provider === "vimeo")
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": 12,
2
+ "version": 14,
3
3
  "section": {
4
4
  "nodeType": "section",
5
5
  "label": "Section",
@@ -548,7 +548,13 @@
548
548
  "card-grid": {
549
549
  "label": "Card Grid",
550
550
  "measure": "wide",
551
- "rootPlacement": "flow"
551
+ "rootPlacement": "flow",
552
+ "imageSpacings": {
553
+ "none": "0rem",
554
+ "small": "1em",
555
+ "medium": "2em",
556
+ "text": "var(--esp-size-small-to-normal, 1rem)"
557
+ }
552
558
  },
553
559
  "image-banner": {
554
560
  "label": "Image Banner",
@@ -569,6 +575,42 @@
569
575
  "label": "Video embed",
570
576
  "measure": "wide",
571
577
  "rootPlacement": "flow"
578
+ },
579
+ "image-row": {
580
+ "label": "Image Row",
581
+ "measure": "wide",
582
+ "rootPlacement": "flow",
583
+ "maxItems": 12,
584
+ "heights": {
585
+ "small": "2.5rem",
586
+ "medium": "4rem",
587
+ "large": "6rem"
588
+ }
589
+ },
590
+ "product-grid": {
591
+ "label": "Product Grid",
592
+ "measure": "wide",
593
+ "rootPlacement": "flow",
594
+ "maxItems": 100,
595
+ "limits": {
596
+ "title": 80,
597
+ "price": 40,
598
+ "priceNote": 60,
599
+ "badge": 40,
600
+ "description": 240,
601
+ "feature": 80,
602
+ "features": 8,
603
+ "buttonText": 40
604
+ },
605
+ "imageAspectRatio": { "width": 16, "height": 9 }
606
+ },
607
+ "social-links": {
608
+ "label": "Social Links",
609
+ "measure": "wide",
610
+ "rootPlacement": "flow",
611
+ "maxItems": 12,
612
+ "limits": { "label": 120 },
613
+ "sizes": { "small": "1.5rem", "medium": "2rem", "large": "3rem" }
572
614
  }
573
615
  },
574
616
  "integrationPlacement": {
@@ -4,6 +4,8 @@ import {
4
4
  isContactOrWebUrl,
5
5
  } from "./contact-url.js";
6
6
  import { hasControlCharacter, isCanonicalUuid, SiteAuthoringError } from "./errors.js";
7
+ import { isSocialLinkUrl, SOCIAL_LINK_URL_MAX_LENGTH, trimSocialLinkInput } from "./social-link-url.js";
8
+ import socialPlatforms from "./social-platforms.json" with { type: "json" };
7
9
  import { FOOTER_COLOR_TOKENS, requireAppearanceColor } from "./theme-validation.js";
8
10
 
9
11
  export const FOOTER_LIMITS = Object.freeze({
@@ -12,6 +14,7 @@ export const FOOTER_LIMITS = Object.freeze({
12
14
  groupsPerColumn: 6,
13
15
  linksPerGroup: 12,
14
16
  bottomLinks: 8,
17
+ socialLinks: 12,
15
18
  richTextParagraphs: 12,
16
19
  richTextRuns: 200,
17
20
  groupHeadingLength: 80,
@@ -56,10 +59,12 @@ export const FOOTER_FIELD_INVENTORY = Object.freeze({
56
59
  "bottomContent",
57
60
  "featureImage",
58
61
  "showBrandText",
62
+ "socialLinks",
59
63
  ]),
60
64
  FooterLinkColumn: Object.freeze(["id", "groups"]),
61
65
  FooterLinkGroup: Object.freeze(["id", "heading", "links"]),
62
66
  FooterLink: Object.freeze(["id", "label", "pageResourceId", "externalUrl"]),
67
+ FooterSocialLink: Object.freeze(["id", "platform", "url", "label"]),
63
68
  FooterRichText: Object.freeze(["paragraphs"]),
64
69
  FooterRichTextParagraph: Object.freeze(["runs"]),
65
70
  FooterRichTextRun: Object.freeze(["text", "bold", "italic", "underline", "link"]),
@@ -82,6 +87,13 @@ export const FOOTER_FIELD_INVENTORY = Object.freeze({
82
87
  FooterFeatureResponsiveImageUrl: Object.freeze(["minWidth", "url"]),
83
88
  });
84
89
 
90
+ /** The platform keys a footer social link may name, in the vocabulary's order; `other` is the Website link. */
91
+ export const SOCIAL_PLATFORM_KEYS = Object.freeze(socialPlatforms.platforms.map((platform) => platform.key));
92
+ export const OTHER_SOCIAL_PLATFORM = "other";
93
+ export const SOCIAL_LINK_URL_REQUIREMENT =
94
+ `must be an absolute https URL without credentials, at most ${SOCIAL_LINK_URL_MAX_LENGTH} characters, `
95
+ + "to a domain name of unaccented letters, digits and hyphens, like https://www.instagram.com/yourname";
96
+
85
97
  export const FOOTER_READ_ONLY_FIELDS = Object.freeze([
86
98
  "light.backgroundImageUrl",
87
99
  "dark.backgroundImageUrl",
@@ -314,6 +326,40 @@ function footerLink(value, path, seenLinkIds, knownPageResourceIds, tolerateStor
314
326
  };
315
327
  }
316
328
 
329
+ // Mirrors the server's social-link rules (TR01324) in the same order: the id
330
+ // shares the footer's seen set, the platform is a vocabulary key, the address
331
+ // is judged after the shared trim, and a label is optional text that is
332
+ // refused when it is only whitespace and required for a Website link.
333
+ function socialLink(value, path, seenLinkIds, tolerateStoredValues) {
334
+ value = object(value, path);
335
+ closed(value, "FooterSocialLink", path);
336
+ const id = stableId(value.id, `${path}.id`, seenLinkIds, "link");
337
+ // A stored platform that later leaves the vocabulary must still pull, as a
338
+ // stored bottom-link address does; only an authored document is refused.
339
+ if (typeof value.platform !== "string" || (!tolerateStoredValues && !SOCIAL_PLATFORM_KEYS.includes(value.platform))) {
340
+ fail("footer.field_invalid", `${path}.platform must be one of ${SOCIAL_PLATFORM_KEYS.join(", ")}.`, `${path}.platform`);
341
+ }
342
+ if (typeof value.url !== "string") {
343
+ fail("footer.url_invalid", `${path}.url ${SOCIAL_LINK_URL_REQUIREMENT}.`, `${path}.url`);
344
+ }
345
+ const url = trimSocialLinkInput(value.url);
346
+ if (!tolerateStoredValues && !isSocialLinkUrl(url)) {
347
+ fail("footer.url_invalid", `${path}.url ${SOCIAL_LINK_URL_REQUIREMENT}.`, `${path}.url`);
348
+ }
349
+ const rawLabel = value.label === undefined ? "" : value.label;
350
+ const label = normalizedSingleLine(rawLabel, FOOTER_LIMITS.linkLabelLength, `${path}.label`, { tolerateStoredValues });
351
+ if (!tolerateStoredValues) {
352
+ // Blank is judged with the shared whitespace set, as the page component and the handler judge it.
353
+ if (rawLabel.length > 0 && trimSocialLinkInput(label) === "") {
354
+ fail("footer.text_required", `${path}.label: A label must have text, or leave it out.`, `${path}.label`);
355
+ }
356
+ if (trimSocialLinkInput(label) === "" && value.platform === OTHER_SOCIAL_PLATFORM) {
357
+ fail("footer.text_required", `${path}.label: A Website link needs a label.`, `${path}.label`);
358
+ }
359
+ }
360
+ return { id, platform: value.platform, url, label };
361
+ }
362
+
317
363
  function footerLinks(values, path, maximum, seenLinkIds, knownPageResourceIds, tolerateStoredValues) {
318
364
  return array(values, path, maximum).map((value, index) =>
319
365
  footerLink(value, `${path}[${index}]`, seenLinkIds, knownPageResourceIds, tolerateStoredValues)
@@ -463,6 +509,7 @@ export function footerDefaults() {
463
509
  bottomContent: { paragraphs: [] },
464
510
  featureImage: undefined,
465
511
  showBrandText: true,
512
+ socialLinks: [],
466
513
  };
467
514
  }
468
515
 
@@ -571,6 +618,8 @@ function normalizeFooterDocument(
571
618
  ),
572
619
  ...(normalizedFeatureImage === undefined ? {} : { featureImage: normalizedFeatureImage }),
573
620
  showBrandText: boolean(value.showBrandText, true, "footerSettings.showBrandText"),
621
+ socialLinks: array(value.socialLinks, "footerSettings.socialLinks", FOOTER_LIMITS.socialLinks)
622
+ .map((link, index) => socialLink(link, `footerSettings.socialLinks[${index}]`, seenLinkIds, tolerateStoredValues)),
574
623
  };
575
624
  }
576
625
 
@@ -684,4 +733,14 @@ export const FOOTER_EXAMPLE = Object.freeze({
684
733
  alt: "A sunlit creative studio with books, artwork, and a writing desk",
685
734
  }),
686
735
  showBrandText: true,
736
+ // The icon row between the bottom content and the bottom links. A label is
737
+ // the link's text; without one the platform's name is read out.
738
+ socialLinks: Object.freeze([
739
+ Object.freeze({
740
+ id: "7c1f3a52-9d4e-4b8a-8f1e-2a6c9d0e4b11",
741
+ platform: "instagram",
742
+ url: "https://www.instagram.com/examplestudio",
743
+ label: "Example Studio on Instagram",
744
+ }),
745
+ ]),
687
746
  });
@@ -88,7 +88,7 @@ export function stableJson(value) {
88
88
  // the canonical form.
89
89
  export function computeFooterDraftHash(settings = {}) {
90
90
  const canonical = [];
91
- append(canonical, "footer-draft-v3");
91
+ append(canonical, "footer-draft-v4");
92
92
  append(canonical, settings.enabled === true);
93
93
  append(canonical, settings.showBrand ?? true);
94
94
  append(canonical, settings.showBrandText ?? true);
@@ -113,6 +113,7 @@ export function computeFooterDraftHash(settings = {}) {
113
113
  if (hasAsideCta) appendLink(canonical, settings.asideCta);
114
114
  appendRichText(canonical, settings.bottomContent);
115
115
  appendLinks(canonical, settings.bottomLinks);
116
+ appendSocialLinks(canonical, settings.socialLinks);
116
117
  appendScheme(canonical, settings.light);
117
118
  appendScheme(canonical, settings.dark);
118
119
 
@@ -132,6 +133,18 @@ function appendLinks(canonical, rawLinks) {
132
133
  for (const link of links) appendLink(canonical, link);
133
134
  }
134
135
 
136
+ function appendSocialLinks(canonical, rawLinks) {
137
+ const links = Array.isArray(rawLinks) ? rawLinks : [];
138
+ append(canonical, links.length);
139
+ for (const rawLink of links) {
140
+ const link = rawLink ?? {};
141
+ append(canonical, normalizeId(link.id));
142
+ append(canonical, link.platform ?? "");
143
+ append(canonical, link.url ?? "");
144
+ append(canonical, link.label ?? "");
145
+ }
146
+ }
147
+
135
148
  // Coalesce rather than default so the canonical form stays total even for a
136
149
  // null entry: a parameter default covers only undefined.
137
150
  function appendLink(canonical, rawLink) {
@@ -35,6 +35,8 @@ import {
35
35
  FOOTER_FIELD_INVENTORY,
36
36
  FOOTER_LIMITS,
37
37
  FOOTER_READ_ONLY_FIELDS,
38
+ SOCIAL_LINK_URL_REQUIREMENT,
39
+ SOCIAL_PLATFORM_KEYS,
38
40
  validateFooterDocument,
39
41
  } from "./footer-contract.js";
40
42
  import {
@@ -589,6 +591,11 @@ const FOOTER_FIELD_DETAILS = Object.freeze({
589
591
  maximumParagraphs: FOOTER_LIMITS.richTextParagraphs,
590
592
  },
591
593
  "FooterSettings.bottomLinks": { type: "FooterLink[]", default: [], maximumItems: FOOTER_LIMITS.bottomLinks },
594
+ "FooterSettings.socialLinks": {
595
+ type: "FooterSocialLink[]",
596
+ default: [],
597
+ maximumItems: FOOTER_LIMITS.socialLinks,
598
+ },
592
599
  "FooterSettings.light": { type: "FooterSchemeSettings", default: "scheme defaults" },
593
600
  "FooterSettings.dark": { type: "FooterSchemeSettings", default: "scheme defaults" },
594
601
  "FooterSettings.featureImage": { type: "FooterFeatureImageSettings|null", default: null },
@@ -609,6 +616,14 @@ const FOOTER_FIELD_DETAILS = Object.freeze({
609
616
  exclusiveWith: "pageResourceId",
610
617
  maximumLength: FOOTER_LIMITS.externalUrlLength,
611
618
  },
619
+ "FooterSocialLink.id": { type: "canonical-lowercase-uuid", required: true },
620
+ "FooterSocialLink.platform": { type: "social-platform-key", required: true, values: SOCIAL_PLATFORM_KEYS },
621
+ "FooterSocialLink.url": { type: "https-url", required: true, maximumLength: FOOTER_LIMITS.externalUrlLength },
622
+ "FooterSocialLink.label": {
623
+ type: "plain-text",
624
+ requiredWhen: "platform is other",
625
+ maximumLength: FOOTER_LIMITS.linkLabelLength,
626
+ },
612
627
  "FooterRichText.paragraphs": { type: "FooterRichTextParagraph[]", default: [] },
613
628
  "FooterRichTextParagraph.runs": { type: "FooterRichTextRun[]", default: [] },
614
629
  "FooterRichTextRun.text": { type: "string", required: true },
@@ -703,6 +718,12 @@ export function getFooterReference() {
703
718
  "Each link has exactly one target: a tracked pageResourceId, or an externalUrl that is an absolute "
704
719
  + "credential-free http/https URL, or a mailto:/tel: contact URL such as tel:+15555550123. A contact URL is "
705
720
  + "published exactly as authored, so write the number the way it should be dialled.",
721
+ socialLinkRule:
722
+ "Each social link names a platform key (" + SOCIAL_PLATFORM_KEYS.join(", ") + ") and a url that "
723
+ + SOCIAL_LINK_URL_REQUIREMENT + ". The label is the link's text and what screen readers announce; "
724
+ + "leave it empty to use the platform's name, and give one to every other (Website) link. Domain names "
725
+ + "with accented or non-English letters are not supported here; use a bottom link for that site. "
726
+ + "The row renders between the bottom content and the bottom links.",
706
727
  imageRule:
707
728
  "Image ids come from pull or media upload. Feature images require alt text; background art is decorative.",
708
729
  readOnlyProjections: FOOTER_READ_ONLY_FIELDS,
@@ -837,7 +858,8 @@ export function formatPresentationReference(reference) {
837
858
  ).join("\n\n");
838
859
  return reference.title + "\nUsage: " + reference.usage + "\nWorkspace: "
839
860
  + reference.workspaceFile + " :: " + reference.workspacePath + "\n\nClosed schema:\n"
840
- + messages + "\n\nTargets: " + reference.targetRule + "\nImages: " + reference.imageRule
861
+ + messages + "\n\nTargets: " + reference.targetRule + "\nSocial links: " + reference.socialLinkRule
862
+ + "\nImages: " + reference.imageRule
841
863
  + "\nConcurrency: " + reference.concurrency
842
864
  + "\nTheme interaction: " + reference.themeInteraction + "\nRead-only projections: "
843
865
  + reference.readOnlyProjections.join(", ") + "\n\nWorkflow:\n"
@@ -95,7 +95,7 @@ import {
95
95
 
96
96
  export { getAppearanceReference, getFooterReference, getSettingsReference, getThemeReference };
97
97
 
98
- export const REFERENCE_VERSION = 47;
98
+ export const REFERENCE_VERSION = 49;
99
99
 
100
100
  // The date-limit examples `help forms` prints; tests run them through the form validator.
101
101
  export const FORM_DATE_WINDOW_FIELD = Object.freeze({
@@ -1938,6 +1938,7 @@ function schemaConstraints(schema) {
1938
1938
  const constraints = [];
1939
1939
  if (schema.minimum !== undefined) constraints.push(`minimum ${schema.minimum}`);
1940
1940
  if (schema.maximum !== undefined) constraints.push(`maximum ${schema.maximum}`);
1941
+ if (schema.minItems !== undefined) constraints.push(`minimum ${schema.minItems} items`);
1941
1942
  if (schema.maxItems !== undefined) constraints.push(`maximum ${schema.maxItems} items`);
1942
1943
  if (schema.minLength !== undefined) constraints.push(`minimum length ${schema.minLength}`);
1943
1944
  if (schema.maxLength !== undefined) constraints.push(`maximum length ${schema.maxLength}`);
@@ -1953,7 +1954,12 @@ function schemaConstraints(schema) {
1953
1954
 
1954
1955
  function formatSchemaLine(name, schema, required, indent) {
1955
1956
  const constraints = schemaConstraints(schema);
1956
- const label = schema.markdownType ? "media path | null in Markdown; object | null in .pm.json" : schemaLabel(schema);
1957
+ // A required image (TR01322) takes no null in either form.
1958
+ const label = schema.markdownType
1959
+ ? Array.isArray(schema.type)
1960
+ ? "media path | null in Markdown; object | null in .pm.json"
1961
+ : "media path in Markdown; object in .pm.json"
1962
+ : schemaLabel(schema);
1957
1963
  return `${indent}${name.padEnd(Math.max(1, 28 - indent.length))} ${label}; ${
1958
1964
  required ? "required" : "optional"
1959
1965
  }${constraints.length > 0 ? `; ${constraints.join("; ")}` : ""}`;
@@ -159,7 +159,9 @@ function addHtmlLinks(links, html, currentPagePath) {
159
159
  }
160
160
  function isUrlProperty(name) {
161
161
  const lower = name.toLowerCase();
162
- return lower === "href" || lower === "url" || lower === "link" || lower === "pagepath";
162
+ // Component buttons and cards (cta, card-grid, product-grid; TR01323).
163
+ return lower === "href" || lower === "url" || lower === "link" || lower === "pagepath"
164
+ || lower === "buttonurl" || lower === "linkurl";
163
165
  }
164
166
  function addEncodedComponentLinks(links, value, currentPagePath) {
165
167
  const decoded = htmlDecodeRepeatedly(value);
@@ -0,0 +1,160 @@
1
+ // Generated from shared/social-link-url.ts by scripts/sync-field-validation.mjs. Do not edit.
2
+ /**
3
+ * The one owner of which addresses a social link accepts (TR01324).
4
+ *
5
+ * A social link is an absolute https URL to a plain domain name. The rule is
6
+ * literal on purpose: every verdict a URL parser might decide differently in
7
+ * JavaScript and .NET (case folding, IDNA mapping, IPv4 shorthand, length
8
+ * limits, whitespace) is decided here by explicit character checks first, and
9
+ * the parser is only asked whether what remains parses at all. That keeps the
10
+ * CLI, the generator, the editor and the API's own port in agreement, which
11
+ * `shared/social-link-url-corpus.json` proves in each of them.
12
+ *
13
+ * It is pure and dependency-free. The generator and the site-authoring CLI
14
+ * cannot import this file, so `node scripts/sync-field-validation.mjs` writes
15
+ * their copies; edit only this file and re-run it. The editor imports it by
16
+ * relative path.
17
+ */
18
+ /** The longest address stored anywhere a social link lives. */
19
+ export const SOCIAL_LINK_URL_MAX_LENGTH = 2048;
20
+ /**
21
+ * The whitespace a social link may not contain and a typed value is trimmed
22
+ * of: the .NET `char.IsWhiteSpace` set, which the footer's single-line
23
+ * normalisation already mirrors, plus U+FEFF, which JavaScript's `trim` strips
24
+ * and .NET's does not.
25
+ */
26
+ export function isSocialLinkWhiteSpace(codeUnit) {
27
+ return (codeUnit >= 0x0009 && codeUnit <= 0x000d)
28
+ || codeUnit === 0x0020
29
+ || codeUnit === 0x0085
30
+ || codeUnit === 0x00a0
31
+ || codeUnit === 0x1680
32
+ || (codeUnit >= 0x2000 && codeUnit <= 0x200a)
33
+ || codeUnit === 0x2028
34
+ || codeUnit === 0x2029
35
+ || codeUnit === 0x202f
36
+ || codeUnit === 0x205f
37
+ || codeUnit === 0x3000
38
+ || codeUnit === 0xfeff;
39
+ }
40
+ /** Strips the whitespace set above from both ends; what a typed address is judged on. */
41
+ export function trimSocialLinkInput(value) {
42
+ let start = 0;
43
+ let end = value.length;
44
+ while (start < end && isSocialLinkWhiteSpace(value.charCodeAt(start)))
45
+ start += 1;
46
+ while (end > start && isSocialLinkWhiteSpace(value.charCodeAt(end - 1)))
47
+ end -= 1;
48
+ return value.slice(start, end);
49
+ }
50
+ /**
51
+ * Control characters, the backslash, and the default-ignorable and format
52
+ * code points that can hide inside an address; none has a place in a link.
53
+ */
54
+ function isRefusedCodeUnit(codeUnit) {
55
+ return codeUnit <= 0x001f
56
+ || (codeUnit >= 0x007f && codeUnit <= 0x009f)
57
+ || codeUnit === 0x005c
58
+ || codeUnit === 0x00ad
59
+ || codeUnit === 0x034f
60
+ || (codeUnit >= 0x200b && codeUnit <= 0x200f)
61
+ || (codeUnit >= 0x202a && codeUnit <= 0x202e)
62
+ || (codeUnit >= 0x2060 && codeUnit <= 0x206f)
63
+ || (codeUnit >= 0xfe00 && codeUnit <= 0xfe0f)
64
+ || codeUnit >= 0xfff0;
65
+ }
66
+ /** Folds only A to Z, so no runtime's case mapping (U+212A to k, U+0130) can reach a verdict; the editors' field copy reads it too. */
67
+ export function asciiLower(value) {
68
+ let result = "";
69
+ for (let index = 0; index < value.length; index += 1) {
70
+ const codeUnit = value.charCodeAt(index);
71
+ result += codeUnit >= 0x41 && codeUnit <= 0x5a ? String.fromCharCode(codeUnit + 0x20) : value[index];
72
+ }
73
+ return result;
74
+ }
75
+ const LABEL_RE = /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/;
76
+ /** A final label that is a number in decimal or hex makes the host an IPv4 form, which is refused. */
77
+ const NUMERIC_FINAL_LABEL_RE = /^(?:[0-9]+|0[xX][0-9a-fA-F]*)$/;
78
+ const PORT_RE = /^[0-9]+$/;
79
+ const HOST_MAX_LENGTH = 253;
80
+ const LABEL_MAX_LENGTH = 63;
81
+ function isAcceptedAuthority(authority) {
82
+ if (authority.length === 0)
83
+ return false;
84
+ for (let index = 0; index < authority.length; index += 1) {
85
+ const codeUnit = authority.charCodeAt(index);
86
+ if (codeUnit > 0x7f)
87
+ return false;
88
+ const character = authority[index];
89
+ if (character === "@" || character === "%" || character === "_")
90
+ return false;
91
+ }
92
+ let host = authority;
93
+ const colon = authority.indexOf(":");
94
+ if (colon !== -1) {
95
+ host = authority.slice(0, colon);
96
+ const port = authority.slice(colon + 1);
97
+ if (!PORT_RE.test(port))
98
+ return false;
99
+ const portNumber = Number(port);
100
+ if (portNumber < 1 || portNumber > 65535)
101
+ return false;
102
+ }
103
+ if (host.length > HOST_MAX_LENGTH)
104
+ return false;
105
+ const labels = host.split(".");
106
+ if (labels.length < 2)
107
+ return false;
108
+ for (const label of labels) {
109
+ if (label.length < 1 || label.length > LABEL_MAX_LENGTH || !LABEL_RE.test(label))
110
+ return false;
111
+ if (asciiLower(label).startsWith("xn--"))
112
+ return false;
113
+ }
114
+ return !NUMERIC_FINAL_LABEL_RE.test(labels[labels.length - 1]);
115
+ }
116
+ /**
117
+ * Whether a value, exactly as written, is an address a social link accepts.
118
+ * Typed input is trimmed with `trimSocialLinkInput` first; a hand-written
119
+ * document is judged as it is.
120
+ */
121
+ export function isSocialLinkUrl(value) {
122
+ if (typeof value !== "string" || value.length === 0 || value.length > SOCIAL_LINK_URL_MAX_LENGTH)
123
+ return false;
124
+ for (let index = 0; index < value.length; index += 1) {
125
+ const codeUnit = value.charCodeAt(index);
126
+ if (isSocialLinkWhiteSpace(codeUnit) || isRefusedCodeUnit(codeUnit))
127
+ return false;
128
+ if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) {
129
+ const next = index + 1 < value.length ? value.charCodeAt(index + 1) : 0;
130
+ if (next < 0xdc00 || next > 0xdfff)
131
+ return false;
132
+ index += 1;
133
+ }
134
+ else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) {
135
+ return false;
136
+ }
137
+ }
138
+ if (asciiLower(value.slice(0, 8)) !== "https://")
139
+ return false;
140
+ const rest = value.slice(8);
141
+ let authorityEnd = rest.length;
142
+ for (const terminator of ["/", "?", "#"]) {
143
+ const at = rest.indexOf(terminator);
144
+ if (at !== -1 && at < authorityEnd)
145
+ authorityEnd = at;
146
+ }
147
+ if (!isAcceptedAuthority(rest.slice(0, authorityEnd)))
148
+ return false;
149
+ try {
150
+ new URL(value);
151
+ }
152
+ catch {
153
+ return false;
154
+ }
155
+ return true;
156
+ }
157
+ /** The address as published: the parsed form, as the footer emits its own links. */
158
+ export function socialLinkHref(value) {
159
+ return new URL(value).toString();
160
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "platforms": [
3
+ { "key": "instagram", "name": "Instagram", "icon": "brand-instagram" },
4
+ { "key": "facebook", "name": "Facebook", "icon": "brand-facebook" },
5
+ { "key": "yelp", "name": "Yelp", "icon": "brand-yelp" },
6
+ { "key": "youtube", "name": "YouTube", "icon": "brand-youtube" },
7
+ { "key": "tiktok", "name": "TikTok", "icon": "brand-tiktok" },
8
+ { "key": "linkedin", "name": "LinkedIn", "icon": "brand-linkedin" },
9
+ { "key": "x", "name": "X", "icon": "brand-x" },
10
+ { "key": "threads", "name": "Threads", "icon": "brand-threads" },
11
+ { "key": "bluesky", "name": "Bluesky", "icon": "brand-bluesky" },
12
+ { "key": "mastodon", "name": "Mastodon", "icon": "brand-mastodon" },
13
+ { "key": "pinterest", "name": "Pinterest", "icon": "brand-pinterest" },
14
+ { "key": "other", "name": "Website", "icon": "external-link" }
15
+ ]
16
+ }