@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.
@@ -8,8 +8,16 @@
8
8
  */
9
9
 
10
10
  import freeFormSectionRegistry from "./free-form-section-registry.json" with { type: "json" };
11
+ import {
12
+ explicitImageMaxHeight,
13
+ mediaOverrideStyleParts,
14
+ normalizeBorderWidthOverride,
15
+ normalizeCssLengthOverride,
16
+ normalizeMaxHeightOverride,
17
+ TIPTAP_SITE_DEFAULT,
18
+ type TiptapImageMaxHeightVh,
19
+ } from "./media-presentation.js";
11
20
 
12
- export const TIPTAP_SITE_DEFAULT = "site-default";
13
21
  export const DEFAULT_TIPTAP_IMAGE_MAX_IMAGES_PER_ROW = 2;
14
22
  export const TIPTAP_IMAGE_MAX_IMAGES_PER_ROW_OPTIONS = [1, 2, 3] as const;
15
23
  export const TIPTAP_IMAGE_NARROW_CONTENT_REM = 40;
@@ -21,15 +29,11 @@ export function normalizeTiptapImageMaxImagesPerRow(value: unknown): number {
21
29
  : DEFAULT_TIPTAP_IMAGE_MAX_IMAGES_PER_ROW;
22
30
  }
23
31
 
24
- export const DEFAULT_TIPTAP_IMAGE_MAX_HEIGHT_VH = 60;
25
- export const TIPTAP_IMAGE_MAX_HEIGHT_OPTIONS = [45, 60, 75, 90] as const;
26
32
  const SECTION_CONTEXT_PATTERN = new RegExp(
27
33
  freeFormSectionRegistry.section.attrs.context.pattern,
28
34
  "u",
29
35
  );
30
36
 
31
- export type TiptapSiteDefault = typeof TIPTAP_SITE_DEFAULT;
32
- export type TiptapImageMaxHeightVh = typeof TIPTAP_IMAGE_MAX_HEIGHT_OPTIONS[number];
33
37
  export type TiptapImagePresentationPlacement =
34
38
  | "left"
35
39
  | "left-text-right"
