@taprootio/site-authoring 0.12.6 → 0.13.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
@@ -188,7 +188,7 @@ Which verbs apply depends on the site:
188
188
  `pull` snapshots its four settings documents, `theme push`, `footer push`,
189
189
  and `media upload` author them, and `deploy` stages and promotes them.
190
190
  Its pages, navigation, and redirects come from the Docs artifact, so
191
- `pages push`, `nav push`, the `redirects` verbs, `approve`, and the
191
+ `pages push`, `nav push`, the `redirects` and `forms` verbs, `approve`, and the
192
192
  `preview` verbs refuse with `surface.presentation_only` before any request.
193
193
  - A **prebuilt Docs** site (`none`) serves its artifact unchanged and takes no
194
194
  verb; each refuses with `surface.none`.
@@ -356,7 +356,15 @@ 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; the command
366
+ reports its `videoId` for a `video` component (`help component video`), and
367
+ `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
368
  navigation tree, the complete light/dark theme pair with its appearance
361
369
  settings, and the closed footer document. `pull` writes each scheme's
362
370
  complete effective theme — the stored theme resolved over the same defaults
@@ -373,6 +381,16 @@ validates the file offline and replaces the whole map; `help redirects`
373
381
  states the entry shape (`path`, `kind` redirect or gone, `target`, `status`
374
382
  defaulting to 301), the normalization rule, and every refusal.
375
383
 
384
+ `forms pull` writes each live form to `forms/<key>.json` and records the
385
+ version it read; `forms push` validates every file, refuses a form the site
386
+ changed since (`forms.concurrent_modification`) and creates or updates the
387
+ rest, appending a version when the definition changed. `forms validate` checks
388
+ the files offline against the shared field schema with no credential. Both
389
+ verbs write one canonical key order, so a pulled file pushed back is unchanged.
390
+ Submissions are never pulled. `help forms` states the file shape and every
391
+ refusal, and placing a form on a page is the free-form `form`
392
+ component.
393
+
376
394
  `preview page <page-path-or-id>` renders one persisted draft behind the
377
395
  staging gate and mints a browser handoff URL, reported only in the final JSON
378
396
  result. **Each handoff is single-use.** Opening the URL consumes it; a reused,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taprootio/site-authoring",
3
- "version": "0.12.6",
3
+ "version": "0.13.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": "4.20.1"
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,102 @@ 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
+ bytes: safeCount(video.bytes),
845
+ durationMilliseconds: safeCount(video.durationMs),
846
+ width: safeCount(video.width),
847
+ height: safeCount(video.height),
848
+ sourceUrl: text(video.sourceUrl),
849
+ };
850
+ }
851
+
852
+ /**
853
+ * The site's videos through `ListVideos`, paged like the image library and
854
+ * bounded the same way. A video is ready the moment it is listed.
855
+ */
856
+ export async function listVideos(client, siteId, requestOptions = {}) {
857
+ const videos = [];
858
+ let pageToken = "";
859
+ for (let request = 0; request < MAXIMUM_LIST_REQUESTS; request += 1) {
860
+ const search = query([["pageSize", LIST_PAGE_SIZE], ["pageToken", pageToken]]);
861
+ const response = requireObject(
862
+ await client.request(sitePath(siteId, `videos${search}`), requestOptions),
863
+ "api.video_contract",
864
+ "site videos",
865
+ );
866
+ const batch = Array.isArray(response.videos) ? response.videos : [];
867
+ for (const entry of batch) videos.push(normalizeVideo(entry));
868
+ pageToken = typeof response.nextPageToken === "string" ? response.nextPageToken : "";
869
+ if (pageToken === "" || batch.length === 0) return { videos, truncated: false };
870
+ if (videos.length >= MAXIMUM_IMAGES) break;
871
+ }
872
+ return { videos, truncated: true };
873
+ }
874
+
804
875
  /**
805
876
  * Processing is observed through `ListSiteImages`; `GetImageById` is
806
877
  * session-only under TR00602's read list, so a key-authorized client watches
807
878
  * the library rather than one image.
808
879
  *
880
+ * `imageIds` (at most 100) reads exactly those images instead of the library, in any
881
+ * purpose: the way to watch an image the library never lists, such as the embed
882
+ * poster the server copied for a push.
883
+ *
809
884
  * `requestOptions` carries a poller's `{ deadline, now }` into *every* page of
810
885
  * the listing. Without it a single read of a large library could spend far more
811
886
  * than the caller's whole budget — the page bound alone permits hundreds of
812
887
  * requests, each with its own request timeout and retry budget — and the
813
888
  * deadline would only be noticed once the read finally returned.
814
889
  */
815
- export async function listSiteImages(client, siteId, requestOptions = {}) {
890
+ export async function listSiteImages(client, siteId, requestOptions = {}, { imageIds = [] } = {}) {
816
891
  const images = [];
817
892
  let pageToken = "";
818
893
  let summary = { totalImages: 0, processingImages: 0 };
819
894
  for (let request = 0; request < MAXIMUM_LIST_REQUESTS; request += 1) {
820
- const search = query([["pageSize", LIST_PAGE_SIZE], ["pageToken", pageToken]]);
895
+ // Asking by id reads exactly those images, whatever their purpose. The library lists the
896
+ // owner's own images only, so a copied embed poster is never in it.
897
+ const search = imageIds.length > 0
898
+ ? query([["imageIds", imageIds]])
899
+ : query([["pageSize", LIST_PAGE_SIZE], ["pageToken", pageToken]]);
821
900
  const response = requireObject(
822
901
  await client.request(sitePath(siteId, `images${search}`), requestOptions),
823
902
  "api.image_contract",
@@ -23,7 +23,7 @@ export const SITE_AUTHORING_CAPABILITIES = Object.freeze([
23
23
 
24
24
  /** What each capability lets an exchanged credential do, for help and `whoami`. */
25
25
  export const SITE_AUTHORING_CAPABILITY_SUMMARIES = Object.freeze({
26
- "delegation.content": "write pages and media",
26
+ "delegation.content": "write pages, media, and form definitions",
27
27
  "delegation.design": "set the theme, navigation, and footer",
28
28
  "delegation.deployments": "stage and deploy",
29
29
  });
package/src/cli.js CHANGED
@@ -20,6 +20,9 @@ import {
20
20
  VERB_STAGING_REVIEW,
21
21
  VERB_ENV,
22
22
  VERB_FOOTER_PUSH,
23
+ VERB_FORMS_PULL,
24
+ VERB_FORMS_PUSH,
25
+ VERB_FORMS_VALIDATE,
23
26
  VERB_HELP,
24
27
  VERB_LOGIN,
25
28
  VERB_LOGOUT,
@@ -71,14 +74,14 @@ import { VERB_HANDLERS } from "./verbs/index.js";
71
74
  /**
72
75
  * The verbs the local version gate applies to (TR00703).
73
76
  *
74
- * Exactly the three that make no request. Every other verb reaches Taproot,
77
+ * Exactly the ones that make no request. Every other verb reaches Taproot,
75
78
  * whose own refusal is authoritative and better informed than this recording,
76
79
  * so gating them here would only duplicate an answer — and gating `login`,
77
80
  * `logout`, or `env` would take away the commands an operator needs while they
78
81
  * are outdated. `--help` and `--version` are outside it for the same reason:
79
82
  * they are how someone finds out what they have.
80
83
  */
81
- const VERSION_GATED_OFFLINE_VERBS = Object.freeze([VERB_HELP, VERB_VALIDATE, VERB_WHOAMI]);
84
+ const VERSION_GATED_OFFLINE_VERBS = Object.freeze([VERB_HELP, VERB_VALIDATE, VERB_FORMS_VALIDATE, VERB_WHOAMI]);
82
85
 
83
86
  // One entry per verb, in help order. `tokens` is the exact leading positional
84
87
  // sequence; matching prefers the longest sequence, so a two-token family can
@@ -281,6 +284,47 @@ const VERBS = Object.freeze([
281
284
  + "goes live with that deploy: staging first, production when promoted. "
282
285
  + "See 'taproot-site help redirects'.",
283
286
  },
287
+ {
288
+ name: VERB_FORMS_PULL,
289
+ surface: SURFACE_STANDARD,
290
+ // Forms are authored with the pages that place them, so they ride the
291
+ // content capability; the site's forms permissions decide what it may do.
292
+ capabilities: [CAPABILITY_CONTENT],
293
+ tokens: ["forms", "pull"],
294
+ summary: "Write each live form to forms/<key>.json and record the versions read.",
295
+ note: "Needs a pulled workspace ('pull' first). It replaces a form's file with the site's copy and records the "
296
+ + "version, which 'forms push' uses to refuse a change made on the site since. An archived form is listed "
297
+ + "and not written. Submissions are never pulled: they are visitors' personal data and stay on the site. "
298
+ + "See 'taproot-site help forms'.",
299
+ },
300
+ {
301
+ name: VERB_FORMS_PUSH,
302
+ surface: SURFACE_STANDARD,
303
+ capabilities: [CAPABILITY_CONTENT],
304
+ tokens: ["forms", "push"],
305
+ positionals: "formKeys",
306
+ summary: "Validate forms/<key>.json and create or update each form on the site.",
307
+ note: "Every file is validated and compared with the site before anything is written. A form whose site "
308
+ + "version moved since the last pull is refused (forms.concurrent_modification), and a form the site has "
309
+ + "that this workspace never pulled is refused (forms.pull_required). Editing a definition appends a version; "
310
+ + "a page keeps the version it was published with until the next deploy. An after_submit page_path is looked "
311
+ + "up on the site, and a path that names no page is refused (forms.page_not_found), as is a generated, 404, legal, profile or integration page (forms.page_not_allowed). Name form keys to narrow "
312
+ + "the push. See 'taproot-site help forms'.",
313
+ },
314
+ {
315
+ name: VERB_FORMS_VALIDATE,
316
+ tokens: ["forms", "validate"],
317
+ offline: true,
318
+ // Offline, but it reads taproot-site.json to find the workspace.
319
+ readsLocalState: true,
320
+ positionals: "formKeys",
321
+ summary: "Validate the workspace's form files offline, against the shared field schema.",
322
+ note: "Reads forms/<key>.json and checks the file contract and every field definition with the same validator "
323
+ + "'forms push' and the published form use. Name form keys to narrow it; with none, every file under forms/ "
324
+ + "is checked. It cannot prove that the site accepts the key, that an after_submit page_path names a page, or "
325
+ + "that the caller may write forms. "
326
+ + "See 'taproot-site help forms'.",
327
+ },
284
328
  {
285
329
  name: VERB_THEME_PUSH,
286
330
  surface: SURFACE_DOCS_PRESENTATION,
@@ -323,7 +367,7 @@ const VERBS = Object.freeze([
323
367
  // (TR00790).
324
368
  surfaceCapabilities: { [SURFACE_DOCS_PRESENTATION]: [CAPABILITY_DESIGN] },
325
369
  tokens: ["media", "upload"],
326
- 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.",
327
371
  positionals: "paths",
328
372
  note: "Positional arguments name the files or directories to upload;"
329
373
  + " with none, the workspace's media/ directory is walked.",
@@ -556,6 +600,8 @@ function verbHelp(verb) {
556
600
  ? " [production | local | <url>]"
557
601
  : verb.positionals === "siteSelector"
558
602
  ? " <site-name-or-id>"
603
+ : verb.positionals === "formKeys"
604
+ ? " [form-key...]"
559
605
  : verb.positionals
560
606
  ? ` [${verb.positionals === "paths" ? "path" : "page-path"}...]`
561
607
  : "";
@@ -706,6 +752,7 @@ function parseReferenceArguments(arguments_) {
706
752
  || topic === "footer"
707
753
  || topic === "fixture"
708
754
  || topic === "redirects"
755
+ || topic === "forms"
709
756
  || topic === "walkthrough"
710
757
  || topic === "delivery"
711
758
  || topic === "import"
@@ -773,6 +820,7 @@ function referenceResult(parsed) {
773
820
  }
774
821
  case "nav":
775
822
  case "redirects":
823
+ case "forms":
776
824
  case "media":
777
825
  case "preview":
778
826
  case "fixture":
package/src/constants.js CHANGED
@@ -87,6 +87,9 @@ export const VERB_NAV_PUSH = "nav push";
87
87
  export const VERB_REDIRECTS_CHECK = "redirects check";
88
88
  export const VERB_REDIRECTS_PULL = "redirects pull";
89
89
  export const VERB_REDIRECTS_PUSH = "redirects push";
90
+ export const VERB_FORMS_PULL = "forms pull";
91
+ export const VERB_FORMS_PUSH = "forms push";
92
+ export const VERB_FORMS_VALIDATE = "forms validate";
90
93
  export const VERB_THEME_PUSH = "theme push";
91
94
  export const VERB_FOOTER_PUSH = "footer push";
92
95
  export const VERB_MEDIA_UPLOAD = "media upload";
@@ -222,6 +225,9 @@ export const LIMITS = Object.freeze({
222
225
  redirectMapResponseBytes: 8 * 1024 * 1024,
223
226
  requestMilliseconds: 60_000,
224
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,
225
231
  deploymentMilliseconds: 30 * 60_000,
226
232
  previewMilliseconds: 5 * 60_000,
227
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,52 @@ 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.
301
+ */
302
+ const VIDEO = Object.freeze({
303
+ videoId: Object.freeze({ kind: "uuid", required: true }),
304
+ poster: image(),
305
+ caption: boundedStr(300),
306
+ aspectRatio: str({
307
+ pattern: VIDEO_ASPECT_RATIO_RE,
308
+ patternSource: VIDEO_ASPECT_RATIO_PATTERN,
309
+ patternDescription: "two whole numbers of at most three digits joined by a colon, such as 16:9",
310
+ }),
311
+ maxHeight: cssLength({ allowNone: true }),
312
+ borderWidth: cssLength(),
313
+ });
314
+
315
+ /**
316
+ * `VideoEmbedData`. A YouTube or Vimeo video is stored as its provider and id,
317
+ * never as a link: the published page builds the player URL from the pair. A
318
+ * Markdown component fence may name the video by `url` instead, and
319
+ * `expandComponentAuthoringShorthand` reduces it to the pair before validation.
320
+ */
321
+ const VIDEO_EMBED = Object.freeze({
322
+ provider: Object.freeze({ ...enumOf("youtube", "vimeo"), required: true }),
323
+ videoId: boundedStr(32, { required: true, nonWhitespace: true }),
324
+ title: boundedStr(200, { required: true, nonWhitespace: true }),
325
+ poster: image(),
326
+ aspectRatio: str({
327
+ pattern: VIDEO_ASPECT_RATIO_RE,
328
+ patternSource: VIDEO_ASPECT_RATIO_PATTERN,
329
+ patternDescription: "two whole numbers of at most three digits joined by a colon, such as 16:9",
330
+ }),
331
+ maxHeight: cssLength({ allowNone: true }),
332
+ borderWidth: cssLength(),
333
+ });
282
334
  const SPACER = Object.freeze({
283
335
  height: enumOf("small", "medium", "large"),
284
336
  showDivider: bool(),
@@ -286,14 +338,31 @@ const SPACER = Object.freeze({
286
338
  dividerWidth: num({ integer: true, minimum: 1 }),
287
339
  });
288
340
 
341
+ /**
342
+ * A form defined under Site → Forms (or with `forms push`), placed by key. The
343
+ * key is checked against the site's forms when the page is published, not here:
344
+ * this table only knows the shape.
345
+ */
346
+ const FORM = Object.freeze({
347
+ formKey: str(),
348
+ });
349
+
289
350
  function deepFreeze(value) {
290
351
  if (value === null || typeof value !== "object" || Object.isFrozen(value)) return value;
291
352
  for (const child of Object.values(value)) deepFreeze(child);
292
353
  return Object.freeze(value);
293
354
  }
294
355
 
295
- function component(fields, displayName, summary, defaultData, accessibility, example) {
296
- return deepFreeze({ fields, displayName, summary, defaultData, accessibility, example });
356
+ function component(fields, displayName, summary, defaultData, accessibility, example, authoring) {
357
+ return deepFreeze({
358
+ fields,
359
+ displayName,
360
+ summary,
361
+ defaultData,
362
+ accessibility,
363
+ example,
364
+ ...(authoring ? { authoring } : {}),
365
+ });
297
366
  }
298
367
 
299
368
  /**
@@ -555,6 +624,62 @@ const COMPONENT_DEFINITIONS = Object.freeze({
555
624
  imageMotion: "parallax-subtle",
556
625
  },
557
626
  ),
627
+ "form": component(
628
+ FORM,
629
+ "Form",
630
+ "A form from Site → Forms that visitors fill in and submit.",
631
+ { formKey: "" },
632
+ [
633
+ "Set formKey to the key of an existing form ('forms push' creates it); an unknown or archived key fails the publish.",
634
+ "The button text and what happens after a response is sent belong to the form, not the page: set submit_label and after_submit in the form's file (see 'help forms').",
635
+ "The form's field labels are its accessible names; write each so it makes sense out of context.",
636
+ ],
637
+ { formKey: "contact" },
638
+ ),
639
+ "video": component(
640
+ VIDEO,
641
+ "Video",
642
+ "A video from the site's library that plays when the visitor presses play; nothing loads before then.",
643
+ {},
644
+ [
645
+ "Add a caption that tells a visitor what the video is about; the caption is also the player's accessible name.",
646
+ "A video with spoken content needs captions or a transcript in the page text; the player does not generate them.",
647
+ "Leave aspectRatio out to follow the video's own shape; set it only to crop the frame to a different one.",
648
+ ],
649
+ {
650
+ videoId: "7c5e2b1a-9d3f-4a68-b0c4-1e2f3a4b5c6d",
651
+ caption: "A walk through the studio before your first class.",
652
+ aspectRatio: "16:9",
653
+ },
654
+ { notes: [MEDIA_PRESENTATION_HELP] },
655
+ ),
656
+ "video-embed": component(
657
+ VIDEO_EMBED,
658
+ "Video embed",
659
+ "A YouTube or Vimeo video shown as a poster and a play button; nothing from the provider loads until the visitor presses play.",
660
+ {},
661
+ [
662
+ "Set title to what the video is about; it names the play button and the player for assistive technology.",
663
+ "A video with spoken content needs captions turned on at the provider or a transcript in the page text.",
664
+ "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.",
665
+ ],
666
+ {
667
+ provider: "youtube",
668
+ videoId: "dQw4w9WgXcQ",
669
+ title: "A walk through the studio before your first class.",
670
+ aspectRatio: "16:9",
671
+ },
672
+ {
673
+ notes: [
674
+ MEDIA_PRESENTATION_HELP,
675
+ "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.",
676
+ "A link that is not an accepted YouTube or Vimeo video gets a one-line refusal that names the links it accepts.",
677
+ "url together with provider or videoId is refused: give the video one way.",
678
+ "Stored blocks and .pm.json sources hold only provider and videoId; url is never stored.",
679
+ ],
680
+ markdownExample: "```component:video-embed\n{\"url\": \"https://youtu.be/dQw4w9WgXcQ\", \"title\": \"A walk through the studio before your first class.\"}\n```",
681
+ },
682
+ ),
558
683
  });
