@taprootio/site-authoring 0.12.7 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -356,7 +356,17 @@ closed with `pages.push_conflict` when a page's stored-state revision has
356
356
  moved since this workspace last reconciled with it, naming both revisions;
357
357
  `pull` first, or push after a refused pull has shown you the site's version.
358
358
  `media upload [path...]` uploads raster files and records component-ready
359
- delivery fields. `nav push`, `theme push`, and `footer push` replace the whole
359
+ delivery fields; MP4, MOV, and WebM files are uploaded as videos. Taproot does not
360
+ encode: the command reads each file with mediabunny, rewrites an H.264 and AAC
361
+ file that is not an MP4 with its index first as one (copying the tracks, never
362
+ re-encoding), declares its codecs, and prints the server's one-line refusal as
363
+ it came for anything it will not accept (a non-H.264 or non-AAC file, one over
364
+ the licence's upload cap). A video is ready when its upload confirms; the CLI sets no poster (Node has no video decoder), so
365
+ choose one on the Videos page, where an upload made there gets one automatically, and set its
366
+ title and caption there too: the poster, caption and shape are the video's own and show wherever
367
+ it is placed. The command reports its `videoId`, title, caption and file name for a `video`
368
+ component (`help component video`), and
369
+ `pages push` refuses a page that places a video the site does not have. `nav push`, `theme push`, and `footer push` replace the whole
360
370
  navigation tree, the complete light/dark theme pair with its appearance
361
371
  settings, and the closed footer document. `pull` writes each scheme's
362
372
  complete effective theme — the stored theme resolved over the same defaults
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taprootio/site-authoring",
3
- "version": "0.12.7",
3
+ "version": "0.14.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",
@@ -28,7 +28,8 @@
28
28
  "README.md"
29
29
  ],
30
30
  "dependencies": {
31
- "@taprootio/espalier": "5.0.0"
31
+ "@taprootio/espalier": "5.0.0",
32
+ "mediabunny": "1.61.1"
32
33
  },
33
34
  "scripts": {
34
35
  "test": "node --test",
package/src/api.js CHANGED
@@ -801,23 +801,104 @@ export function normalizeImage(value) {
801
801
  };
802
802
  }
803
803
 
