@taprootio/site-authoring 0.12.7 → 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 +9 -1
- package/package.json +3 -2
- package/src/api.js +81 -2
- package/src/cli.js +1 -1
- package/src/constants.js +3 -0
- package/src/content/components.js +159 -2
- package/src/content/free-form-section-registry.json +11 -1
- package/src/content/markdown.js +12 -2
- package/src/content/media-presentation.js +123 -0
- package/src/content/tiptap-prosemirror.ts +12 -64
- package/src/content/video-embed-url.js +104 -0
- package/src/content/vocabulary.js +9 -32
- package/src/field-schema.json +11 -1
- package/src/field-validation.js +8 -2
- package/src/fixture-contract.js +13 -2
- package/src/fixture-init.js +3 -0
- package/src/image-metadata.js +1 -1
- package/src/reference-help.js +18 -4
- package/src/session.js +22 -1
- package/src/transport.js +77 -3
- package/src/typed-pages.js +83 -13
- package/src/verbs/media-upload.js +352 -28
- package/src/verbs/pages-push.js +97 -2
- package/src/verbs/validate.js +37 -2
- package/src/video-metadata.js +90 -0
- package/src/video-prepare.js +136 -0
- package/src/workspace.js +113 -1
|
@@ -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 =
|
|
1206
|
-
const borderWidthOverride =
|
|
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:
|
|
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
|
-
|
|
190
|
-
|
|
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([
|
|
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
|
package/src/field-schema.json
CHANGED
|
@@ -133,7 +133,7 @@
|
|
|
133
133
|
{
|
|
134
134
|
"properties": {
|
|
135
135
|
"id": { "$ref": "#/$defs/id" },
|
|
136
|
-
"type": { "enum": ["email", "
|
|
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" },
|
package/src/field-validation.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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)
|
package/src/fixture-contract.js
CHANGED
|
@@ -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
|
-
/**
|
|
44
|
-
export const
|
|
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
|
package/src/fixture-init.js
CHANGED
|
@@ -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
|
});
|
package/src/image-metadata.js
CHANGED
|
@@ -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
|
|
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
|
}
|
package/src/reference-help.js
CHANGED
|
@@ -71,7 +71,7 @@ import {
|
|
|
71
71
|
|
|
72
72
|
export { getAppearanceReference, getFooterReference, getThemeReference };
|
|
73
73
|
|
|
74
|
-
export const REFERENCE_VERSION =
|
|
74
|
+
export const REFERENCE_VERSION = 32;
|
|
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. The command reports videos.items: { file, videoId, 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 poster, caption, and aspectRatio. 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}
|
|
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
|
-
|
|
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(
|
|
991
|
+
signal: operationSignal(
|
|
992
|
+
this.signal,
|
|
993
|
+
(streamed ? bodyBytes.timeoutMilliseconds : undefined) ?? LIMITS.uploadMilliseconds,
|
|
994
|
+
this.timeoutSignal,
|
|
995
|
+
),
|
|
924
996
|
headers,
|
|
925
|
-
|
|
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) {
|