@taprootio/site-authoring 0.27.1 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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": {