559
684
 
560
685
  export const COMPONENT_TYPES = Object.freeze(Object.keys(COMPONENT_DEFINITIONS));
@@ -576,6 +701,10 @@ function referenceSchema(spec) {
576
701
  return {
577
702
  type: "string",
578
703
  ...(spec.nonWhitespace ? { minLength: 1, description: "Must contain non-whitespace text." } : {}),
704
+ ...(spec.maximumLength !== undefined ? { maxLength: spec.maximumLength } : {}),
705
+ ...(spec.patternSource !== undefined
706
+ ? { pattern: spec.patternSource, description: `Must be ${spec.patternDescription}.` }
707
+ : {}),
579
708
  };
580
709
  case "safe-url":
581
710
  return {
@@ -585,6 +714,8 @@ function referenceSchema(spec) {
585
714
  spec.allowEmpty === false ? "An" : "Empty, or an"
586
715
  } 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.`,
587
716
  };
717
+ case "css-length":
718
+ return { type: "string", description: `Must be ${describe(spec)}.` };
588
719
  case "texture-name":
589
720
  return {
590
721
  type: "string",
@@ -689,6 +820,10 @@ function describe(spec) {
689
820
  return `${
690
821
  spec.allowEmpty === false ? "a" : "an empty string or a"
691
822
  } safe HTTP(S), mailto, tel, non-protocol-relative root path, fragment, query, or relative URL; backslashes and ASCII control characters are rejected`;
823
+ case "css-length":
824
+ return spec.allowNone
825
+ ? "a CSS height such as 60vh or 32rem, or none"
826
+ : "a CSS width such as 2px or 0.25rem, or 0 for no border";
692
827
  case "texture-name":
693
828
  return "a lowercase hyphenated texture identifier of at most 64 characters";
694
829
  default:
@@ -709,6 +844,9 @@ function validateValue(spec, value, path, errors) {
709
844
  case "string":
710
845
  if (typeof value !== "string") fail("a string");
711
846
  else if (spec.nonWhitespace && value.trim() === "") fail("a string containing non-whitespace text");
847
+ else if (spec.maximumLength !== undefined && value.length > spec.maximumLength) {
848
+ fail(`a string of at most ${spec.maximumLength} characters`);
849
+ } else if (spec.pattern !== undefined && !spec.pattern.test(value)) fail(spec.patternDescription);
712
850
  return;
713
851
  case "safe-url":
714
852
  if (
@@ -723,6 +861,10 @@ function validateValue(spec, value, path, errors) {
723
861
  fail(describe(spec));
724
862
  }
725
863
  return;
864
+ case "css-length":
865
+ // The renderer drops a value outside this grammar silently, so it is refused here instead.
866
+ if (normalizeCssLengthOverride(value, { allowNone: spec.allowNone === true }) === null) fail(describe(spec));
867
+ return;
726
868
  case "texture-name":
727
869
  if (
728
870
  typeof value !== "string"
@@ -826,6 +968,31 @@ function validateFields(fields, value, path, errors) {
826
968
  }
827
969
  }
828
970
 
971
+ /**
972
+ * Lets a Markdown component fence name a video embed by link. Returns the
973
+ * fence's JSON source with `url` replaced by the `provider` and `videoId` it
974
+ * reduces to, or `{ error }` with the one-line reason a link is refused. Any
975
+ * other component, and a source that is not a JSON object, comes back as it was
976
+ * so validation reports it in its usual words.
977
+ */
978
+ export function expandComponentAuthoringShorthand(componentType, source) {
979
+ if (componentType !== "video-embed") return { source };
980
+ let parsed;
981
+ try {
982
+ parsed = JSON.parse(source);
983
+ } catch {
984
+ return { source };
985
+ }
986
+ if (!isPlainObject(parsed) || !Object.hasOwn(parsed, "url")) return { source };
987
+ if (Object.hasOwn(parsed, "provider") || Object.hasOwn(parsed, "videoId")) {
988
+ return { error: "Give the video as url, or as provider and videoId, not both." };
989
+ }
990
+ const target = parseVideoEmbedUrl(parsed.url);
991
+ if (!target) return { error: VIDEO_EMBED_REFUSAL };
992
+ const { url: _url, ...rest } = parsed;
993
+ return { source: JSON.stringify({ provider: target.provider, videoId: target.videoId, ...rest }) };
994
+ }
995
+
829
996
  /**
830
997
  * Validates a `componentBlock`'s `componentType` and `componentData` pair.
831
998
  *
@@ -894,6 +1061,17 @@ export function validateComponentBlock(componentType, componentData, basePath) {
894
1061
  }
895
1062
  }
896
1063
  }
1064
+ if (
1065
+ componentType === "video-embed" && typeof parsed.provider === "string" && typeof parsed.videoId === "string"
1066
+ && (parsed.provider === "youtube" || parsed.provider === "vimeo")
1067
+ && !isVideoEmbedId(parsed.provider, parsed.videoId)
1068
+ ) {
1069
+ errors.push(contentError(
1070
+ `${dataPath}/videoId`,
1071
+ CODES.componentData,
1072
+ `Component data field 'videoId' must be a valid ${parsed.provider} video id. ${VIDEO_EMBED_REFUSAL}`,
1073
+ ));
1074
+ }
897
1075
  return errors;
898
1076
  }
899
1077
 
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": 7,
2
+ "version": 12,
3
3
  "section": {
4
4
  "nodeType": "section",
5
5
  "label": "Section",
@@ -554,6 +554,21 @@
554
554
  "label": "Image Banner",
555
555
  "measure": "wide",
556
556
  "rootPlacement": "root-band"
557
+ },
558
+ "form": {
559
+ "label": "Form",
560
+ "measure": "wide",
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"
557
572
  }
558
573
  },
559
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);