804
+ export async function requestVideoUpload(client, siteId, body) {
805
+ return requireObject(
806
+ await client.request(sitePath(siteId, "videos/request-upload"), { method: "POST", body }),
807
+ "api.video_contract",
808
+ "video upload request",
809
+ );
810
+ }
811
+
812
+ export async function confirmVideoUpload(client, siteId, uploadId) {
813
+ return normalizeVideo(
814
+ await client.request(sitePath(siteId, "videos/confirm-upload"), { method: "POST", body: { uploadId } }),
815
+ );
816
+ }
817
+
818
+ /**
819
+ * Copies a YouTube or Vimeo thumbnail into the site's images and returns that image,
820
+ * which is still being processed (TR01109). `posterFromImage` shapes it for a component.
821
+ */
822
+ export async function importVideoEmbedPoster(client, siteId, provider, videoId) {
823
+ const response = requireObject(
824
+ await client.request(sitePath(siteId, "videos/import-embed-poster"), {
825
+ method: "POST",
826
+ body: { provider, videoId },
827
+ }),
828
+ "api.video_contract",
829
+ "video embed poster",
830
+ );
831
+ return normalizeImage(response.image);
832
+ }
833
+
834
+ /** The object a component's poster field holds, from an image whose processing is complete. */
835
+ export function posterFromImage(image) {
836
+ return { imageId: image.imageId, src: image.url, urls: image.responsiveUrls, width: image.width, height: image.height, alt: "" };
837
+ }
838
+
839
+ export function normalizeVideo(value) {
840
+ const video = requireObject(value, "api.video_contract", "video");
841
+ return {
842
+ videoId: requireCanonicalUuid(video.videoId, "api.video_contract", "video.videoId"),
843
+ title: text(video.title),
844
+ caption: text(video.caption),
845
+ fileName: text(video.fileName),
846
+ bytes: safeCount(video.bytes),
847
+ durationMilliseconds: safeCount(video.durationMs),
848
+ width: safeCount(video.width),
849
+ height: safeCount(video.height),
850
+ sourceUrl: text(video.sourceUrl),
851
+ };
852
+ }
853
+
854
+ /**
855
+ * The site's videos through `ListVideos`, paged like the image library and
856
+ * bounded the same way. A video is ready the moment it is listed.
857
+ */
858
+ export async function listVideos(client, siteId, requestOptions = {}) {
859
+ const videos = [];
860
+ let pageToken = "";
861
+ for (let request = 0; request < MAXIMUM_LIST_REQUESTS; request += 1) {
862
+ const search = query([["pageSize", LIST_PAGE_SIZE], ["pageToken", pageToken]]);
863
+ const response = requireObject(
864
+ await client.request(sitePath(siteId, `videos${search}`), requestOptions),
865
+ "api.video_contract",
866
+ "site videos",
867
+ );
868
+ const batch = Array.isArray(response.videos) ? response.videos : [];
869
+ for (const entry of batch) videos.push(normalizeVideo(entry));
870
+ pageToken = typeof response.nextPageToken === "string" ? response.nextPageToken : "";
871
+ if (pageToken === "" || batch.length === 0) return { videos, truncated: false };
872
+ if (videos.length >= MAXIMUM_IMAGES) break;
873
+ }
874
+ return { videos, truncated: true };
875
+ }
876
+
804
877
  /**
805
878
  * Processing is observed through `ListSiteImages`; `GetImageById` is
806
879
  * session-only under TR00602's read list, so a key-authorized client watches
807
880
  * the library rather than one image.
808
881
  *
882
+ * `imageIds` (at most 100) reads exactly those images instead of the library, in any
883
+ * purpose: the way to watch an image the library never lists, such as the embed
884
+ * poster the server copied for a push.
885
+ *
809
886
  * `requestOptions` carries a poller's `{ deadline, now }` into *every* page of
810
887
  * the listing. Without it a single read of a large library could spend far more
811
888
  * than the caller's whole budget — the page bound alone permits hundreds of
812
889
  * requests, each with its own request timeout and retry budget — and the
813
890
  * deadline would only be noticed once the read finally returned.
814
891
  */