@@ -1202,13 +1206,11 @@ function resolveImagePresentation(
1202
1206
  defaults: RenderProseMirrorOptions["imageDefaults"],
1203
1207
  ): Record<string, string | number | undefined> {
1204
1208
  const explicitMaxHeight = explicitImageMaxHeight(attrs.maxHeightVh);
1205
- const maxHeightOverride = normalizeCssLengthOverride(attrs.maxHeight, { allowNone: true });
1206
- const borderWidthOverride = normalizeCssLengthOverride(attrs.borderWidth);
1209
+ const maxHeightOverride = normalizeMaxHeightOverride(attrs.maxHeight);
1210
+ const borderWidthOverride = normalizeBorderWidthOverride(attrs.borderWidth);
1207
1211
  const placement = resolvePlacement(attrs, defaults?.placement);
1208
1212
  const placementAttrs = imagePresentationAttributesForPlacement(placement);
1209
- const styleParts: string[] = [];
1210
- if (maxHeightOverride) styleParts.push(`--taproot-article-image-max-height: ${maxHeightOverride}`);
1211
- if (borderWidthOverride) styleParts.push(`--esp-image-border: ${imageBorderValue(borderWidthOverride)}`);
1213
+ const styleParts = mediaOverrideStyleParts({ maxHeight: maxHeightOverride, borderWidth: borderWidthOverride });
1212
1214
  return {
1213
1215
  "data-tiptap-image": normalizeTiptapImageMaxImagesPerRow(defaults?.maxImagesPerRow),
1214
1216
  // The page shell owns the inherited site default so HTML and structured
@@ -1226,11 +1228,6 @@ function resolveImagePresentation(
1226
1228
  };
1227
1229
  }
1228
1230
 
1229
- function explicitImageMaxHeight(value: unknown): TiptapImageMaxHeightVh | null {
1230
- if (value === TIPTAP_SITE_DEFAULT || value === undefined || value === null) return null;
1231
- return isImageMaxHeightOption(value) ? imageMaxHeightOption(value) : null;
1232
- }
1233
-
1234
1231
  function resolvePlacement(
1235
1232
  attrs: Record<string, JsonValue | undefined>,
1236
1233
  defaultValue: unknown,
@@ -1257,55 +1254,6 @@ function isAbsentPresentationValue(value: unknown): boolean {
1257
1254
  return value === undefined || value === null;
1258
1255
  }
1259
1256
 
1260
- export function normalizeImageMaxHeight(value: unknown): TiptapImageMaxHeightVh {
1261
- return isImageMaxHeightOption(value) ? imageMaxHeightOption(value) : DEFAULT_TIPTAP_IMAGE_MAX_HEIGHT_VH;
1262
- }
1263
-
1264
- function isImageMaxHeightOption(value: unknown): boolean {
1265
- return TIPTAP_IMAGE_MAX_HEIGHT_OPTIONS.includes(imageMaxHeightOption(value));
1266
- }
1267
-
1268
- function imageMaxHeightOption(value: unknown): TiptapImageMaxHeightVh {
1269
- return (typeof value === "number" ? value : Number(value)) as TiptapImageMaxHeightVh;
1270
- }
1271
-
1272
- const CSS_LENGTH_UNITS = "px|em|rem|ch|ex|vw|vh|svh|lvh|dvh|svw|lvw|dvw|vmin|vmax|%";
1273
- const CSS_LENGTH_RE = new RegExp(`^\\d+(?:\\.\\d+)?(?:${CSS_LENGTH_UNITS})$`, "i");
1274
- const CSS_VAR_RE = /^var\(--[a-z0-9-]+\)$/i;
1275
- const CSS_CALC_TERM = `(?:\\d+(?:\\.\\d+)?(?:${CSS_LENGTH_UNITS})|\\d+(?:\\.\\d+)?|var\\(--[a-z0-9-]+\\))`;
1276
- const CSS_CALC_RE = new RegExp(
1277
- `^calc\\(\\s*${CSS_CALC_TERM}(?:\\s*[+\\-*/]\\s*${CSS_CALC_TERM})*\\s*\\)$`,
1278
- "i",
1279
- );
1280
-
1281
- /**
1282
- * Validate a user-supplied CSS length for per-image presentation overrides
1283
- * (max height, border width). These values are emitted into inline styles on
1284
- * published pages, so anything outside this strict grammar is rejected:
1285
- * unitless `0`, a non-negative length, `var(--token)`, `calc(...)` of those
1286
- * terms, and — when `allowNone` is set — the `none` keyword (max-height).
1287
- * Returns the trimmed value, or null when invalid or set to the site default.
1288
- */
1289
- export function normalizeCssLengthOverride(
1290
- value: unknown,
1291
- options?: { allowNone?: boolean },
1292
- ): string | null {
1293
- if (typeof value !== "string") return null;
1294
- const trimmed = value.trim();
1295
- if (!trimmed || trimmed === TIPTAP_SITE_DEFAULT) return null;
1296
- if (trimmed === "0") return trimmed;
1297
- if (options?.allowNone && trimmed.toLowerCase() === "none") return "none";
1298
- if (CSS_LENGTH_RE.test(trimmed)) return trimmed;
1299
- if (CSS_VAR_RE.test(trimmed)) return trimmed;
1300
- if (CSS_CALC_RE.test(trimmed)) return trimmed;
1301
- return null;
1302
- }
1303
-
1304
- /** CSS value for the esp-image border hook given a width; zero collapses to none. */
1305
- export function imageBorderValue(width: string): string {
1306
- return /^0(?:\.0+)?(?:[a-z%]+)?$/i.test(width) ? "none" : `${width} solid var(--esp-color-border)`;
1307
- }
1308
-
1309
1257
  export function normalizeImagePresentationPlacement(value: unknown): TiptapImagePresentationPlacement {
1310
1258
  switch (value) {
1311
1259
  case "left":
@@ -0,0 +1,104 @@
1
+ // Generated from shared/video-embed-url.ts by scripts/sync-field-validation.mjs. Do not edit.
2
+ /**
3
+ * The one owner of which video links the embed card accepts.
4
+ *
5
+ * A link is reduced to a provider and an id when it is authored, and only that
6
+ * pair is stored. Every player URL is built here from the validated pair, never
7
+ * from the text an author pasted. Anything that is not a YouTube watch, share,
8
+ * Shorts or embed link, or a Vimeo video link, is refused.
9
+ *
10
+ * It is pure and dependency-free. The generator, the editor and the
11
+ * site-authoring CLI cannot all import this file, so
12
+ * `node scripts/sync-field-validation.mjs` writes their copies; edit only this
13
+ * file and re-run it. `shared/video-embed-url-corpus.json` is the evidence
14
+ * every copy agrees.
15
+ */
16
+ export const VIDEO_EMBED_PROVIDERS = ["youtube", "vimeo"];
17
+ /** The one line shown for any link that is not accepted. */
18
+ export const VIDEO_EMBED_REFUSAL = "Use a YouTube or Vimeo video link, such as youtube.com/watch?v=… or vimeo.com/123456789.";
19
+ /** The origin each provider's player loads from; a page with an embed may frame exactly these. */
20
+ export const VIDEO_EMBED_FRAME_ORIGINS = {
21
+ youtube: "https://www.youtube-nocookie.com",
22
+ vimeo: "https://player.vimeo.com",
23
+ };
24
+ const YOUTUBE_ID = /^[A-Za-z0-9_-]{11}$/u;
25
+ /** Two 11-character path segments of `/embed/` links that are playlists and live streams, not video ids. */
26
+ const YOUTUBE_NON_VIDEO_IDS = new Set(["videoseries", "live_stream"]);
27
+ const VIMEO_ID = /^[1-9][0-9]{0,11}$/u;
28
+ const YOUTUBE_HOSTS = new Set([
29
+ "youtube.com",
30
+ "www.youtube.com",
31
+ "m.youtube.com",
32
+ "youtube-nocookie.com",
33
+ "www.youtube-nocookie.com",
34
+ ]);
35
+ const VIMEO_HOSTS = new Set(["vimeo.com", "www.vimeo.com"]);
36
+ export function isVideoEmbedProvider(value) {
37
+ return value === "youtube" || value === "vimeo";
38
+ }
39
+ export function isVideoEmbedId(provider, videoId) {
40
+ if (typeof videoId !== "string")
41
+ return false;
42
+ if (provider === "youtube")
43
+ return YOUTUBE_ID.test(videoId) && !YOUTUBE_NON_VIDEO_IDS.has(videoId);
44
+ if (provider === "vimeo")
45
+ return VIMEO_ID.test(videoId);
46
+ return false;
47
+ }
48
+ /** The player URL for a validated provider and id, or an empty string when either is not valid. */
49
+ export function videoEmbedPlayerUrl(provider, videoId) {
50
+ if (!isVideoEmbedProvider(provider) || !isVideoEmbedId(provider, videoId))
51
+ return "";
52
+ return provider === "youtube"
53
+ ? `${VIDEO_EMBED_FRAME_ORIGINS.youtube}/embed/${videoId}?autoplay=1`
54
+ : `${VIDEO_EMBED_FRAME_ORIGINS.vimeo}/video/${videoId}?dnt=1&autoplay=1`;
55
+ }
56
+ /** Reduces an accepted video link to its provider and id; every other value gives `null`. */
57
+ export function parseVideoEmbedUrl(input) {
58
+ if (typeof input !== "string")
59
+ return null;
60
+ const text = input.trim();
61
+ if (text === "" || text.length > 2048)
62
+ return null;
63
+ let url;
64
+ try {
65
+ url = new URL(text);
66
+ }
67
+ catch {
68
+ return null;
69
+ }
70
+ if (url.protocol !== "https:" && url.protocol !== "http:")
71
+ return null;
72
+ if (url.username !== "" || url.password !== "" || url.port !== "")
73
+ return null;
74
+ const host = url.hostname.toLowerCase();
75
+ const pathname = url.pathname.length > 1 && url.pathname.endsWith("/") ? url.pathname.slice(0, -1) : url.pathname;
76
+ if (pathname.includes("//"))
77
+ return null;
78
+ const segments = pathname.slice(1).split("/");
79
+ if (host === "youtu.be") {
80
+ return segments.length === 1 ? target("youtube", segments[0]) : null;
81
+ }
82
+ if (YOUTUBE_HOSTS.has(host)) {
83
+ if (segments.length === 1 && segments[0] === "watch")
84
+ return target("youtube", url.searchParams.get("v"));
85
+ if (segments.length === 2 && (segments[0] === "shorts" || segments[0] === "embed")) {
86
+ return target("youtube", segments[1]);
87
+ }
88
+ return null;
89
+ }
90
+ // An unlisted Vimeo video needs its `h` privacy hash to play; only the id is
91
+ // stored, so a link that carries one is refused rather than saved broken.
92
+ if (VIMEO_HOSTS.has(host)) {
93
+ return segments.length === 1 && !url.searchParams.has("h") ? target("vimeo", segments[0]) : null;
94
+ }
95
+ if (host === "player.vimeo.com") {
96
+ return segments.length === 2 && segments[0] === "video" && !url.searchParams.has("h")
97
+ ? target("vimeo", segments[1])
98
+ : null;
99
+ }
100
+ return null;
101
+ }
102
+ function target(provider, videoId) {
103
+ return videoId !== null && videoId !== undefined && isVideoEmbedId(provider, videoId) ? { provider, videoId } : null;
104
+ }
@@ -1,6 +1,11 @@
1
1
  import { isContactOrWebUrl } from "../contact-url.js";
2
2
  import { sanitizeDiagnostic } from "../errors.js";
3
3
  import freeFormSectionRegistry from "./free-form-section-registry.json" with { type: "json" };
4
+ import {
5
+ normalizeCssLengthOverride,
6
+ TIPTAP_IMAGE_MAX_HEIGHT_OPTIONS,
7
+ TIPTAP_SITE_DEFAULT,
8
+ } from "./media-presentation.js";
4
9
 
5
10
  /**
6
11
  * The primitives the content module shares: the accepted ProseMirror
@@ -186,11 +191,12 @@ export const CONTENT_LIMITS = Object.freeze({
186
191
  // Renderer-mirrored constants and predicates
187
192
  // ---------------------------------------------------------------------------
188
193
 
189
- /** `TIPTAP_SITE_DEFAULT` — the "no explicit choice" sentinel. */
190
- export const TIPTAP_SITE_DEFAULT = "site-default";
194
+ // The sentinel, the max height options and the CSS length grammar are the
195
+ // renderer's own, from the shared media presentation module.
196
+ export { normalizeCssLengthOverride, TIPTAP_SITE_DEFAULT };
191
197
 
192
198
  /** `TIPTAP_IMAGE_MAX_HEIGHT_OPTIONS` — anything else normalises to 60 (silent). */
193
- export const IMAGE_MAX_HEIGHT_OPTIONS = Object.freeze([45, 60, 75, 90]);
199
+ export const IMAGE_MAX_HEIGHT_OPTIONS = Object.freeze([...TIPTAP_IMAGE_MAX_HEIGHT_OPTIONS]);
194
200
 
195
201
  /** The canonical `TiptapImagePresentationPlacement` union. */
196
202
  export const IMAGE_PRESENTATION_PLACEMENTS = Object.freeze([
@@ -244,35 +250,6 @@ export function isSafeUrl(url) {
244
250
  }
245
251
  }
246
252
 
247
- // The renderer's CSS grammar, character for character. These values are
248
- // emitted into inline styles on published pages, which is why the grammar is
249
- // this narrow — and why a value outside it is dropped rather than escaped.
250
- const CSS_LENGTH_UNITS = "px|em|rem|ch|ex|vw|vh|svh|lvh|dvh|svw|lvw|dvw|vmin|vmax|%";
251
- const CSS_LENGTH_RE = new RegExp(`^\\d+(?:\\.\\d+)?(?:${CSS_LENGTH_UNITS})$`, "i");
252
- const CSS_VAR_RE = /^var\(--[a-z0-9-]+\)$/i;
253
- const CSS_CALC_TERM = `(?:\\d+(?:\\.\\d+)?(?:${CSS_LENGTH_UNITS})|\\d+(?:\\.\\d+)?|var\\(--[a-z0-9-]+\\))`;
254
- const CSS_CALC_RE = new RegExp(
255
- `^calc\\(\\s*${CSS_CALC_TERM}(?:\\s*[+\\-*/]\\s*${CSS_CALC_TERM})*\\s*\\)$`,
256
- "i",
257
- );
258
-
259
- /**
260
- * Mirrors the renderer's `normalizeCssLengthOverride`: returns the trimmed
261
- * value, or null when the value is invalid *or* is the site-default sentinel.
262
- * Callers that need to tell those two apart check the sentinel themselves.
263
- */
264
- export function normalizeCssLengthOverride(value, options) {
265
- if (typeof value !== "string") return null;
266
- const trimmed = value.trim();
267
- if (!trimmed || trimmed === TIPTAP_SITE_DEFAULT) return null;
268
- if (trimmed === "0") return trimmed;
269
- if (options?.allowNone && trimmed.toLowerCase() === "none") return "none";
270
- if (CSS_LENGTH_RE.test(trimmed)) return trimmed;
271
- if (CSS_VAR_RE.test(trimmed)) return trimmed;
272
- if (CSS_CALC_RE.test(trimmed)) return trimmed;
273
- return null;
274
- }
275
-
276
253
  /**
277
254
  * The lowercase canonical UUID the API round-trips. Deliberately looser than
278
255
  * `config.js`'s site-id pattern on the version nibble: image ids are server
@@ -133,7 +133,7 @@
133
133
  {
134
134
  "properties": {
135
135
  "id": { "$ref": "#/$defs/id" },
136
- "type": { "enum": ["email", "tel", "url"] },
136
+ "type": { "enum": ["email", "url"] },
137
137
  "label": { "$ref": "#/$defs/label" },
138
138
  "hint": { "$ref": "#/$defs/hint" },
139
139
  "required": { "$ref": "#/$defs/required" },
@@ -141,6 +141,16 @@
141
141
  },
142
142
  "additionalProperties": false
143
143
  },
144
+ {
145
+ "properties": {
146
+ "id": { "$ref": "#/$defs/id" },
147
+ "type": { "const": "tel" },
148
+ "label": { "$ref": "#/$defs/label" },
149
+ "hint": { "$ref": "#/$defs/hint" },
150
+ "required": { "$ref": "#/$defs/required" }
151
+ },
152
+ "additionalProperties": false
153
+ },
144
154
  {
145
155
  "properties": {
146
156
  "id": { "$ref": "#/$defs/id" },
@@ -44,7 +44,10 @@ export const CONSTRAINTS_BY_TYPE = {
44
44
  text: ["required", "min_length", "max_length", "format"],
45
45
  long_text: ["required", "min_length", "max_length"],
46
46
  email: ["required", "max_length"],
47
- tel: ["required", "max_length"],
47
+ // A published phone field submits E.164, which the visitor did not type, so a
48
+ // character limit could refuse a number they cannot shorten; the 7–15 digit
49
+ // check bounds it instead (TR01090).
50
+ tel: ["required"],
48
51
  url: ["required", "max_length"],
49
52
  number: ["required", "min", "max", "format"],
50
53
  date: ["required", "min", "max"],
@@ -653,7 +656,10 @@ function validateValue(field, value, context) {
653
656
  const isTextType = (type) => type === "text" || type === "long_text" || type === "email" || type === "tel" || type === "url";
654
657
  function maxLengthOf(field) {
655
658
  const ceiling = field.type === "long_text" ? MAX_LONG_TEXT_LENGTH : MAX_SHORT_TEXT_LENGTH;
656
- return Math.min(field.max_length ?? ceiling, ceiling);
659
+ // A definition stored before tel lost max_length (TR01090) may still carry
660
+ // one; only a type that accepts the constraint is held to it.
661
+ const limit = CONSTRAINTS_BY_TYPE[field.type].includes("max_length") ? field.max_length : undefined;
662
+ return Math.min(limit ?? ceiling, ceiling);
657
663
  }
658
664
  function validateString(field, value) {
659
665
  if (field.min_length !== undefined && value.length < field.min_length)
@@ -40,8 +40,19 @@ export const FIXTURE_ROOT_FIELDS = Object.freeze(
40
40
  [...FIXTURE_REQUIRED_ROOT_FIELDS, ...FIXTURE_OPTIONAL_ROOT_FIELDS].sort(),
41
41
  );
42
42
 
43
- /** Every field the versioned `fixture` block may declare. All are required. */
44
- export const FIXTURE_METADATA_FIELDS = Object.freeze(["contractVersion", "imageIds", "deliveryOrigins"]);
43
+ /** Fields of the versioned `fixture` block that must be present. */
44
+ export const FIXTURE_REQUIRED_METADATA_FIELDS = Object.freeze(["contractVersion", "imageIds", "deliveryOrigins"]);
45
+
46
+ /**
47
+ * Fields of the `fixture` block that may be omitted. `videoIds` lists the
48
+ * `ready` videos a fixture's pages may place; a fixture that places none omits it.
49
+ */
50
+ export const FIXTURE_OPTIONAL_METADATA_FIELDS = Object.freeze(["videoIds"]);
51
+
52
+ /** Every field the versioned `fixture` block may declare. */
53
+ export const FIXTURE_METADATA_FIELDS = Object.freeze(
54
+ [...FIXTURE_REQUIRED_METADATA_FIELDS, ...FIXTURE_OPTIONAL_METADATA_FIELDS],
55
+ );
45
56
 
46
57
  /**
47
58
  * Delivery origins are restricted to the reserved example domain rather than
@@ -70,6 +70,7 @@ export async function initializeFixture(invocation, validate) {
70
70
 
71
71
  const identities = new Map();
72
72
  const imageIds = new Set();
73
+ const videoIds = new Set();
73
74
  const origins = new Map();
74
75
  const uuid = /[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/giu;
75
76
  function identity(value) {
@@ -105,6 +106,7 @@ export async function initializeFixture(invocation, validate) {
105
106
  }
106
107
  const result = sanitize(child, depth + 1);
107
108
  if (/(?:imageId|logoId|canvasImageId|faviconId)$/iu.test(key) && isCanonicalUuid(result)) imageIds.add(result);
109
+ if (key === "videoId" && isCanonicalUuid(result)) videoIds.add(result);
108
110
  return [key, result];
109
111
  }),
110
112
  );
@@ -226,6 +228,7 @@ export async function initializeFixture(invocation, validate) {
226
228
  fixture: {
227
229
  contractVersion: FIXTURE_CONTRACT_VERSION,
228
230
  imageIds: [...imageIds].sort(),
231
+ ...(videoIds.size > 0 ? { videoIds: [...videoIds].sort() } : {}),
229
232
  deliveryOrigins: [...origins.values()],
230
233
  },
231
234
  });
@@ -36,7 +36,7 @@ const JPEG_SOI = Buffer.from([0xff, 0xd8]);
36
36
  function unsupportedFormat(fileName) {
37
37
  return new SiteAuthoringError(
38
38
  "media.unsupported_format",
39
- `'${fileName}' is not a PNG, JPEG, GIF, or WebP image. Taproot accepts raster uploads only.`,
39
+ `'${fileName}' is not a PNG, JPEG, GIF, or WebP image, or an MP4, MOV, or WebM video. Taproot accepts raster images and those video containers only.`,
40
40
  { field: fileName },
41
41
  );
42
42
  }
@@ -71,7 +71,7 @@ import {
71
71
 
72
72
  export { getAppearanceReference, getFooterReference, getThemeReference };
73
73
 
74
- export const REFERENCE_VERSION = 31;
74
+ export const REFERENCE_VERSION = 33;
75
75
 
76
76
  // The date-limit examples `help forms` prints; tests run them through the form validator.
77
77
  export const FORM_DATE_WINDOW_FIELD = Object.freeze({
@@ -829,7 +829,7 @@ const WORKFLOW_REFERENCES = Object.freeze({
829
829
  }),
830
830
  media: Object.freeze({
831
831
  title: "Media upload contract",
832
- summary: "Upload workspace-root-relative raster files and receive component-ready media objects.",
832
+ summary: "Upload workspace-root-relative raster files and videos and receive component-ready media objects and video ids.",
833
833
  usage: `${CLI_BINARY_NAME} media upload [path...]`,
834
834
  details: Object.freeze([
835
835
  "Paths are relative to the configured workspace root, not the shell's current directory.",
@@ -838,6 +838,8 @@ const WORKFLOW_REFERENCES = Object.freeze({
838
838
  "Prepare variants locally with any image tool before uploading: a transparent logo, its @2x copy, a scheme-specific dark logo when one asset does not read on both headers, and a square favicon. The CLI uploads what you give it; it has no recolor, crop, or SVG upload command.",
839
839
  "Assign both schemes after uploading: lightLogoId and darkLogoId in settings/taproot-styles.json and brand.faviconId in settings/brand.json take the returned image ids; theme push writes them. See help walkthrough for the full design pass.",
840
840
  "Each result item includes media: { imageId, src, urls, width, height, alt }.",
841
+ "MP4, MOV, and WebM files are uploaded as videos (up to 250 MB each; the site's licence sets the cap, and the container is read from the file's bytes, not its name). The CLI reads each file with mediabunny. A file that is H.264 and AAC but not already an MP4 with its index first is rewritten as one without re-encoding; anything else is sent as it is and judged by the server. When the server refuses a file, its one-line message is printed as media.video_refused. A video is ready to play as soon as the upload is confirmed. The CLI sets no poster, because Node has no video decoder: the Videos page picks one automatically for a video uploaded there, and lets the owner choose another frame, so set a CLI-uploaded video's poster there, with its title and caption. The command reports videos.items: { file, videoId, title, caption, fileName, contentType, byteLength, deduplicated, durationMilliseconds, component }, where component.markdown is the component:video fence and component.block the componentBlock node that place it. Needs the site's licence to include video.",
842
+ "Place an uploaded video on a page with a video component: a component:video fence in Markdown or a componentBlock of type video, with the returned videoId and optional maxHeight and borderWidth; the poster, caption and shape are the video's own. See help component video. pages push refuses a video that is not in the site's library; validate checks it against fixture.videoIds.",
841
843
  "The same src/urls delivery fields are saved in .taproot-site-media.json for page and component authoring.",
842
844
  ]),
843
845
  example: Object.freeze({
@@ -925,7 +927,9 @@ const WORKFLOW_REFERENCES = Object.freeze({
925
927
  SETTINGS_GROUPS.map((group) => `${group.settingsType} to '${SETTINGS_DIRECTORY}/${group.file}'`).join("; ")
926
928
  }.`,
927
929
  `The fixture block carries ${FIXTURE_METADATA_FIELDS.join(", ")}. Every image a page references by imageId must `
928
- + "be listed in fixture.imageIds, and every absolute delivery URL a page uses must sit on a declared origin.",
930
+ + "be listed in fixture.imageIds, and every absolute delivery URL a page uses must sit on a declared origin. "
931
+ + "videoIds is optional: list the ready videos a page may place with a video component, and validate refuses a "
932
+ + "video component whose videoId is not listed. A live push makes the same check against the site's video library.",
929
933
  `Each deliveryOrigins entry is an origin-only HTTPS URL (no path, query, fragment, or credentials) on `
930
934
  + `${FIXTURE_DELIVERY_ORIGIN_DOMAIN} or a subdomain of it, at most ${FIXTURE_MAXIMUM_DELIVERY_ORIGINS} of them, `
931
935
  + "with no duplicates. Fixtures are copied and shipped, so a real delivery host in one would be a live "
@@ -1025,6 +1029,12 @@ export function getComponentReference(type) {
1025
1029
  accessibility: definition.accessibility,
1026
1030
  example,
1027
1031
  markdownExample,
1032
+ ...(definition.authoring
1033
+ ? {
1034
+ authoringNotes: definition.authoring.notes,
1035
+ authoringMarkdownExample: definition.authoring.markdownExample,
1036
+ }
1037
+ : {}),
1028
1038
  componentBlockExample: {
1029
1039
  type: "componentBlock",
1030
1040
  attrs: {
@@ -1841,7 +1851,11 @@ export function formatReferenceResult(result) {
1841
1851
  component.accessibility.map((note) => ` - ${note}`).join("\n")
1842
1852
  }\n\nValid componentData object:\n${
1843
1853
  JSON.stringify(component.example, null, 2)
1844
- }\n\nMarkdown component fence:\n${component.markdownExample}\n\nRemember: componentData stores JSON.stringify(the object), not the object itself. Use --json for the exact nested schema and a complete componentBlock example.\n`;
1854
+ }\n\nMarkdown component fence:\n${component.markdownExample}${
1855
+ component.authoringNotes === undefined
1856
+ ? ""
1857
+ : `\n\nAuthoring by link:\n${component.authoringNotes.map((note) => ` - ${note}`).join("\n")}\n${component.authoringMarkdownExample}`
1858
+ }\n\nRemember: componentData stores JSON.stringify(the object), not the object itself. Use --json for the exact nested schema and a complete componentBlock example.\n`;
1845
1859
  }
1846
1860
  case "workflow": {
1847
1861
  const reference = result.reference;
package/src/session.js CHANGED
@@ -373,7 +373,28 @@ export async function openSession(invocation = {}) {
373
373
  // Deliberately swallowed; see above.
374
374
  }
375
375
 
376
- const client = new SiteApiClient(buildClientOptions(invocation, apiBaseUrl, exchanged.key));
376
+ // The exchanged credential lasts about an hour. A long operation (a video
377
+ // upload and its encoding) re-exchanges the same stored sign-in for the same
378
+ // site and capabilities shortly before it lapses, rather than failing midway
379
+ // with the work already done.
380
+ const client = new SiteApiClient({
381
+ ...buildClientOptions(invocation, apiBaseUrl, exchanged.key),
382
+ credentialRenewal: {
383
+ expiresAtMilliseconds: Date.parse(exchanged.expiresAt),
384
+ renew: async () => {
385
+ const renewed = await withRefusalGuidance(
386
+ onProgress,
387
+ "sign-in token exchange",
388
+ async () =>
389
+ await exchangeSiteAuthoringToken(accountClient, {
390
+ siteId: config.siteId,
391
+ capabilities: narrowedCapabilities(invocation, config.authoringSurface),
392
+ }),
393
+ );
394
+ return { key: renewed.key, expiresAtMilliseconds: Date.parse(renewed.expiresAt) };
395
+ },
396
+ },
397
+ });
377
398
  return {
378
399
  config,
379
400
  client,
package/src/transport.js CHANGED
@@ -22,6 +22,8 @@ import {
22
22
  REFUSAL_UNCLASSIFIED,
23
23
  ROLLOUT_REFUSAL_FIELD,
24
24
  } from "./constants.js";
25
+ import { Readable } from "node:stream";
26
+
25
27
  import { validateApiBaseUrl } from "./config.js";
26
28
  import { hasControlCharacter, sanitizeDiagnostic, SiteAuthoringError } from "./errors.js";
27
29
 
@@ -43,6 +45,9 @@ const ALLOWED_METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE"]);
43
45
  // `REPLACEABLE_POST_PATHS` below.
44
46
  const IDEMPOTENT_METHODS = new Set(["GET"]);
45
47
 
48
+ /** An exchanged credential is renewed once it is this close to expiring. */
49
+ const CREDENTIAL_RENEWAL_MARGIN_MILLISECONDS = 5 * 60_000;
50
+
46
51
  const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
47
52
  const RETRYABLE_RESPONSE_ERRORS = new Set([
48
53
  "transport.invalid_json",
@@ -611,9 +616,20 @@ export class SiteApiClient {
611
616
  */
612
617
  #authorization;
613
618
 
619
+ /**
620
+ * How an exchanged credential is replaced before it expires, or `undefined`
621
+ * for a credential that is not exchanged (a key in the environment). The
622
+ * exchanged credential lives about an hour, and a video upload plus its
623
+ * encoding can outlast that.
624
+ */
625
+ #renewCredential;
626
+ #credentialExpiresAtMilliseconds;
627
+ #renewing;
628
+
614
629
  constructor({
615
630
  apiBaseUrl,
616
631
  token,
632
+ credentialRenewal,
617
633
  fetch = globalThis.fetch,
618
634
  sleep = defaultSleep,
619
635
  signal,
@@ -643,6 +659,12 @@ export class SiteApiClient {
643
659
  this.apiPathPrefix = new URL(this.apiBaseUrl).pathname;
644
660
  this.anonymous = anonymous;
645
661
  this.#authorization = anonymous ? undefined : `Bearer ${token}`;
662
+ // A renewal whose first expiry is not a real time could never say when it
663
+ // is due, and would exchange on every request, so it is not installed.
664
+ if (!anonymous && credentialRenewal !== undefined && Number.isFinite(credentialRenewal.expiresAtMilliseconds)) {
665
+ this.#renewCredential = credentialRenewal.renew;
666
+ this.#credentialExpiresAtMilliseconds = credentialRenewal.expiresAtMilliseconds;
667
+ }
646
668
  this.fetch = fetch;
647
669
  this.sleep = sleep;
648
670
  this.signal = signal;
@@ -665,6 +687,42 @@ export class SiteApiClient {
665
687
  return await this.#request(path, options, false);
666
688
  }
667
689
 
690
+ /**
691
+ * Replaces an exchanged credential that is within five minutes of expiring,
692
+ * once however many requests ask at the same time, before the request that
693
+ * would have carried the old one. A failed renewal is the request's failure:
694
+ * sending a credential known to be expiring would only be refused later.
695
+ */
696
+ async #renewCredentialIfDue() {
697
+ if (
698
+ this.#renewCredential === undefined
699
+ || this.now() < this.#credentialExpiresAtMilliseconds - CREDENTIAL_RENEWAL_MARGIN_MILLISECONDS
700
+ ) return;
701
+ this.#renewing ??= (async () => {
702
+ try {
703
+ const renewed = await this.#renewCredential();
704
+ if (!isWellFormedCredential(renewed?.key) || !Number.isFinite(renewed?.expiresAtMilliseconds)) {
705
+ throw new SiteAuthoringError(
706
+ "auth.renewal_invalid",
707
+ "Taproot returned an invalid renewed credential.",
708
+ );
709
+ }
710
+ this.#authorization = `Bearer ${renewed.key}`;
711
+ this.#credentialExpiresAtMilliseconds = renewed.expiresAtMilliseconds;
712
+ // A credential that is due again the moment it arrives (a lifetime
713
+ // shorter than the margin, or a clock far ahead of the server's) would
714
+ // be exchanged for on every request, each minting a key. It is used as
715
+ // it is, and the server's own refusal speaks if it has truly lapsed.
716
+ if (renewed.expiresAtMilliseconds - CREDENTIAL_RENEWAL_MARGIN_MILLISECONDS <= this.now()) {
717
+ this.#renewCredential = undefined;
718
+ }
719
+ } finally {
720
+ this.#renewing = undefined;
721
+ }
722
+ })();
723
+ await this.#renewing;
724
+ }
725
+
668
726
  /**
669
727
  * Replays only the POST shapes whose server contract answers a replay instead
670
728
  * of duplicating work — see `REPLACEABLE_POST_PATHS`. This is deliberately not
@@ -711,6 +769,7 @@ export class SiteApiClient {
711
769
  }
712
770
  const replayable = IDEMPOTENT_METHODS.has(method) || replaceableMutation;
713
771
  const serializedBody = body === undefined ? undefined : JSON.stringify(body);
772
+ await this.#renewCredentialIfDue();
714
773
  // Whether any attempt of a mutation has ended without an authoritative
715
774
  // outcome. Accumulated, not read off the final attempt: a claim whose first
716
775
  // send committed the mint and died on the reply, and whose retry then failed
@@ -843,10 +902,19 @@ export class SiteApiClient {
843
902
  * shape that may be replayed.
844
903
  */
845
904
  async upload({ url: uploadUrl, requiredHeaders } = {}, bodyBytes) {
905
+ // A video original is streamed from disk: `{ byteLength, stream, timeoutMilliseconds }`,
906
+ // where `stream()` returns a fresh readable of exactly `byteLength` bytes so a
907
+ // retried attempt reads the file again from the start.
908
+ const streamed = bodyBytes !== null
909
+ && typeof bodyBytes === "object"
910
+ && !ArrayBuffer.isView(bodyBytes)
911
+ && typeof bodyBytes.stream === "function"
912
+ && Number.isSafeInteger(bodyBytes.byteLength)
913
+ && bodyBytes.byteLength >= 0;
846
914
  if (
847
915
  typeof uploadUrl !== "string"
848
916
  || uploadUrl.length === 0
849
- || !ArrayBuffer.isView(bodyBytes)
917
+ || !(streamed || ArrayBuffer.isView(bodyBytes))
850
918
  ) {
851
919
  throw new SiteAuthoringError("upload.contract_invalid", "Taproot returned an invalid upload capability.");
852
920
  }
@@ -920,9 +988,15 @@ export class SiteApiClient {
920
988
  response = await this.fetch(url, {
921
989
  method: "PUT",
922
990
  redirect: "error",
923
- signal: operationSignal(this.signal, LIMITS.uploadMilliseconds, this.timeoutSignal),
991
+ signal: operationSignal(
992
+ this.signal,
993
+ (streamed ? bodyBytes.timeoutMilliseconds : undefined) ?? LIMITS.uploadMilliseconds,
994
+ this.timeoutSignal,
995
+ ),
924
996
  headers,
925
- body: bodyBytes,
997
+ ...(streamed
998
+ ? { body: Readable.toWeb(bodyBytes.stream()), duplex: "half" }
999
+ : { body: bodyBytes }),
926
1000
  });
927
1001
  } catch {
928
1002
  if (attempt + 1 < LIMITS.uploadAttempts && !this.signal?.aborted) {