@taprootio/site-authoring 0.28.0 → 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.
@@ -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.29.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,22 @@
1
1
  {
2
2
  "entries": [
3
+ {
4
+ "version": "0.29.0",
5
+ "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.",
6
+ "behavior": [
7
+ "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.",
8
+ "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.",
9
+ "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.",
10
+ "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.",
11
+ "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.",
12
+ "The help reference version is 48."
13
+ ],
14
+ "help": [
15
+ "components",
16
+ "component",
17
+ "footer"
18
+ ]
19
+ },
3
20
  {
4
21
  "version": "0.28.0",
5
22
  "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
@@ -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 { type: "string", enum: [...spec.values] };
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 { type: "array", maxItems: MAX_ITEMS, items: referenceSchema(spec.item) };
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
- "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.",
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(COMPONENT_IMAGE_FIELDS),
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 "a media path in Markdown, an image object in .pm.json, or null";
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() === "") fail("a string containing non-whitespace text");
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 > MAX_ITEMS) {
965
- fail(`at most ${MAX_ITEMS} entries`);
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
- if (value === null) return;
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(COMPONENT_IMAGE_FIELDS, value, path, errors);
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": 12,
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": {
@@ -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 = 48;
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
+ }