815
- export async function listSiteImages(client, siteId, requestOptions = {}) {
892
+ export async function listSiteImages(client, siteId, requestOptions = {}, { imageIds = [] } = {}) {
816
893
  const images = [];
817
894
  let pageToken = "";
818
895
  let summary = { totalImages: 0, processingImages: 0 };
819
896
  for (let request = 0; request < MAXIMUM_LIST_REQUESTS; request += 1) {
820
- const search = query([["pageSize", LIST_PAGE_SIZE], ["pageToken", pageToken]]);
897
+ // Asking by id reads exactly those images, whatever their purpose. The library lists the
898
+ // owner's own images only, so a copied embed poster is never in it.
899
+ const search = imageIds.length > 0
900
+ ? query([["imageIds", imageIds]])
901
+ : query([["pageSize", LIST_PAGE_SIZE], ["pageToken", pageToken]]);
821
902
  const response = requireObject(
822
903
  await client.request(sitePath(siteId, `images${search}`), requestOptions),
823
904
  "api.image_contract",
@@ -946,6 +1027,10 @@ export async function getPublishingReadiness(client, siteId, selection = {}) {
946
1027
  blockedPageCount: safeCount(response.blockedPageCount),
947
1028
  hasCandidateChanges: response.hasCandidateChanges === true,
948
1029
  hasSuccessfulStagingDeployment: response.hasSuccessfulStagingDeployment === true,
1030
+ // Site-wide inputs frozen with every release that differ from production's.
1031
+ redirectsChanged: response.redirectsChanged === true,
1032
+ formsChanged: response.formsChanged === true,
1033
+ videosChanged: response.videosChanged === true,
949
1034
  blockers,
950
1035
  };
951
1036
  }
package/src/cli.js CHANGED
@@ -367,7 +367,7 @@ const VERBS = Object.freeze([
367
367
  // (TR00790).
368
368
  surfaceCapabilities: { [SURFACE_DOCS_PRESENTATION]: [CAPABILITY_DESIGN] },
369
369
  tokens: ["media", "upload"],
370
- summary: "Upload media and wait for processing to finish.",
370
+ summary: "Upload images and videos; images are waited on until processed, and a video is ready when it confirms.",
371
371
  positionals: "paths",
372
372
  note: "Positional arguments name the files or directories to upload;"
373
373
  + " with none, the workspace's media/ directory is walked.",
package/src/constants.js CHANGED
@@ -225,6 +225,9 @@ export const LIMITS = Object.freeze({
225
225
  redirectMapResponseBytes: 8 * 1024 * 1024,
226
226
  requestMilliseconds: 60_000,
227
227
  uploadMilliseconds: 5 * 60_000,
228
+ // A video is up to the licence's upload cap (at most 250 MB) over one PUT, which
229
+ // takes minutes where an image takes seconds.
230
+ videoUploadMilliseconds: 2 * 60 * 60_000,
228
231
  deploymentMilliseconds: 30 * 60_000,
229
232
  previewMilliseconds: 5 * 60_000,
230
233
  pollIntervalMilliseconds: 2_000,
@@ -15,6 +15,8 @@ import {
15
15
  isUuid,
16
16
  pointerSegment,
17
17
  } from "./vocabulary.js";
18
+ import { normalizeCssLengthOverride } from "./media-presentation.js";
19
+ import { isVideoEmbedId, parseVideoEmbedUrl, VIDEO_EMBED_REFUSAL } from "./video-embed-url.js";
18
20
 
19
21
  /**
20
22
  * The eight canonical `componentBlock` data shapes. Seven remain mirrored
@@ -54,6 +56,8 @@ const LATEST_POST_PAGE_TYPES = Object.freeze([
54
56
  const REGISTERED_TEXTURE_NAME_RE = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u;
55
57
 
56
58
  const str = (options = {}) => Object.freeze({ kind: "string", ...options });
59
+ /** A string bounded in length, optionally constrained to a pattern the published renderer also applies. */
60
+ const boundedStr = (maximumLength, options = {}) => str({ maximumLength, ...options });
57
61
  const bool = () => Object.freeze({ kind: "boolean" });
58
62
  const num = (options = {}) => Object.freeze({ kind: "number", ...options });
59
63
  const enumOf = (...values) => Object.freeze({ kind: "enum", values: Object.freeze(values) });
@@ -61,6 +65,8 @@ const list = (item) => Object.freeze({ kind: "array", item });
61
65
  const shape = (fields, options = {}) => Object.freeze({ kind: "object", fields: Object.freeze(fields), ...options });
62
66
  const image = () => Object.freeze({ kind: "image" });
63
67
  const safeUrl = (options = {}) => Object.freeze({ kind: "safe-url", ...options });
68
+ /** A CSS length the published renderer accepts as a per-block override; `allowNone` adds `none` (a max height). */
69
+ const cssLength = (options = {}) => Object.freeze({ kind: "css-length", ...options });
64
70
  const textureName = () => Object.freeze({ kind: "texture-name", maximumLength: 64 });
65
71
  const withOmission = (spec, whenOmitted) => Object.freeze({ ...spec, whenOmitted: Object.freeze(whenOmitted) });
66
72
 
@@ -279,6 +285,47 @@ const IMAGE_BANNER = Object.freeze({
279
285
  imageMotion: enumOf("none", "slow-zoom", "drift-start", "drift-end", "parallax-subtle", "parallax"),
280
286
  });
281
287
 
288
+ /** The `W:H` form of a video's aspect ratio, as the published renderer and the editor accept it. */
289
+ const VIDEO_ASPECT_RATIO_PATTERN = "^[1-9][0-9]{0,2}:[1-9][0-9]{0,2}$";
290
+ const VIDEO_ASPECT_RATIO_RE = new RegExp(VIDEO_ASPECT_RATIO_PATTERN, "u");
291
+
292
+ /** Shown with both video blocks: the two optional fields follow the same site settings images use. */
293
+ const MEDIA_PRESENTATION_HELP =
294
+ "Leave maxHeight and borderWidth out to follow the site's image max height and border; set maxHeight (a viewport or fixed height such as 50vh or 30rem, or none) or borderWidth (such as 4px, or 0 for no border) to override them for this block. The frame is never wider than the video's own pixel size.";
295
+
296
+ /**
297
+ * `VideoData`. The page names a library video by id and nothing about where it
298
+ * is served: the generator resolves the id at publish time and refuses a video
299
+ * that is not `ready` or not this site's own. Delivery URLs are not authorable
300
+ * here, so a `delivery` key is refused like any other unknown field. The poster,
301
+ * caption and ratio are the video's own (TR01184), so a block carrying one is
302
+ * refused the same way.
303
+ */
304
+ const VIDEO = Object.freeze({
305
+ videoId: Object.freeze({ kind: "uuid", required: true }),
306
+ maxHeight: cssLength({ allowNone: true }),
307
+ borderWidth: cssLength(),
308
+ });
309
+
310
+ /**
311
+ * `VideoEmbedData`. A YouTube or Vimeo video is stored as its provider and id,
312
+ * never as a link: the published page builds the player URL from the pair. A
313
+ * Markdown component fence may name the video by `url` instead, and
314
+ * `expandComponentAuthoringShorthand` reduces it to the pair before validation.
315
+ */
316
+ const VIDEO_EMBED = Object.freeze({
317
+ provider: Object.freeze({ ...enumOf("youtube", "vimeo"), required: true }),
318
+ videoId: boundedStr(32, { required: true, nonWhitespace: true }),
319
+ title: boundedStr(200, { required: true, nonWhitespace: true }),
320
+ poster: image(),
321
+ aspectRatio: str({
322
+ pattern: VIDEO_ASPECT_RATIO_RE,
323
+ patternSource: VIDEO_ASPECT_RATIO_PATTERN,
324
+ patternDescription: "two whole numbers of at most three digits joined by a colon, such as 16:9",
325
+ }),
326
+ maxHeight: cssLength({ allowNone: true }),
327
+ borderWidth: cssLength(),
328
+ });
282
329
  const SPACER = Object.freeze({
283
330
  height: enumOf("small", "medium", "large"),
284
331
  showDivider: bool(),
@@ -301,8 +348,16 @@ function deepFreeze(value) {
301
348
  return Object.freeze(value);
302
349
  }
303
350
 
304
- function component(fields, displayName, summary, defaultData, accessibility, example) {
305
- return deepFreeze({ fields, displayName, summary, defaultData, accessibility, example });
351
+ function component(fields, displayName, summary, defaultData, accessibility, example, authoring) {
352
+ return deepFreeze({
353
+ fields,
354
+ displayName,
355
+ summary,
356
+ defaultData,
357
+ accessibility,
358
+ example,
359
+ ...(authoring ? { authoring } : {}),
360
+ });
306
361
  }
307
362
 
308
363
  /**
@@ -576,6 +631,47 @@ const COMPONENT_DEFINITIONS = Object.freeze({
576
631
  ],
577
632
  { formKey: "contact" },
578
633
  ),
634
+ "video": component(
635
+ VIDEO,
636
+ "Video",
637
+ "A video from the site's library that plays when the visitor presses play; the page reads only the start of the file before then.",
638
+ {},
639
+ [
640
+ "The poster, the caption and the shape are the video's own and show wherever it is placed: set the title, caption and poster on the site's Videos page. The caption tells a visitor what the video is about and is also the player's accessible name.",
641
+ "A video with spoken content needs captions or a transcript in the page text; the player does not generate them.",
642
+ ],
643
+ {
644
+ videoId: "7c5e2b1a-9d3f-4a68-b0c4-1e2f3a4b5c6d",
645
+ },
646
+ { notes: [MEDIA_PRESENTATION_HELP] },
647
+ ),
648
+ "video-embed": component(
649
+ VIDEO_EMBED,
650
+ "Video embed",
651
+ "A YouTube or Vimeo video shown as a poster and a play button; nothing from the provider loads until the visitor presses play.",
652
+ {},
653
+ [
654
+ "Set title to what the video is about; it names the play button and the player for assistive technology.",
655
+ "A video with spoken content needs captions turned on at the provider or a transcript in the page text.",
656
+ "Leave poster out and 'pages push' copies the provider's thumbnail into the site's images and sets it (once per video), or set poster to one of the site's images. The published page never loads anything from the provider before the visitor presses play; if the copy is refused the block is sent with no poster.",
657
+ ],
658
+ {
659
+ provider: "youtube",
660
+ videoId: "dQw4w9WgXcQ",
661
+ title: "A walk through the studio before your first class.",
662
+ aspectRatio: "16:9",
663
+ },
664
+ {
665
+ notes: [
666
+ MEDIA_PRESENTATION_HELP,
667
+ "In a Markdown fence, give the video as url (a YouTube or Vimeo link) and title instead of provider and videoId; the CLI reduces the link to provider and videoId before it validates or sends the page.",
668
+ "A link that is not an accepted YouTube or Vimeo video gets a one-line refusal that names the links it accepts.",
669
+ "url together with provider or videoId is refused: give the video one way.",
670
+ "Stored blocks and .pm.json sources hold only provider and videoId; url is never stored.",
671
+ ],
672
+ markdownExample: "```component:video-embed\n{\"url\": \"https://youtu.be/dQw4w9WgXcQ\", \"title\": \"A walk through the studio before your first class.\"}\n```",
673
+ },
674
+ ),
579
675
  });
580
676
 
581
677
  export const COMPONENT_TYPES = Object.freeze(Object.keys(COMPONENT_DEFINITIONS));
@@ -597,6 +693,10 @@ function referenceSchema(spec) {
597
693
  return {
598
694
  type: "string",
599
695
  ...(spec.nonWhitespace ? { minLength: 1, description: "Must contain non-whitespace text." } : {}),
696
+ ...(spec.maximumLength !== undefined ? { maxLength: spec.maximumLength } : {}),
697
+ ...(spec.patternSource !== undefined
698
+ ? { pattern: spec.patternSource, description: `Must be ${spec.patternDescription}.` }
699
+ : {}),
600
700
  };
601
701
  case "safe-url":
602
702
  return {
@@ -606,6 +706,8 @@ function referenceSchema(spec) {
606
706
  spec.allowEmpty === false ? "An" : "Empty, or an"
607
707
  } explicit HTTP(S), mailto, or tel URL; a root-relative path that does not begin with //; a fragment; a query; or a relative URL. Backslashes and ASCII control characters are rejected. Contact URLs require a non-empty body and refuse tel:// and mailto:// authority forms.`,
608
708
  };
709
+ case "css-length":
710
+ return { type: "string", description: `Must be ${describe(spec)}.` };
609
711
  case "texture-name":
610
712
  return {
611
713
  type: "string",
@@ -710,6 +812,10 @@ function describe(spec) {
710
812
  return `${
711
813
  spec.allowEmpty === false ? "a" : "an empty string or a"
712
814
  } safe HTTP(S), mailto, tel, non-protocol-relative root path, fragment, query, or relative URL; backslashes and ASCII control characters are rejected`;
815
+ case "css-length":
816
+ return spec.allowNone
817
+ ? "a CSS height such as 60vh or 32rem, or none"
818
+ : "a CSS width such as 2px or 0.25rem, or 0 for no border";
713
819
  case "texture-name":
714
820
  return "a lowercase hyphenated texture identifier of at most 64 characters";
715
821
  default:
@@ -730,6 +836,9 @@ function validateValue(spec, value, path, errors) {
730
836
  case "string":
731
837
  if (typeof value !== "string") fail("a string");
732
838
  else if (spec.nonWhitespace && value.trim() === "") fail("a string containing non-whitespace text");
839
+ else if (spec.maximumLength !== undefined && value.length > spec.maximumLength) {
840
+ fail(`a string of at most ${spec.maximumLength} characters`);
841
+ } else if (spec.pattern !== undefined && !spec.pattern.test(value)) fail(spec.patternDescription);
733
842
  return;
734
843
  case "safe-url":
735
844
  if (
@@ -744,6 +853,10 @@ function validateValue(spec, value, path, errors) {
744
853
  fail(describe(spec));
745
854
  }
746
855
  return;
856
+ case "css-length":
857
+ // The renderer drops a value outside this grammar silently, so it is refused here instead.
858
+ if (normalizeCssLengthOverride(value, { allowNone: spec.allowNone === true }) === null) fail(describe(spec));
859
+ return;
747
860
  case "texture-name":
748
861
  if (
749
862
  typeof value !== "string"
@@ -847,6 +960,31 @@ function validateFields(fields, value, path, errors) {
847
960
  }
848
961
  }
849
962
 
963
+ /**
964
+ * Lets a Markdown component fence name a video embed by link. Returns the
965
+ * fence's JSON source with `url` replaced by the `provider` and `videoId` it
966
+ * reduces to, or `{ error }` with the one-line reason a link is refused. Any
967
+ * other component, and a source that is not a JSON object, comes back as it was
968
+ * so validation reports it in its usual words.
969
+ */
970
+ export function expandComponentAuthoringShorthand(componentType, source) {
971
+ if (componentType !== "video-embed") return { source };
972
+ let parsed;
973
+ try {
974
+ parsed = JSON.parse(source);
975
+ } catch {
976
+ return { source };
977
+ }
978
+ if (!isPlainObject(parsed) || !Object.hasOwn(parsed, "url")) return { source };
979
+ if (Object.hasOwn(parsed, "provider") || Object.hasOwn(parsed, "videoId")) {
980
+ return { error: "Give the video as url, or as provider and videoId, not both." };
981
+ }
982
+ const target = parseVideoEmbedUrl(parsed.url);
983
+ if (!target) return { error: VIDEO_EMBED_REFUSAL };
984
+ const { url: _url, ...rest } = parsed;
985
+ return { source: JSON.stringify({ provider: target.provider, videoId: target.videoId, ...rest }) };
986
+ }
987
+
850
988
  /**
851
989
  * Validates a `componentBlock`'s `componentType` and `componentData` pair.
852
990
  *
@@ -915,6 +1053,17 @@ export function validateComponentBlock(componentType, componentData, basePath) {
915
1053
  }
916
1054
  }
917
1055
  }
1056
+ if (
1057
+ componentType === "video-embed" && typeof parsed.provider === "string" && typeof parsed.videoId === "string"
1058
+ && (parsed.provider === "youtube" || parsed.provider === "vimeo")
1059
+ && !isVideoEmbedId(parsed.provider, parsed.videoId)
1060
+ ) {
1061
+ errors.push(contentError(
1062
+ `${dataPath}/videoId`,
1063
+ CODES.componentData,
1064
+ `Component data field 'videoId' must be a valid ${parsed.provider} video id. ${VIDEO_EMBED_REFUSAL}`,
1065
+ ));
1066
+ }
918
1067
  return errors;
919
1068
  }
920
1069
 
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": 9,
2
+ "version": 12,
3
3
  "section": {
4
4
  "nodeType": "section",
5
5
  "label": "Section",
@@ -559,6 +559,16 @@
559
559
  "label": "Form",
560
560
  "measure": "wide",
561
561
  "rootPlacement": "flow"
562
+ },
563
+ "video": {
564
+ "label": "Video",
565
+ "measure": "wide",
566
+ "rootPlacement": "flow"
567
+ },
568
+ "video-embed": {
569
+ "label": "Video embed",
570
+ "measure": "wide",
571
+ "rootPlacement": "flow"
562
572
  }
563
573
  },
564
574
  "integrationPlacement": {
@@ -1,5 +1,10 @@
1
1
  import { SiteAuthoringError } from "../errors.js";
2
- import { canonicalizeComponentData, isComponentType, validateComponentBlock } from "./components.js";
2
+ import {
3
+ canonicalizeComponentData,
4
+ expandComponentAuthoringShorthand,
5
+ isComponentType,
6
+ validateComponentBlock,
7
+ } from "./components.js";
3
8
  import {
4
9
  FREE_FORM_SECTION_REGISTRY,
5
10
  normalizeFreeFormSectionBackground,
@@ -869,7 +874,12 @@ function componentNode(componentType, body, line) {
869
874
  line.number,
870
875
  );
871
876
  }
872
- const source = body.trim() === "" ? "{}" : body;
877
+ const authored = body.trim() === "" ? "{}" : body;
878
+ const expanded = expandComponentAuthoringShorthand(componentType, authored);
879
+ if (expanded.error !== undefined) {
880
+ throw fail(CODES.componentData, "component block", expanded.error, line.number);
881
+ }
882
+ const source = expanded.source;
873
883
  const errors = validateComponentBlock(componentType, source, "");
874
884
  if (errors.length > 0) {
875
885
  throw fail(errors[0].code, "component block", errors[0].message, line.number);
@@ -0,0 +1,123 @@
1
+ // Generated from shared/media-presentation.ts by scripts/sync-field-validation.mjs. Do not edit.
2
+ /**
3
+ * The one owner of how an image, a video and a video embed are sized and
4
+ * bordered: the site-default option lists and normalizers, the per-block
5
+ * override grammar, and the CSS values built from them.
6
+ *
7
+ * Images, the Video block and the Video embed block read the same two site
8
+ * settings (max height, border width) and take the same two optional per-block
9
+ * overrides, so they share these functions and the custom properties they set.
10
+ *
11
+ * It is pure and dependency-free. The generator, the editor and the
12
+ * site-authoring CLI cannot all import this file, so
13
+ * `node scripts/sync-field-validation.mjs` writes their copies; edit only this
14
+ * file and re-run it. The API mirrors the site-setting ranges in
15
+ * `SiteSettings.cs`.
16
+ */
17
+ /** The "no explicit choice" sentinel stored in place of an override. */
18
+ export const TIPTAP_SITE_DEFAULT = "site-default";
19
+ export const DEFAULT_TIPTAP_IMAGE_MAX_HEIGHT_VH = 60;
20
+ export const TIPTAP_IMAGE_MAX_HEIGHT_OPTIONS = [45, 60, 75, 90];
21
+ // Mirrors TiptapImageBorder in the API's SiteSettings.cs. 2px matches
22
+ // esp-image's built-in border so untouched sites keep their current look.
23
+ export const TIPTAP_IMAGE_BORDER_WIDTH_MIN = 0;
24
+ export const TIPTAP_IMAGE_BORDER_WIDTH_MAX = 20;
25
+ export const DEFAULT_TIPTAP_IMAGE_BORDER_WIDTH_PX = 2;
26
+ /** The custom property every sized media block reads its max height from. */
27
+ export const MEDIA_MAX_HEIGHT_PROPERTY = "--taproot-article-image-max-height";
28
+ /** The custom property every bordered media block reads its border from. */
29
+ export const MEDIA_BORDER_PROPERTY = "--esp-image-border";
30
+ /** A number, or text read as one (a cleared field reads as 0); anything else is not a number. */
31
+ function optionNumber(value) {
32
+ if (typeof value === "number")
33
+ return value;
34
+ return typeof value === "string" ? Number(value) : Number.NaN;
35
+ }
36
+ function isMaxHeightOption(value) {
37
+ return TIPTAP_IMAGE_MAX_HEIGHT_OPTIONS.includes(optionNumber(value));
38
+ }
39
+ /** The site max height (in vh) for any stored value; anything outside the options is the default. */
40
+ export function normalizeImageMaxHeight(value) {
41
+ return isMaxHeightOption(value)
42
+ ? optionNumber(value)
43
+ : DEFAULT_TIPTAP_IMAGE_MAX_HEIGHT_VH;
44
+ }
45
+ /** An explicit per-image max height choice, or null for the site default, the sentinel or an invalid value. */
46
+ export function explicitImageMaxHeight(value) {
47
+ if (value === TIPTAP_SITE_DEFAULT || value === undefined || value === null)
48
+ return null;
49
+ return isMaxHeightOption(value) ? optionNumber(value) : null;
50
+ }
51
+ /** The site border width in pixels for any stored value; anything outside the range is the default. */
52
+ export function normalizeImageBorderWidthPx(value) {
53
+ const parsed = optionNumber(value);
54
+ if (!Number.isFinite(parsed))
55
+ return DEFAULT_TIPTAP_IMAGE_BORDER_WIDTH_PX;
56
+ const width = Math.trunc(parsed);
57
+ return width >= TIPTAP_IMAGE_BORDER_WIDTH_MIN && width <= TIPTAP_IMAGE_BORDER_WIDTH_MAX
58
+ ? width
59
+ : DEFAULT_TIPTAP_IMAGE_BORDER_WIDTH_PX;
60
+ }
61
+ const CSS_LENGTH_UNITS = "px|em|rem|ch|ex|vw|vh|svh|lvh|dvh|svw|lvw|dvw|vmin|vmax|%";
62
+ const CSS_LENGTH_RE = new RegExp(`^\\d+(?:\\.\\d+)?(?:${CSS_LENGTH_UNITS})$`, "i");
63
+ const CSS_VAR_RE = /^var\(--[a-z0-9-]+\)$/i;
64
+ const CSS_CALC_TERM = `(?:\\d+(?:\\.\\d+)?(?:${CSS_LENGTH_UNITS})|\\d+(?:\\.\\d+)?|var\\(--[a-z0-9-]+\\))`;
65
+ const CSS_CALC_RE = new RegExp(`^calc\\(\\s*${CSS_CALC_TERM}(?:\\s*[+\\-*/]\\s*${CSS_CALC_TERM})*\\s*\\)$`, "i");
66
+ /**
67
+ * Validate a user-supplied CSS length for per-block presentation overrides
68
+ * (max height, border width). These values are emitted into inline styles on
69
+ * published pages, so anything outside this strict grammar is rejected:
70
+ * unitless `0`, a non-negative length, `var(--token)`, `calc(...)` of those
71
+ * terms, and — when `allowNone` is set — the `none` keyword (max-height).
72
+ * Returns the trimmed value, or null when invalid or set to the site default.
73
+ */
74
+ export function normalizeCssLengthOverride(value, options) {
75
+ if (typeof value !== "string")
76
+ return null;
77
+ const trimmed = value.trim();
78
+ if (!trimmed || trimmed === TIPTAP_SITE_DEFAULT)
79
+ return null;
80
+ if (trimmed === "0")
81
+ return trimmed;
82
+ if (options?.allowNone && trimmed.toLowerCase() === "none")
83
+ return "none";
84
+ if (CSS_LENGTH_RE.test(trimmed))
85
+ return trimmed;
86
+ if (CSS_VAR_RE.test(trimmed))
87
+ return trimmed;
88
+ if (CSS_CALC_RE.test(trimmed))
89
+ return trimmed;
90
+ return null;
91
+ }
92
+ /** The validated max height override of a block (a CSS length or "none"), or null for the site default. */
93
+ export function normalizeMaxHeightOverride(value) {
94
+ return normalizeCssLengthOverride(value, { allowNone: true });
95
+ }
96
+ /** The validated border width override of a block (a CSS length), or null for the site default. */
97
+ export function normalizeBorderWidthOverride(value) {
98
+ return normalizeCssLengthOverride(value);
99
+ }
100
+ /** CSS value for the border hook given a width; zero collapses to none. */
101
+ export function imageBorderValue(width) {
102
+ return /^0(?:\.0+)?(?:[a-z%]+)?$/i.test(width) ? "none" : `${width} solid var(--esp-color-border)`;
103
+ }
104
+ /** The site-default border as CSS, from the stored pixel width. */
105
+ export function siteDefaultBorderValue(widthPx) {
106
+ return imageBorderValue(`${normalizeImageBorderWidthPx(widthPx)}px`);
107
+ }
108
+ /**
109
+ * The inline custom-property declarations that carry a block's overrides.
110
+ * Only an explicit, valid override is emitted, so an absent one keeps
111
+ * following the site default.
112
+ */
113
+ export function mediaOverrideStyleParts(overrides) {
114
+ const parts = [];
115
+ const maxHeight = normalizeMaxHeightOverride(overrides.maxHeight);
116
+ const borderWidth = normalizeBorderWidthOverride(overrides.borderWidth);
117
+ // A unitless zero would make the video frame's width calc invalid; 0px means the same to an image.
118
+ if (maxHeight)
119
+ parts.push(`${MEDIA_MAX_HEIGHT_PROPERTY}: ${maxHeight === "0" ? "0px" : maxHeight}`);
120
+ if (borderWidth)
121
+ parts.push(`${MEDIA_BORDER_PROPERTY}: ${imageBorderValue(borderWidth)}`);
122
+ return parts;
123
+ }