@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.
@@ -0,0 +1,123 @@
1
+ // Generated from shared/media-presentation.ts by scripts/sync-field-validation.mjs. Do not edit.
2
+ /**
3
+ * The one owner of how an image, a video and a video embed are sized and
4
+ * bordered: the site-default option lists and normalizers, the per-block
5
+ * override grammar, and the CSS values built from them.
6
+ *
7
+ * Images, the Video block and the Video embed block read the same two site
8
+ * settings (max height, border width) and take the same two optional per-block
9
+ * overrides, so they share these functions and the custom properties they set.
10
+ *
11
+ * It is pure and dependency-free. The generator, the editor and the
12
+ * site-authoring CLI cannot all import this file, so
13
+ * `node scripts/sync-field-validation.mjs` writes their copies; edit only this
14
+ * file and re-run it. The API mirrors the site-setting ranges in
15
+ * `SiteSettings.cs`.
16
+ */
17
+ /** The "no explicit choice" sentinel stored in place of an override. */
18
+ export const TIPTAP_SITE_DEFAULT = "site-default";
19
+ export const DEFAULT_TIPTAP_IMAGE_MAX_HEIGHT_VH = 60;
20
+ export const TIPTAP_IMAGE_MAX_HEIGHT_OPTIONS = [45, 60, 75, 90];
21
+ // Mirrors TiptapImageBorder in the API's SiteSettings.cs. 2px matches
22
+ // esp-image's built-in border so untouched sites keep their current look.
23
+ export const TIPTAP_IMAGE_BORDER_WIDTH_MIN = 0;
24
+ export const TIPTAP_IMAGE_BORDER_WIDTH_MAX = 20;
25
+ export const DEFAULT_TIPTAP_IMAGE_BORDER_WIDTH_PX = 2;
26
+ /** The custom property every sized media block reads its max height from. */
27
+ export const MEDIA_MAX_HEIGHT_PROPERTY = "--taproot-article-image-max-height";
28
+ /** The custom property every bordered media block reads its border from. */
29
+ export const MEDIA_BORDER_PROPERTY = "--esp-image-border";
30
+ /** A number, or text read as one (a cleared field reads as 0); anything else is not a number. */
31
+ function optionNumber(value) {
32
+ if (typeof value === "number")
33
+ return value;
34
+ return typeof value === "string" ? Number(value) : Number.NaN;
35
+ }
36
+ function isMaxHeightOption(value) {
37
+ return TIPTAP_IMAGE_MAX_HEIGHT_OPTIONS.includes(optionNumber(value));
38
+ }
39
+ /** The site max height (in vh) for any stored value; anything outside the options is the default. */
40
+ export function normalizeImageMaxHeight(value) {
41
+ return isMaxHeightOption(value)
42
+ ? optionNumber(value)
43
+ : DEFAULT_TIPTAP_IMAGE_MAX_HEIGHT_VH;
44
+ }
45
+ /** An explicit per-image max height choice, or null for the site default, the sentinel or an invalid value. */
46
+ export function explicitImageMaxHeight(value) {
47
+ if (value === TIPTAP_SITE_DEFAULT || value === undefined || value === null)
48
+ return null;
49
+ return isMaxHeightOption(value) ? optionNumber(value) : null;
50
+ }
51
+ /** The site border width in pixels for any stored value; anything outside the range is the default. */
52
+ export function normalizeImageBorderWidthPx(value) {
53
+ const parsed = optionNumber(value);
54
+ if (!Number.isFinite(parsed))
55
+ return DEFAULT_TIPTAP_IMAGE_BORDER_WIDTH_PX;
56
+ const width = Math.trunc(parsed);
57
+ return width >= TIPTAP_IMAGE_BORDER_WIDTH_MIN && width <= TIPTAP_IMAGE_BORDER_WIDTH_MAX
58
+ ? width
59
+ : DEFAULT_TIPTAP_IMAGE_BORDER_WIDTH_PX;
60
+ }
61
+ const CSS_LENGTH_UNITS = "px|em|rem|ch|ex|vw|vh|svh|lvh|dvh|svw|lvw|dvw|vmin|vmax|%";
62
+ const CSS_LENGTH_RE = new RegExp(`^\\d+(?:\\.\\d+)?(?:${CSS_LENGTH_UNITS})$`, "i");
63
+ const CSS_VAR_RE = /^var\(--[a-z0-9-]+\)$/i;
64
+ const CSS_CALC_TERM = `(?:\\d+(?:\\.\\d+)?(?:${CSS_LENGTH_UNITS})|\\d+(?:\\.\\d+)?|var\\(--[a-z0-9-]+\\))`;
65
+ const CSS_CALC_RE = new RegExp(`^calc\\(\\s*${CSS_CALC_TERM}(?:\\s*[+\\-*/]\\s*${CSS_CALC_TERM})*\\s*\\)$`, "i");
66
+ /**
67
+ * Validate a user-supplied CSS length for per-block presentation overrides
68
+ * (max height, border width). These values are emitted into inline styles on
69
+ * published pages, so anything outside this strict grammar is rejected:
70
+ * unitless `0`, a non-negative length, `var(--token)`, `calc(...)` of those
71
+ * terms, and — when `allowNone` is set — the `none` keyword (max-height).
72
+ * Returns the trimmed value, or null when invalid or set to the site default.
73
+ */
74
+ export function normalizeCssLengthOverride(value, options) {
75
+ if (typeof value !== "string")
76
+ return null;
77
+ const trimmed = value.trim();
78
+ if (!trimmed || trimmed === TIPTAP_SITE_DEFAULT)
79
+ return null;
80
+ if (trimmed === "0")
81
+ return trimmed;
82
+ if (options?.allowNone && trimmed.toLowerCase() === "none")
83
+ return "none";
84
+ if (CSS_LENGTH_RE.test(trimmed))
85
+ return trimmed;
86
+ if (CSS_VAR_RE.test(trimmed))
87
+ return trimmed;
88
+ if (CSS_CALC_RE.test(trimmed))
89
+ return trimmed;
90
+ return null;
91
+ }
92
+ /** The validated max height override of a block (a CSS length or "none"), or null for the site default. */
93
+ export function normalizeMaxHeightOverride(value) {
94
+ return normalizeCssLengthOverride(value, { allowNone: true });
95
+ }
96
+ /** The validated border width override of a block (a CSS length), or null for the site default. */
97
+ export function normalizeBorderWidthOverride(value) {
98
+ return normalizeCssLengthOverride(value);
99
+ }
100
+ /** CSS value for the border hook given a width; zero collapses to none. */
101
+ export function imageBorderValue(width) {
102
+ return /^0(?:\.0+)?(?:[a-z%]+)?$/i.test(width) ? "none" : `${width} solid var(--esp-color-border)`;
103
+ }
104
+ /** The site-default border as CSS, from the stored pixel width. */
105
+ export function siteDefaultBorderValue(widthPx) {
106
+ return imageBorderValue(`${normalizeImageBorderWidthPx(widthPx)}px`);
107
+ }
108
+ /**
109
+ * The inline custom-property declarations that carry a block's overrides.
110
+ * Only an explicit, valid override is emitted, so an absent one keeps
111
+ * following the site default.
112
+ */
113
+ export function mediaOverrideStyleParts(overrides) {
114
+ const parts = [];
115
+ const maxHeight = normalizeMaxHeightOverride(overrides.maxHeight);
116
+ const borderWidth = normalizeBorderWidthOverride(overrides.borderWidth);
117
+ // A unitless zero would make the video frame's width calc invalid; 0px means the same to an image.
118
+ if (maxHeight)
119
+ parts.push(`${MEDIA_MAX_HEIGHT_PROPERTY}: ${maxHeight === "0" ? "0px" : maxHeight}`);
120
+ if (borderWidth)
121
+ parts.push(`${MEDIA_BORDER_PROPERTY}: ${imageBorderValue(borderWidth)}`);
122
+ return parts;
123
+ }
@@ -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
@@ -0,0 +1,227 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://taproot.io/schemas/field-definitions.json",
4
+ "title": "Taproot field definitions",
5
+ "description": "A flat, closed list of typed fields. shared/field-validation.ts and the API validator interpret it; shared/field-validation-corpus.json is the evidence they agree. Limits: 30 fields, 16 KB of submitted content. JSON Schema maxLength counts code points while the validators count UTF-16 code units, so the validators are authoritative and stricter for astral characters.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["fields"],
9
+ "properties": {
10
+ "fields": {
11
+ "type": "array",
12
+ "minItems": 1,
13
+ "maxItems": 30,
14
+ "items": { "$ref": "#/$defs/field" }
15
+ },
16
+ "contact_field": {
17
+ "description": "The id of the email field whose value the API copies to subject_email. It must name an email field. Omit it when the form has one email field (that field is the contact); it is required with two or more.",
18
+ "$ref": "#/$defs/id"
19
+ },
20
+ "submit_label": {
21
+ "description": "The text on the submit button. Omit it for Taproot's default. Must contain more than whitespace; the validator also rejects a blank label, which a schema cannot express.",
22
+ "type": "string",
23
+ "minLength": 1,
24
+ "maxLength": 40
25
+ },
26
+ "after_submit": {
27
+ "description": "What a visitor sees once a response is sent. Omit it to show Taproot's default message. A page is named by its resource id and must belong to the same site; the release freezes the path the page has when it is built.",
28
+ "oneOf": [
29
+ {
30
+ "type": "object",
31
+ "additionalProperties": false,
32
+ "required": ["show"],
33
+ "properties": {
34
+ "show": { "const": "message" },
35
+ "message": {
36
+ "description": "Omit it for Taproot's default. Must contain more than whitespace; the validator also rejects a blank message, which a schema cannot express.",
37
+ "type": "string",
38
+ "minLength": 1,
39
+ "maxLength": 500
40
+ }
41
+ }
42
+ },
43
+ {
44
+ "type": "object",
45
+ "additionalProperties": false,
46
+ "required": ["show", "page_resource_id"],
47
+ "properties": {
48
+ "show": { "const": "page" },
49
+ "page_resource_id": { "type": "string", "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" }
50
+ }
51
+ }
52
+ ]
53
+ }
54
+ },
55
+ "$defs": {
56
+ "id": { "type": "string", "pattern": "^[a-z][a-z0-9_]{0,39}$" },
57
+ "label": {
58
+ "description": "Must contain more than whitespace; the validator also rejects a blank label, which a schema cannot express.",
59
+ "type": "string",
60
+ "minLength": 1,
61
+ "maxLength": 200
62
+ },
63
+ "consentLabel": {
64
+ "description": "A consent field's label is the agreement the visitor accepts, so it is longer than any other label. Must contain more than whitespace; the validator also rejects a blank label.",
65
+ "type": "string",
66
+ "minLength": 1,
67
+ "maxLength": 1000
68
+ },
69
+ "hint": {
70
+ "description": "Optional helper text drawn under the field. Must contain more than whitespace; the validator rejects a blank hint, which a schema cannot express.",
71
+ "type": "string",
72
+ "minLength": 1,
73
+ "maxLength": 200
74
+ },
75
+ "required": { "type": "boolean" },
76
+ "shortLength": { "type": "integer", "minimum": 0, "maximum": 500 },
77
+ "shortMaxLength": { "type": "integer", "minimum": 1, "maximum": 500 },
78
+ "longLength": { "type": "integer", "minimum": 0, "maximum": 5000 },
79
+ "longMaxLength": { "type": "integer", "minimum": 1, "maximum": 5000 },
80
+ "choices": {
81
+ "description": "Each choice must contain more than whitespace; the validator rejects blank choices, which a schema cannot express.",
82
+ "type": "array",
83
+ "minItems": 1,
84
+ "maxItems": 50,
85
+ "uniqueItems": true,
86
+ "items": { "type": "string", "minLength": 1, "maxLength": 100 }
87
+ },
88
+ "isoDate": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" },
89
+ "relativeDateBound": {
90
+ "description": "A date measured from the site's calendar date, resolved when the page loads and again when a submission arrives. offset_days is bounded to a year either side (366 covers a leap year); further out is a fixed date. A fixed date is the other form of a date bound.",
91
+ "type": "object",
92
+ "additionalProperties": false,
93
+ "required": ["from"],
94
+ "properties": {
95
+ "from": {
96
+ "enum": ["today", "start_of_month", "end_of_month", "start_of_next_month", "end_of_next_month"]
97
+ },
98
+ "offset_days": { "type": "integer", "minimum": -366, "maximum": 366 }
99
+ }
100
+ },
101
+ "dateBound": {
102
+ "oneOf": [{ "$ref": "#/$defs/isoDate" }, { "$ref": "#/$defs/relativeDateBound" }]
103
+ },
104
+ "field": {
105
+ "type": "object",
106
+ "required": ["id", "type", "label"],
107
+ "oneOf": [
108
+ {
109
+ "properties": {
110
+ "id": { "$ref": "#/$defs/id" },
111
+ "type": { "const": "text" },
112
+ "label": { "$ref": "#/$defs/label" },
113
+ "hint": { "$ref": "#/$defs/hint" },
114
+ "required": { "$ref": "#/$defs/required" },
115
+ "min_length": { "$ref": "#/$defs/shortLength" },
116
+ "max_length": { "$ref": "#/$defs/shortMaxLength" },
117
+ "format": { "enum": ["digits", "alphanumeric"] }
118
+ },
119
+ "additionalProperties": false
120
+ },
121
+ {
122
+ "properties": {
123
+ "id": { "$ref": "#/$defs/id" },
124
+ "type": { "const": "long_text" },
125
+ "label": { "$ref": "#/$defs/label" },
126
+ "hint": { "$ref": "#/$defs/hint" },
127
+ "required": { "$ref": "#/$defs/required" },
128
+ "min_length": { "$ref": "#/$defs/longLength" },
129
+ "max_length": { "$ref": "#/$defs/longMaxLength" }
130
+ },
131
+ "additionalProperties": false
132
+ },
133
+ {
134
+ "properties": {
135
+ "id": { "$ref": "#/$defs/id" },
136
+ "type": { "enum": ["email", "url"] },
137
+ "label": { "$ref": "#/$defs/label" },
138
+ "hint": { "$ref": "#/$defs/hint" },
139
+ "required": { "$ref": "#/$defs/required" },
140
+ "max_length": { "$ref": "#/$defs/shortMaxLength" }
141
+ },
142
+ "additionalProperties": false
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
+ },
154
+ {
155
+ "properties": {
156
+ "id": { "$ref": "#/$defs/id" },
157
+ "type": { "const": "number" },
158
+ "label": { "$ref": "#/$defs/label" },
159
+ "hint": { "$ref": "#/$defs/hint" },
160
+ "required": { "$ref": "#/$defs/required" },
161
+ "min": { "type": "number" },
162
+ "max": { "type": "number" },
163
+ "format": { "enum": ["integer"] }
164
+ },
165
+ "additionalProperties": false
166
+ },
167
+ {
168
+ "properties": {
169
+ "id": { "$ref": "#/$defs/id" },
170
+ "type": { "const": "date" },
171
+ "label": { "$ref": "#/$defs/label" },
172
+ "hint": { "$ref": "#/$defs/hint" },
173
+ "required": { "$ref": "#/$defs/required" },
174
+ "min": { "$ref": "#/$defs/dateBound" },
175
+ "max": { "$ref": "#/$defs/dateBound" }
176
+ },
177
+ "additionalProperties": false
178
+ },
179
+ {
180
+ "required": ["choices"],
181
+ "properties": {
182
+ "id": { "$ref": "#/$defs/id" },
183
+ "type": { "const": "single_choice" },
184
+ "label": { "$ref": "#/$defs/label" },
185
+ "hint": { "$ref": "#/$defs/hint" },
186
+ "required": { "$ref": "#/$defs/required" },
187
+ "choices": { "$ref": "#/$defs/choices" }
188
+ },
189
+ "additionalProperties": false
190
+ },
191
+ {
192
+ "required": ["choices"],
193
+ "properties": {
194
+ "id": { "$ref": "#/$defs/id" },
195
+ "type": { "const": "multi_choice" },
196
+ "label": { "$ref": "#/$defs/label" },
197
+ "hint": { "$ref": "#/$defs/hint" },
198
+ "required": { "$ref": "#/$defs/required" },
199
+ "choices": { "$ref": "#/$defs/choices" },
200
+ "min": { "type": "integer", "minimum": 0, "maximum": 50 },
201
+ "max": { "type": "integer", "minimum": 0, "maximum": 50 }
202
+ },
203
+ "additionalProperties": false
204
+ },
205
+ {
206
+ "properties": {
207
+ "id": { "$ref": "#/$defs/id" },
208
+ "type": { "const": "checkbox" },
209
+ "label": { "$ref": "#/$defs/label" },
210
+ "hint": { "$ref": "#/$defs/hint" },
211
+ "required": { "$ref": "#/$defs/required" }
212
+ },
213
+ "additionalProperties": false
214
+ },
215
+ {
216
+ "properties": {
217
+ "id": { "$ref": "#/$defs/id" },
218
+ "type": { "const": "consent" },
219
+ "label": { "$ref": "#/$defs/consentLabel" },
220
+ "hint": { "$ref": "#/$defs/hint" }
221
+ },
222
+ "additionalProperties": false
223
+ }
224
+ ]
225
+ }
226
+ }
227
+ }