@taprootio/site-authoring 0.1.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.
Files changed (57) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +239 -0
  3. package/bin/taproot-site.js +5 -0
  4. package/package.json +40 -0
  5. package/src/api.js +1507 -0
  6. package/src/appearance-contract.js +311 -0
  7. package/src/atomic-file.js +136 -0
  8. package/src/bounded-file.js +59 -0
  9. package/src/capabilities.js +29 -0
  10. package/src/cli.js +872 -0
  11. package/src/config.js +412 -0
  12. package/src/constants.js +173 -0
  13. package/src/content/components.js +870 -0
  14. package/src/content/free-form-section-registry.json +524 -0
  15. package/src/content/free-form-sections.js +582 -0
  16. package/src/content/index.js +29 -0
  17. package/src/content/inline-facts.js +97 -0
  18. package/src/content/markdown.js +1625 -0
  19. package/src/content/tiptap-prosemirror.ts +1266 -0
  20. package/src/content/validate-document.js +1079 -0
  21. package/src/content/vocabulary.js +397 -0
  22. package/src/credentials.js +544 -0
  23. package/src/errors.js +133 -0
  24. package/src/footer-contract.js +687 -0
  25. package/src/footer-draft-hash.js +203 -0
  26. package/src/footer-workspace.js +136 -0
  27. package/src/image-metadata.js +169 -0
  28. package/src/index.js +16 -0
  29. package/src/json-guard.js +111 -0
  30. package/src/output.js +157 -0
  31. package/src/presentation-reference.js +600 -0
  32. package/src/reference-help.js +927 -0
  33. package/src/session.js +316 -0
  34. package/src/settings-catalog.js +187 -0
  35. package/src/settings.js +157 -0
  36. package/src/theme-validation.js +344 -0
  37. package/src/transport.js +777 -0
  38. package/src/verbs/approve.js +178 -0
  39. package/src/verbs/deploy.js +327 -0
  40. package/src/verbs/env.js +89 -0
  41. package/src/verbs/footer-push.js +115 -0
  42. package/src/verbs/index.js +125 -0
  43. package/src/verbs/login.js +529 -0
  44. package/src/verbs/logout.js +39 -0
  45. package/src/verbs/media-upload.js +319 -0
  46. package/src/verbs/nav-push.js +333 -0
  47. package/src/verbs/pages-push.js +990 -0
  48. package/src/verbs/preview-page.js +107 -0
  49. package/src/verbs/preview-revoke.js +56 -0
  50. package/src/verbs/pull.js +727 -0
  51. package/src/verbs/sites.js +57 -0
  52. package/src/verbs/status.js +94 -0
  53. package/src/verbs/theme-push.js +197 -0
  54. package/src/verbs/use.js +129 -0
  55. package/src/verbs/validate.js +477 -0
  56. package/src/verbs/whoami.js +108 -0
  57. package/src/workspace.js +1085 -0
@@ -0,0 +1,1266 @@
1
+ /**
2
+ * Shared ProseMirror JSON helpers for Taproot rich-text page bodies.
3
+ *
4
+ * This is the canonical publishing source. The generator and site-authoring
5
+ * package keep local copies because importing across project roots changes
6
+ * TypeScript output structure or falls outside Docker/package boundaries.
7
+ * Keep those publishing copies in sync and covered by parity tests.
8
+ */
9
+
10
+ import freeFormSectionRegistry from "./free-form-section-registry.json" with { type: "json" };
11
+
12
+ export const TIPTAP_SITE_DEFAULT = "site-default";
13
+ export const DEFAULT_TIPTAP_IMAGE_MAX_HEIGHT_VH = 60;
14
+ export const TIPTAP_IMAGE_MAX_HEIGHT_OPTIONS = [45, 60, 75, 90] as const;
15
+ const SECTION_CONTEXT_PATTERN = new RegExp(
16
+ freeFormSectionRegistry.section.attrs.context.pattern,
17
+ "u",
18
+ );
19
+
20
+ export type TiptapSiteDefault = typeof TIPTAP_SITE_DEFAULT;
21
+ export type TiptapImageMaxHeightVh = typeof TIPTAP_IMAGE_MAX_HEIGHT_OPTIONS[number];
22
+ export type TiptapImagePresentationPlacement =
23
+ | "left"
24
+ | "left-text-right"
25
+ | "center"
26
+ | "right"
27
+ | "right-text-left";
28
+
29
+ export type JsonPrimitive = string | number | boolean | null;
30
+ export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
31
+
32
+ export interface ProseMirrorMark {
33
+ type: string;
34
+ attrs?: Record<string, JsonValue | undefined>;
35
+ }
36
+
37
+ export interface ProseMirrorNode {
38
+ type: string;
39
+ attrs?: Record<string, JsonValue | undefined>;
40
+ content?: ProseMirrorNode[];
41
+ marks?: ProseMirrorMark[];
42
+ text?: string;
43
+ }
44
+
45
+ export interface ProseMirrorDocument extends ProseMirrorNode {
46
+ type: "doc";
47
+ content?: ProseMirrorNode[];
48
+ }
49
+
50
+ export interface RenderProseMirrorOptions {
51
+ /** Normalize free-form root flow into full-bleed Espalier sections. */
52
+ freeFormSections?: boolean;
53
+ imageDefaults?: {
54
+ placement?: TiptapImagePresentationPlacement | string | null;
55
+ maxHeightVh?: TiptapImageMaxHeightVh | number | null;
56
+ };
57
+ }
58
+
59
+ export function createEmptyProseMirrorDocument(): ProseMirrorDocument {
60
+ return {
61
+ type: "doc",
62
+ content: [{ type: "paragraph" }],
63
+ };
64
+ }
65
+
66
+ export function isProseMirrorDocument(value: unknown): value is ProseMirrorDocument {
67
+ if (!isRecord(value)) return false;
68
+ if (value.type !== "doc") return false;
69
+ return value.content === undefined || Array.isArray(value.content);
70
+ }
71
+
72
+ export function renderProseMirrorDocumentToHtml(
73
+ document: ProseMirrorDocument | null | undefined,
74
+ options: RenderProseMirrorOptions = {},
75
+ ): string {
76
+ if (!document?.content?.length) return "";
77
+ return options.freeFormSections
78
+ ? renderFreeFormRootNodes(document.content, options)
79
+ : renderNodes(document.content, options, "root");
80
+ }
81
+
82
+ export function renderRichTextBodyToHtml(
83
+ body: unknown,
84
+ options: RenderProseMirrorOptions = {},
85
+ ): string {
86
+ if (typeof body === "string") return body;
87
+ if (!isProseMirrorDocument(body)) return "";
88
+ return renderProseMirrorDocumentToHtml(body, options);
89
+ }
90
+
91
+ type RenderPlacement = "root" | "section" | "nested";
92
+
93
+ function renderNodes(
94
+ nodes: readonly ProseMirrorNode[],
95
+ options: RenderProseMirrorOptions,
96
+ placement: RenderPlacement,
97
+ ): string {
98
+ return nodes.map((node) => renderNode(node, options, placement)).join("");
99
+ }
100
+
101
+ function renderFreeFormRootNodes(
102
+ nodes: readonly ProseMirrorNode[],
103
+ options: RenderProseMirrorOptions,
104
+ ): string {
105
+ const rendered: string[] = [];
106
+ let flow: ProseMirrorNode[] = [];
107
+ const flushFlow = () => {
108
+ if (flow.length === 0) return;
109
+ rendered.push(renderSection(flow, {}, "implicit", options, "root"));
110
+ flow = [];
111
+ };
112
+
113
+ for (const node of nodes) {
114
+ if (node.type === freeFormSectionRegistry.section.nodeType || isRootBandComponent(node)) {
115
+ flushFlow();
116
+ rendered.push(renderNode(node, options, "root"));
117
+ continue;
118
+ }
119
+ flow.push(node);
120
+ }
121
+ flushFlow();
122
+ return rendered.join("");
123
+ }
124
+
125
+ function isRootBandComponent(node: ProseMirrorNode): boolean {
126
+ if (node.type !== "componentBlock") return false;
127
+ const componentType = stringAttr(node.attrs?.componentType);
128
+ if (!componentType || !Object.prototype.hasOwnProperty.call(freeFormSectionRegistry.components, componentType)) {
129
+ return false;
130
+ }
131
+ return freeFormSectionRegistry.components[componentType as keyof typeof freeFormSectionRegistry.components]
132
+ .rootPlacement === "root-band";
133
+ }
134
+
135
+ function renderNode(
136
+ node: ProseMirrorNode,
137
+ options: RenderProseMirrorOptions,
138
+ placement: RenderPlacement,
139
+ ): string {
140
+ switch (node.type) {
141
+ case "doc":
142
+ return renderNodes(node.content ?? [], options, "nested");
143
+ case "paragraph":
144
+ return renderElement(
145
+ "p",
146
+ withProseMeasure(textBlockAttrs(node.attrs), node.type, options),
147
+ renderNodes(node.content ?? [], options, "nested"),
148
+ );
149
+ case "text":
150
+ return renderTextNode(node);
151
+ case "hardBreak":
152
+ return "<br>";
153
+ case "heading":
154
+ return renderHeading(node, options);
155
+ case "bulletList":
156
+ return renderElement(
157
+ "ul",
158
+ withProseMeasure({}, node.type, options),
159
+ renderNodes(node.content ?? [], options, "nested"),
160
+ );
161
+ case "orderedList":
162
+ return renderElement(
163
+ "ol",
164
+ withProseMeasure({}, node.type, options),
165
+ renderNodes(node.content ?? [], options, "nested"),
166
+ );
167
+ case "listItem":
168
+ return renderElement("li", {}, renderNodes(node.content ?? [], options, "nested"));
169
+ case "blockquote":
170
+ return renderElement(
171
+ "blockquote",
172
+ withProseMeasure({}, node.type, options),
173
+ renderNodes(node.content ?? [], options, "nested"),
174
+ );
175
+ case "codeBlock":
176
+ return renderCodeBlock(node, options);
177
+ case "horizontalRule":
178
+ return "<hr>";
179
+ case "table":
180
+ return placement === "root" || placement === "section" ? renderTable(node, options) : "";
181
+ case "taprootImage":
182
+ return renderTaprootImage(node, options);
183
+ case "inlineFacts":
184
+ return renderInlineFacts(node, options, placement);
185
+ case "componentBlock":
186
+ return renderComponentBlock(node);
187
+ case "section":
188
+ return renderSection(
189
+ node.content ?? [],
190
+ node.attrs ?? {},
191
+ "explicit",
192
+ options,
193
+ placement === "root" ? "section" : "nested",
194
+ );
195
+ case "rawHtml":
196
+ return stringAttr(node.attrs?.html) ?? "";
197
+ default:
198
+ return renderNodes(node.content ?? [], options, "nested");
199
+ }
200
+ }
201
+
202
+ function renderTextNode(node: ProseMirrorNode): string {
203
+ let rendered = escapeHtml(node.text ?? "");
204
+ for (const mark of node.marks ?? []) {
205
+ rendered = wrapMark(rendered, mark);
206
+ }
207
+ return rendered;
208
+ }
209
+
210
+ function wrapMark(html: string, mark: ProseMirrorMark): string {
211
+ switch (mark.type) {
212
+ case "bold":
213
+ return `<strong>${html}</strong>`;
214
+ case "italic":
215
+ return `<em>${html}</em>`;
216
+ case "underline":
217
+ return `<u>${html}</u>`;
218
+ case "strike":
219
+ return `<s>${html}</s>`;
220
+ case "code":
221
+ return `<code>${html}</code>`;
222
+ case "link": {
223
+ const href = stringAttr(mark.attrs?.href);
224
+ if (!href || !isSafeUrl(href)) return html;
225
+ return renderElement(
226
+ "a",
227
+ {
228
+ href,
229
+ rel: "noopener noreferrer nofollow",
230
+ },
231
+ html,
232
+ );
233
+ }
234
+ default:
235
+ return html;
236
+ }
237
+ }
238
+
239
+ function renderHeading(node: ProseMirrorNode, options: RenderProseMirrorOptions): string {
240
+ const rawLevel = numberAttr(node.attrs?.level) ?? 1;
241
+ const level = Math.min(4, Math.max(1, Math.trunc(rawLevel)));
242
+ return renderElement(
243
+ `h${level}`,
244
+ withProseMeasure(textBlockAttrs(node.attrs), node.type, options),
245
+ renderNodes(node.content ?? [], options, "nested"),
246
+ );
247
+ }
248
+
249
+ function renderCodeBlock(node: ProseMirrorNode, options: RenderProseMirrorOptions): string {
250
+ const language = stringAttr(node.attrs?.language);
251
+ const attrs = language ? { class: `language-${language}` } : {};
252
+ const text = (node.content ?? [])
253
+ .map((child) => child.text ?? "")
254
+ .join("");
255
+ return `<pre${serializeAttributes(withProseMeasure({}, node.type, options))}><code${serializeAttributes(attrs)}>${
256
+ escapeHtml(text)
257
+ }</code></pre>`;
258
+ }
259
+
260
+ type NormalizedTable = {
261
+ caption?: string;
262
+ headerCells: string[];
263
+ dataRows: string[][];
264
+ };
265
+
266
+ /**
267
+ * Render the closed table contract only after re-checking its complete shape.
268
+ * Publish readiness provides detailed diagnostics; display-only callers can
269
+ * also receive stored JSON directly, so the renderer's safe failure is to
270
+ * omit one malformed table rather than reinterpret arbitrary blocks as cells.
271
+ */
272
+ function renderTable(node: ProseMirrorNode, options: RenderProseMirrorOptions): string {
273
+ const table = normalizeTable(node);
274
+ if (!table) return "";
275
+
276
+ const header = renderElement(
277
+ "thead",
278
+ {},
279
+ renderElement(
280
+ "tr",
281
+ {},
282
+ table.headerCells.map((cell) => renderElement("th", { scope: "col" }, cell)).join(""),
283
+ ),
284
+ );
285
+ const body = renderElement(
286
+ "tbody",
287
+ {},
288
+ table.dataRows.map((row) =>
289
+ renderElement(
290
+ "tr",
291
+ {},
292
+ row.map((cell) => renderElement("td", {}, cell)).join(""),
293
+ )
294
+ ).join(""),
295
+ );
296
+ const caption = table.caption === undefined
297
+ ? ""
298
+ : renderElement("caption", {}, escapeHtml(table.caption));
299
+ const accessibleLabel = table.caption === undefined
300
+ ? "Scrollable table"
301
+ : `Scrollable table: ${table.caption}`;
302
+
303
+ return renderElement(
304
+ "div",
305
+ withProseMeasure(
306
+ {
307
+ class: "taproot-table-scroll",
308
+ role: "region",
309
+ tabindex: "0",
310
+ "aria-label": accessibleLabel,
311
+ },
312
+ node.type,
313
+ options,
314
+ ),
315
+ renderElement("table", {}, `${caption}${header}${body}`),
316
+ );
317
+ }
318
+
319
+ function normalizeTable(node: ProseMirrorNode): NormalizedTable | undefined {
320
+ const definition = freeFormSectionRegistry.table;
321
+ if (!hasOnlySerializedKeys(node, ["type", "attrs", "content"])) return undefined;
322
+ const attrs = node.attrs;
323
+ if (attrs !== undefined && attrs !== null && (!isRecord(attrs) || !hasOnlyKeys(attrs, ["caption"]))) {
324
+ return undefined;
325
+ }
326
+
327
+ const captionValue = attrs && hasOwn(attrs, "caption") ? attrs.caption : undefined;
328
+ if (
329
+ captionValue !== undefined
330
+ && captionValue !== null
331
+ && (
332
+ typeof captionValue !== "string"
333
+ || captionValue.trim() === ""
334
+ || countUnicodeScalars(captionValue) < definition.attrs.caption.minScalars
335
+ || countUnicodeScalars(captionValue) > definition.attrs.caption.maxScalars
336
+ )
337
+ ) return undefined;
338
+
339
+ if (!Array.isArray(node.content)) return undefined;
340
+ const minimumRows = definition.rows.header + definition.rows.minData;
341
+ const maximumRows = definition.rows.header + definition.rows.maxData;
342
+ if (node.content.length < minimumRows || node.content.length > maximumRows) return undefined;
343
+
344
+ const [headerRow, ...dataRows] = node.content;
345
+ if (
346
+ !isRecord(headerRow)
347
+ || !hasOnlySerializedKeys(headerRow, ["type", "attrs", "content"])
348
+ || headerRow.type !== definition.nodeTypes.row
349
+ || !hasNoAttrs(headerRow)
350
+ ) {
351
+ return undefined;
352
+ }
353
+ if (!Array.isArray(headerRow.content)) return undefined;
354
+ const columnCount = headerRow.content.length;
355
+ if (columnCount < definition.columns.min || columnCount > definition.columns.max) {
356
+ return undefined;
357
+ }
358
+
359
+ const headerCells = normalizeTableRow(headerRow, definition.nodeTypes.header, true);
360
+ if (!headerCells) return undefined;
361
+ const normalizedDataRows: string[][] = [];
362
+ for (const row of dataRows) {
363
+ if (
364
+ !isRecord(row)
365
+ || !hasOnlySerializedKeys(row, ["type", "attrs", "content"])
366
+ || row.type !== definition.nodeTypes.row
367
+ || !hasNoAttrs(row)
368
+ || !Array.isArray(row.content)
369
+ || row.content.length !== columnCount
370
+ ) return undefined;
371
+ const normalized = normalizeTableRow(row, definition.nodeTypes.cell, false);
372
+ if (!normalized) return undefined;
373
+ normalizedDataRows.push(normalized);
374
+ }
375
+
376
+ return {
377
+ ...(typeof captionValue === "string" ? { caption: captionValue } : {}),
378
+ headerCells,
379
+ dataRows: normalizedDataRows,
380
+ };
381
+ }
382
+
383
+ function normalizeTableRow(
384
+ row: ProseMirrorNode,
385
+ expectedCellType: string,
386
+ header: boolean,
387
+ ): string[] | undefined {
388
+ if (!Array.isArray(row.content)) return undefined;
389
+ const cells: string[] = [];
390
+ for (const cell of row.content) {
391
+ if (
392
+ !isRecord(cell)
393
+ || !hasOnlySerializedKeys(cell, ["type", "attrs", "content"])
394
+ || cell.type !== expectedCellType
395
+ || !hasNoAttrs(cell)
396
+ ) return undefined;
397
+ const normalized = normalizeTableCell(cell);
398
+ if (!normalized) return undefined;
399
+ if (header && freeFormSectionRegistry.table.cells.headerNonWhitespace && normalized.text.trim() === "") {
400
+ return undefined;
401
+ }
402
+ cells.push(normalized.html);
403
+ }
404
+ return cells;
405
+ }
406
+
407
+ function normalizeTableCell(cell: ProseMirrorNode): { html: string; text: string } | undefined {
408
+ const paragraphs = cell.content;
409
+ const paragraph = paragraphs?.[0];
410
+ if (
411
+ !Array.isArray(paragraphs)
412
+ || paragraphs.length !== freeFormSectionRegistry.table.cells.paragraphs
413
+ || !isRecord(paragraph)
414
+ || !hasOnlySerializedKeys(paragraph, ["type", "attrs", "content"])
415
+ || paragraph.type !== "paragraph"
416
+ || (!freeFormSectionRegistry.table.cells.paragraphAttrs && !hasNoAttrs(paragraph))
417
+ ) return undefined;
418
+
419
+ const inlineContent = paragraph.content;
420
+ if (inlineContent !== undefined && inlineContent !== null && !Array.isArray(inlineContent)) return undefined;
421
+ const inlineNodes = inlineContent ?? [];
422
+ const allowedInlineNodes = freeFormSectionRegistry.table.cells.inlineNodes as readonly string[];
423
+ const allowedMarks = freeFormSectionRegistry.table.cells.marks as readonly string[];
424
+ let text = "";
425
+ const html: string[] = [];
426
+ for (const inlineNode of inlineNodes) {
427
+ if (!isRecord(inlineNode) || !allowedInlineNodes.includes(inlineNode.type)) return undefined;
428
+ if (!validTableMarks(inlineNode.marks, allowedMarks)) return undefined;
429
+ if (inlineNode.type === "text") {
430
+ if (
431
+ !hasOnlySerializedKeys(inlineNode, ["type", "attrs", "marks", "text"])
432
+ || !hasNoAttrs(inlineNode)
433
+ || typeof inlineNode.text !== "string"
434
+ || inlineNode.text === ""
435
+ ) return undefined;
436
+ text += inlineNode.text;
437
+ html.push(renderTextNode(inlineNode));
438
+ } else if (inlineNode.type === "hardBreak") {
439
+ if (
440
+ !hasOnlySerializedKeys(inlineNode, ["type", "attrs", "marks"])
441
+ || !hasNoAttrs(inlineNode)
442
+ ) return undefined;
443
+ html.push("<br>");
444
+ }
445
+ }
446
+ if (countUnicodeScalars(text) > freeFormSectionRegistry.table.cells.maxTextScalars) {
447
+ return undefined;
448
+ }
449
+ return { html: html.join(""), text };
450
+ }
451
+
452
+ const TABLE_LINK_ATTRS = ["href", "target", "rel", "class", "title"] as const;
453
+
454
+ function validTableMarks(marks: unknown, allowedMarks: readonly string[]): boolean {
455
+ if (marks === undefined || marks === null) return true;
456
+ if (!Array.isArray(marks)) return false;
457
+ const seen = new Set<string>();
458
+ for (const mark of marks) {
459
+ const markType = isRecord(mark) ? stringAttr(mark.type) : undefined;
460
+ if (
461
+ !isRecord(mark)
462
+ || !hasOnlySerializedKeys(mark, ["type", "attrs"])
463
+ || !markType
464
+ || !allowedMarks.includes(markType)
465
+ || seen.has(markType)
466
+ ) return false;
467
+ seen.add(markType);
468
+ const attrs = mark.attrs;
469
+ if (markType !== "link") {
470
+ if (attrs !== undefined && attrs !== null && (!isRecord(attrs) || Object.keys(attrs).length > 0)) return false;
471
+ continue;
472
+ }
473
+ if (!isRecord(attrs) || typeof attrs.href !== "string" || attrs.href === "") return false;
474
+ if (!hasOnlyKeys(attrs, TABLE_LINK_ATTRS)) return false;
475
+ if (
476
+ TABLE_LINK_ATTRS.slice(1).some((key) =>
477
+ attrs[key] !== undefined && attrs[key] !== null && typeof attrs[key] !== "string"
478
+ )
479
+ ) return false;
480
+ }
481
+ return true;
482
+ }
483
+
484
+ function hasOnlySerializedKeys(value: object, keys: readonly string[]): boolean {
485
+ return Object.keys(value).every((key) => keys.includes(key));
486
+ }
487
+
488
+ function hasNoAttrs(node: ProseMirrorNode): boolean {
489
+ return node.attrs === undefined || node.attrs === null
490
+ || (isRecord(node.attrs) && Object.keys(node.attrs).length === 0);
491
+ }
492
+
493
+ function countUnicodeScalars(value: string): number {
494
+ return [...value].length;
495
+ }
496
+
497
+ type NormalizedInlineFact = {
498
+ value: string;
499
+ label: string;
500
+ url?: string;
501
+ };
502
+
503
+ function renderInlineFacts(
504
+ node: ProseMirrorNode,
505
+ options: RenderProseMirrorOptions,
506
+ placement: RenderPlacement,
507
+ ): string {
508
+ if (placement !== "root" && placement !== "section") return "";
509
+ const facts = normalizeInlineFacts(node);
510
+ if (!facts) return "";
511
+ const items = facts.map((fact) => {
512
+ const value = fact.url
513
+ ? renderElement("a", { href: fact.url }, escapeHtml(fact.value))
514
+ : escapeHtml(fact.value);
515
+ return renderElement(
516
+ "div",
517
+ { class: "taproot-inline-fact" },
518
+ `${renderElement("dt", { class: "taproot-inline-fact__label" }, escapeHtml(fact.label))}${
519
+ renderElement("dd", { class: "taproot-inline-fact__value" }, value)
520
+ }`,
521
+ );
522
+ }).join("");
523
+ return renderElement(
524
+ "dl",
525
+ withProseMeasure({ class: "taproot-inline-facts" }, node.type, options),
526
+ items,
527
+ );
528
+ }
529
+
530
+ function normalizeInlineFacts(node: ProseMirrorNode): NormalizedInlineFact[] | undefined {
531
+ const definition = freeFormSectionRegistry.inlineFacts.attrs.items;
532
+ if (
533
+ !hasOnlySerializedKeys(node, ["type", "attrs"])
534
+ || !isRecord(node.attrs)
535
+ || !hasOnlyKeys(node.attrs, ["items"])
536
+ || !hasOwn(node.attrs, "items")
537
+ || !Array.isArray(node.attrs.items)
538
+ || node.attrs.items.length < definition.minItems
539
+ || node.attrs.items.length > definition.maxItems
540
+ ) return undefined;
541
+
542
+ const fieldKeys = Object.keys(definition.fields);
543
+ const facts: NormalizedInlineFact[] = [];
544
+ for (const value of node.attrs.items) {
545
+ if (!isRecord(value) || !hasOnlyKeys(value, fieldKeys)) return undefined;
546
+ const factValue = stringAttr(value.value);
547
+ const label = stringAttr(value.label);
548
+ if (
549
+ !factValue
550
+ || !label
551
+ || factValue.trim() === ""
552
+ || label.trim() === ""
553
+ || countUnicodeScalars(factValue) > definition.fields.value.maxScalars
554
+ || countUnicodeScalars(label) > definition.fields.label.maxScalars
555
+ ) return undefined;
556
+ const url = value.url === undefined || value.url === null ? undefined : stringAttr(value.url);
557
+ if (value.url !== undefined && value.url !== null && (!url || !isSafeUrl(url))) return undefined;
558
+ facts.push({ value: factValue, label, url });
559
+ }
560
+ return facts;
561
+ }
562
+
563
+ function renderSection(
564
+ nodes: readonly ProseMirrorNode[],
565
+ attrs: Record<string, JsonValue | undefined>,
566
+ kind: "explicit" | "implicit",
567
+ options: RenderProseMirrorOptions,
568
+ contentPlacement: RenderPlacement,
569
+ ): string {
570
+ const definitions = freeFormSectionRegistry.section.attrs;
571
+ const contentPadding = attrs.contentPadding === "none" ? "none" : definitions.contentPadding.default;
572
+ const surface = attrs.surface === "raised" || attrs.surface === "elevated"
573
+ ? attrs.surface
574
+ : definitions.surface.default;
575
+ const contextValue = kind === "explicit" ? stringAttr(attrs.context) : undefined;
576
+ const context = contextValue
577
+ && countUnicodeScalars(contextValue) <= definitions.context.maximumLength
578
+ && SECTION_CONTEXT_PATTERN.test(contextValue)
579
+ ? contextValue
580
+ : undefined;
581
+ const sectionStyleParts: string[] = [];
582
+ if (contentPadding === "none") {
583
+ sectionStyleParts.push(
584
+ "--esp-section-max-width: none",
585
+ "--esp-section-padding-inline: 0",
586
+ "--esp-section-padding-block: 0",
587
+ );
588
+ }
589
+ if (kind === "explicit") {
590
+ const decorationStyle = normalizedSectionDecorationStyle(attrs.decoration);
591
+ if (decorationStyle) sectionStyleParts.push(...decorationStyle);
592
+ }
593
+ const background = kind === "explicit" ? normalizeSectionBackground(attrs.background) : undefined;
594
+ if (background) {
595
+ sectionStyleParts.push(
596
+ `--esp-section-background: color-mix(in oklab, var(--esp-color-background) ${background.scrimOpacityPercent}%, transparent)`,
597
+ );
598
+ }
599
+ const sectionStyle = sectionStyleParts.length > 0 ? sectionStyleParts.join("; ") : undefined;
600
+ const content = renderElement(
601
+ "div",
602
+ { "data-section-content": true },
603
+ renderNodes(nodes, options, contentPlacement),
604
+ );
605
+ const surfaced = surface === "none"
606
+ ? content
607
+ : renderElement(
608
+ "esp-box",
609
+ {
610
+ "data-surface": surface,
611
+ style: surface === "elevated"
612
+ ? "--esp-color-box-background: var(--esp-color-layer-2); --esp-box-shadow: var(--esp-shadow-2)"
613
+ : undefined,
614
+ },
615
+ content,
616
+ );
617
+ const section = renderElement(
618
+ "esp-section",
619
+ {
620
+ "data-taproot-section": kind,
621
+ "data-content-padding": contentPadding,
622
+ "data-surface": surface,
623
+ context,
624
+ style: sectionStyle,
625
+ },
626
+ surfaced,
627
+ );
628
+ if (!background) return section;
629
+
630
+ return renderElement(
631
+ "div",
632
+ {
633
+ "data-taproot-section-background": true,
634
+ style:
635
+ `--taproot-section-focus: ${background.focus}; --taproot-section-portrait-focus: ${background.portraitFocus}`,
636
+ },
637
+ `${renderSectionBackgroundPicture(background)}${section}`,
638
+ );
639
+ }
640
+
641
+ type NormalizedSectionImage = {
642
+ src: string;
643
+ urls: Array<{ minWidth: number; url: string }>;
644
+ width?: number;
645
+ height?: number;
646
+ alt?: string;
647
+ };
648
+
649
+ type NormalizedSectionBackground = {
650
+ image: NormalizedSectionImage;
651
+ portraitImage?: NormalizedSectionImage;
652
+ focus: string;
653
+ portraitFocus: string;
654
+ scrimOpacityPercent: number;
655
+ };
656
+
657
+ type SectionImageDefinition = {
658
+ requiredKeys: readonly string[];
659
+ optionalKeys: readonly string[];
660
+ urls: {
661
+ minItems: number;
662
+ maxItems: number;
663
+ fields: {
664
+ minWidth: { minimum: number };
665
+ type: { values: readonly string[] };
666
+ };
667
+ };
668
+ srcMustMatchUrls: boolean;
669
+ };
670
+
671
+ function normalizeSectionBackground(value: unknown): NormalizedSectionBackground | undefined {
672
+ if (!isRecord(value)) return undefined;
673
+ const definition = freeFormSectionRegistry.section.attrs.background;
674
+ const fields = definition.fields;
675
+ if (!hasOnlyKeys(value, Object.keys(fields))) return undefined;
676
+
677
+ const image = normalizeSectionImage(value.image, fields.image);
678
+ if (!image) return undefined;
679
+ const portraitImage = value.portraitImage === undefined || value.portraitImage === null
680
+ ? undefined
681
+ : normalizeSectionImage(value.portraitImage, fields.portraitImage);
682
+ if (value.portraitImage !== undefined && value.portraitImage !== null && !portraitImage) return undefined;
683
+
684
+ const focus = normalizeSectionFocus(value.focus, fields.focus);
685
+ if (!focus) return undefined;
686
+ const portraitFocus = value.portraitFocus === undefined || value.portraitFocus === null
687
+ ? focus
688
+ : normalizeSectionFocus(value.portraitFocus, fields.portraitFocus);
689
+ if (!portraitFocus) return undefined;
690
+
691
+ const scrimStrength = value.scrimStrength ?? fields.scrimStrength.default;
692
+ if (typeof scrimStrength !== "string" || !fields.scrimStrength.values.includes(scrimStrength as never)) {
693
+ return undefined;
694
+ }
695
+ const opacity = fields.scrimStrength.opacityByValue[
696
+ scrimStrength as keyof typeof fields.scrimStrength.opacityByValue
697
+ ];
698
+ if (typeof opacity !== "number") return undefined;
699
+
700
+ return {
701
+ image,
702
+ portraitImage,
703
+ focus,
704
+ portraitFocus,
705
+ scrimOpacityPercent: Number((opacity * 100).toFixed(4)),
706
+ };
707
+ }
708
+
709
+ function normalizeSectionImage(
710
+ value: unknown,
711
+ definition: SectionImageDefinition,
712
+ ): NormalizedSectionImage | undefined {
713
+ if (!isRecord(value)) return undefined;
714
+ const keys = [...definition.requiredKeys, ...definition.optionalKeys];
715
+ if (!hasOnlyKeys(value, keys) || definition.requiredKeys.some((key) => !hasOwn(value, key))) return undefined;
716
+ if (!isCanonicalUuid(value.imageId)) return undefined;
717
+ const src = stringAttr(value.src);
718
+ if (!src || !isSafeSectionImageUrl(src)) return undefined;
719
+ if (
720
+ !Array.isArray(value.urls)
721
+ || value.urls.length < definition.urls.minItems
722
+ || value.urls.length > definition.urls.maxItems
723
+ ) return undefined;
724
+ const urls = value.urls.map((candidate) => normalizeSectionResponsiveUrl(candidate, definition.urls));
725
+ if (urls.some((candidate) => !candidate)) return undefined;
726
+ const candidates = (urls as Array<{ minWidth: number; url: string }>)
727
+ .sort((left, right) => left.minWidth - right.minWidth);
728
+ if (definition.srcMustMatchUrls && !candidates.some((candidate) => candidate.url === src)) return undefined;
729
+ if (
730
+ (hasOwn(value, "width") && !isPositiveInteger(value.width))
731
+ || (hasOwn(value, "height") && !isPositiveInteger(value.height))
732
+ || (hasOwn(value, "alt") && typeof value.alt !== "string")
733
+ ) return undefined;
734
+ return {
735
+ src,
736
+ urls: candidates,
737
+ width: isPositiveInteger(value.width) ? value.width : undefined,
738
+ height: isPositiveInteger(value.height) ? value.height : undefined,
739
+ alt: typeof value.alt === "string" ? value.alt : undefined,
740
+ };
741
+ }
742
+
743
+ function normalizeSectionResponsiveUrl(
744
+ value: unknown,
745
+ definition: SectionImageDefinition["urls"],
746
+ ): { minWidth: number; url: string } | undefined {
747
+ if (!isRecord(value) || !hasOnlyKeys(value, Object.keys(definition.fields))) return undefined;
748
+ if (
749
+ !isPositiveInteger(value.minWidth)
750
+ || value.minWidth < definition.fields.minWidth.minimum
751
+ || typeof value.url !== "string"
752
+ || !isSafeSectionImageUrl(value.url)
753
+ ) {
754
+ return undefined;
755
+ }
756
+ if (
757
+ value.type !== undefined
758
+ && (typeof value.type !== "string" || !definition.fields.type.values.includes(value.type))
759
+ ) return undefined;
760
+ return { minWidth: value.minWidth, url: value.url };
761
+ }
762
+
763
+ function normalizeSectionFocus(
764
+ value: unknown,
765
+ definition: {
766
+ default: { x: number; y: number } | null;
767
+ fields: {
768
+ x: { default: number; minimum: number; maximum: number };
769
+ y: { default: number; minimum: number; maximum: number };
770
+ };
771
+ },
772
+ ): string | undefined {
773
+ if (value === undefined || value === null) {
774
+ if (!definition.default) return undefined;
775
+ return `${definition.default.x * 100}% ${definition.default.y * 100}%`;
776
+ }
777
+ if (!isRecord(value) || !hasOnlyKeys(value, ["x", "y"])) return undefined;
778
+ const x = value.x ?? definition.fields.x.default;
779
+ const y = value.y ?? definition.fields.y.default;
780
+ if (
781
+ !isBoundedNumber(x, definition.fields.x.minimum, definition.fields.x.maximum)
782
+ || !isBoundedNumber(y, definition.fields.y.minimum, definition.fields.y.maximum)
783
+ ) return undefined;
784
+ return `${x * 100}% ${y * 100}%`;
785
+ }
786
+
787
+ function renderSectionBackgroundPicture(background: NormalizedSectionBackground): string {
788
+ const portraitSource = background.portraitImage
789
+ ? renderVoidElement("source", {
790
+ media: "(max-width: 48rem)",
791
+ srcset: sectionImageSrcset(background.portraitImage),
792
+ sizes: "100vw",
793
+ type: "image/webp",
794
+ })
795
+ : "";
796
+ const image = renderVoidElement("img", {
797
+ src: background.image.src,
798
+ srcset: sectionImageSrcset(background.image),
799
+ sizes: "100vw",
800
+ width: background.image.width,
801
+ height: background.image.height,
802
+ alt: "",
803
+ decoding: "async",
804
+ loading: "lazy",
805
+ });
806
+ return renderElement(
807
+ "picture",
808
+ {
809
+ "data-taproot-section-background-picture": true,
810
+ "data-taproot-section-background-alt": background.image.alt,
811
+ "aria-hidden": "true",
812
+ },
813
+ `${portraitSource}${image}`,
814
+ );
815
+ }
816
+
817
+ function sectionImageSrcset(image: NormalizedSectionImage): string {
818
+ return image.urls.map((candidate) => `${candidate.url} ${candidate.minWidth}w`).join(", ");
819
+ }
820
+
821
+ function isSafeSectionImageUrl(value: string): boolean {
822
+ if (value === "" || /[\u0000-\u0020\u007f]/u.test(value) || value.includes("\\")) return false;
823
+ if (value.startsWith("/")) return !value.startsWith("//");
824
+ if (!/^https:\/\//iu.test(value)) return false;
825
+ try {
826
+ const parsed = new URL(value);
827
+ return parsed.protocol === "https:"
828
+ && parsed.hostname !== ""
829
+ && parsed.username === ""
830
+ && parsed.password === "";
831
+ } catch {
832
+ return false;
833
+ }
834
+ }
835
+
836
+ type NormalizedSectionDecoration = {
837
+ imageUrl: string;
838
+ anchor: string;
839
+ inlineOffsetPercent: number;
840
+ blockOffsetPercent: number;
841
+ inlineSizePercent: number;
842
+ opacity: number;
843
+ tintToken: string;
844
+ };
845
+
846
+ function normalizedSectionDecorationStyle(value: unknown): string[] | undefined {
847
+ const decoration = normalizeSectionDecoration(value);
848
+ if (!decoration) return undefined;
849
+ return [
850
+ `--esp-section-decoration-image: url("${escapeCssString(decoration.imageUrl)}")`,
851
+ `--esp-section-decoration-color: var(${decoration.tintToken})`,
852
+ `--esp-section-decoration-position: ${sectionDecorationPosition(decoration)}`,
853
+ `--esp-section-decoration-size: ${decoration.inlineSizePercent}% auto`,
854
+ `--esp-section-decoration-opacity: ${decoration.opacity}`,
855
+ ];
856
+ }
857
+
858
+ /**
859
+ * Re-check the document boundary before emitting inline CSS. Authoring and
860
+ * readiness validation report the actionable diagnostic; the renderer's safe
861
+ * failure is to omit all five hooks rather than partially paint an invalid or
862
+ * attacker-controlled decoration.
863
+ */
864
+ function normalizeSectionDecoration(value: unknown): NormalizedSectionDecoration | undefined {
865
+ if (!isRecord(value)) return undefined;
866
+ const fields = freeFormSectionRegistry.section.attrs.decoration.fields;
867
+ if (!hasOnlyKeys(value, Object.keys(fields))) return undefined;
868
+
869
+ const image = normalizeSectionImage(value.image, fields.image);
870
+ if (!image) return undefined;
871
+
872
+ const anchor = value.anchor ?? fields.anchor.default;
873
+ const inlineOffsetPercent = value.inlineOffsetPercent ?? fields.inlineOffsetPercent.default;
874
+ const blockOffsetPercent = value.blockOffsetPercent ?? fields.blockOffsetPercent.default;
875
+ const inlineSizePercent = value.inlineSizePercent ?? fields.inlineSizePercent.default;
876
+ const opacity = value.opacity ?? fields.opacity.default;
877
+ const tint = value.tint ?? fields.tint.default;
878
+ if (typeof anchor !== "string" || !fields.anchor.values.includes(anchor as never)) return undefined;
879
+ if (!isBoundedNumber(inlineOffsetPercent, fields.inlineOffsetPercent.minimum, fields.inlineOffsetPercent.maximum)) {
880
+ return undefined;
881
+ }
882
+ if (!isBoundedNumber(blockOffsetPercent, fields.blockOffsetPercent.minimum, fields.blockOffsetPercent.maximum)) {
883
+ return undefined;
884
+ }
885
+ if (
886
+ !isBoundedNumber(inlineSizePercent, fields.inlineSizePercent.minimum, fields.inlineSizePercent.maximum)
887
+ || !Number.isInteger(inlineSizePercent)
888
+ ) return undefined;
889
+ if (!isBoundedNumber(opacity, fields.opacity.minimum, fields.opacity.maximum)) return undefined;
890
+ if (typeof tint !== "string" || !fields.tint.values.includes(tint as never)) return undefined;
891
+ const tintToken = fields.tint.tokenByValue[tint as keyof typeof fields.tint.tokenByValue];
892
+ if (!tintToken) return undefined;
893
+
894
+ return {
895
+ imageUrl: image.src,
896
+ anchor,
897
+ inlineOffsetPercent,
898
+ blockOffsetPercent,
899
+ inlineSizePercent,
900
+ opacity,
901
+ tintToken,
902
+ };
903
+ }
904
+
905
+ function sectionDecorationPosition(decoration: NormalizedSectionDecoration): string {
906
+ const [inlineBase, blockBase] = (() => {
907
+ switch (decoration.anchor) {
908
+ case "top-start":
909
+ return [0, 0];
910
+ case "top-end":
911
+ return [100, 0];
912
+ case "bottom-start":
913
+ return [0, 100];
914
+ case "bottom-end":
915
+ return [100, 100];
916
+ case "center":
917
+ default:
918
+ return [50, 50];
919
+ }
920
+ })();
921
+ return `${positionAxis(inlineBase, decoration.inlineOffsetPercent)} ${
922
+ positionAxis(blockBase, decoration.blockOffsetPercent)
923
+ }`;
924
+ }
925
+
926
+ function positionAxis(base: number, offset: number): string {
927
+ if (offset === 0) return `${base}%`;
928
+ return `calc(${base}% ${offset < 0 ? "-" : "+"} ${Math.abs(offset)}%)`;
929
+ }
930
+
931
+ function escapeCssString(value: string): string {
932
+ return value.replace(/(["\\])/gu, "\\$1").replace(
933
+ /[\u0000-\u001f\u007f]/gu,
934
+ (character) => `\\${character.codePointAt(0)?.toString(16)} `,
935
+ );
936
+ }
937
+
938
+ function hasOnlyKeys(value: Record<string, unknown>, keys: readonly string[]): boolean {
939
+ return Object.keys(value).every((key) => keys.includes(key));
940
+ }
941
+
942
+ function hasOwn(value: object, key: PropertyKey): boolean {
943
+ return Object.prototype.hasOwnProperty.call(value, key);
944
+ }
945
+
946
+ function isCanonicalUuid(value: unknown): boolean {
947
+ return typeof value === "string"
948
+ && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u.test(value);
949
+ }
950
+
951
+ function isPositiveInteger(value: unknown): value is number {
952
+ return typeof value === "number" && Number.isInteger(value) && value > 0;
953
+ }
954
+
955
+ function isBoundedNumber(value: unknown, minimum: number, maximum: number): value is number {
956
+ return typeof value === "number" && Number.isFinite(value) && value >= minimum && value <= maximum;
957
+ }
958
+
959
+ function renderComponentBlock(node: ProseMirrorNode): string {
960
+ const componentType = stringAttr(node.attrs?.componentType) || "placeholder";
961
+ const componentData = stringAttr(node.attrs?.componentData) || "{}";
962
+ return renderElement(
963
+ "div",
964
+ {
965
+ "data-component-block": "",
966
+ "data-component-type": componentType,
967
+ "data-component-data": componentData,
968
+ class: "component-block",
969
+ },
970
+ `Component: ${escapeHtml(componentType)}`,
971
+ );
972
+ }
973
+
974
+ function renderTaprootImage(node: ProseMirrorNode, options: RenderProseMirrorOptions): string {
975
+ const attrs = node.attrs ?? {};
976
+ const resolved = resolveImagePresentation(attrs, options.imageDefaults);
977
+ const imageAttrs: Record<string, string | number | boolean | null | undefined> = {
978
+ "low-res": stringAttr(attrs.src),
979
+ "original-width": numberAttr(attrs.width),
980
+ "original-height": numberAttr(attrs.height),
981
+ "data-image-id": stringAttr(attrs.imageId),
982
+ "data-crop": jsonAttr(attrs.crop),
983
+ caption: stringAttr(attrs.alt),
984
+ ...resolved,
985
+ };
986
+ const optionsHtml = validResponsiveImageUrls(attrs.urls)
987
+ .map((url) =>
988
+ renderVoidElement("esp-image-option", {
989
+ width: url.minWidth,
990
+ url: url.url,
991
+ type: url.type,
992
+ })
993
+ )
994
+ .join("");
995
+ return renderElement("esp-image", imageAttrs, optionsHtml);
996
+ }
997
+
998
+ function resolveImagePresentation(
999
+ attrs: Record<string, JsonValue | undefined>,
1000
+ defaults: RenderProseMirrorOptions["imageDefaults"],
1001
+ ): Record<string, string | number | undefined> {
1002
+ const maxHeight = resolveMaxHeight(attrs.maxHeightVh, defaults?.maxHeightVh);
1003
+ const maxHeightOverride = normalizeCssLengthOverride(attrs.maxHeight, { allowNone: true });
1004
+ const borderWidthOverride = normalizeCssLengthOverride(attrs.borderWidth);
1005
+ const placement = resolvePlacement(attrs, defaults?.placement);
1006
+ const placementAttrs = imagePresentationAttributesForPlacement(placement);
1007
+ const styleParts: string[] = [];
1008
+ if (maxHeightOverride) styleParts.push(`--taproot-article-image-max-height: ${maxHeightOverride}`);
1009
+ if (borderWidthOverride) styleParts.push(`--esp-image-border: ${imageBorderValue(borderWidthOverride)}`);
1010
+ return {
1011
+ // An explicit CSS max height supersedes the legacy vh enum entirely.
1012
+ ...(maxHeightOverride === null && maxHeight !== DEFAULT_TIPTAP_IMAGE_MAX_HEIGHT_VH
1013
+ ? { "data-image-max-height-vh": maxHeight }
1014
+ : {}),
1015
+ ...(maxHeightOverride ? { "data-image-max-height": maxHeightOverride } : {}),
1016
+ ...(borderWidthOverride ? { "data-image-border-width": borderWidthOverride } : {}),
1017
+ ...(styleParts.length > 0 ? { style: styleParts.join("; ") } : {}),
1018
+ ...(placementAttrs.imageAlign ? { "data-image-align": placementAttrs.imageAlign } : {}),
1019
+ ...(placementAttrs.imagePlacement ? { "data-image-placement": placementAttrs.imagePlacement } : {}),
1020
+ };
1021
+ }
1022
+
1023
+ function resolveMaxHeight(value: unknown, defaultValue: unknown): TiptapImageMaxHeightVh {
1024
+ if (value === TIPTAP_SITE_DEFAULT || value === undefined || value === null) {
1025
+ return normalizeImageMaxHeight(defaultValue);
1026
+ }
1027
+ return normalizeImageMaxHeight(value);
1028
+ }
1029
+
1030
+ function resolvePlacement(
1031
+ attrs: Record<string, JsonValue | undefined>,
1032
+ defaultValue: unknown,
1033
+ ): TiptapImagePresentationPlacement {
1034
+ const explicitPresentation = attrs.presentationPlacement;
1035
+ if (typeof explicitPresentation === "string" && explicitPresentation !== TIPTAP_SITE_DEFAULT) {
1036
+ return normalizeImagePresentationPlacement(explicitPresentation);
1037
+ }
1038
+ // "site-default" (or absent) means the presentation dialog made no explicit
1039
+ // choice. The node's own alignment attributes still win — mirroring the
1040
+ // editor's node view — and the site default applies only when those are
1041
+ // absent too.
1042
+ if (
1043
+ isAbsentPresentationValue(attrs.imageAlign)
1044
+ && isAbsentPresentationValue(attrs.imagePlacement)
1045
+ && isAbsentPresentationValue(attrs.textAlign)
1046
+ ) {
1047
+ return normalizeImagePresentationPlacement(defaultValue);
1048
+ }
1049
+ return imagePresentationPlacementForAttrs(attrs);
1050
+ }
1051
+
1052
+ function isAbsentPresentationValue(value: unknown): boolean {
1053
+ return value === undefined || value === null;
1054
+ }
1055
+
1056
+ export function normalizeImageMaxHeight(value: unknown): TiptapImageMaxHeightVh {
1057
+ const parsed = typeof value === "number" ? value : Number(value);
1058
+ return TIPTAP_IMAGE_MAX_HEIGHT_OPTIONS.includes(parsed as TiptapImageMaxHeightVh)
1059
+ ? parsed as TiptapImageMaxHeightVh
1060
+ : DEFAULT_TIPTAP_IMAGE_MAX_HEIGHT_VH;
1061
+ }
1062
+
1063
+ const CSS_LENGTH_UNITS = "px|em|rem|ch|ex|vw|vh|svh|lvh|dvh|svw|lvw|dvw|vmin|vmax|%";
1064
+ const CSS_LENGTH_RE = new RegExp(`^\\d+(?:\\.\\d+)?(?:${CSS_LENGTH_UNITS})$`, "i");
1065
+ const CSS_VAR_RE = /^var\(--[a-z0-9-]+\)$/i;
1066
+ const CSS_CALC_TERM = `(?:\\d+(?:\\.\\d+)?(?:${CSS_LENGTH_UNITS})|\\d+(?:\\.\\d+)?|var\\(--[a-z0-9-]+\\))`;
1067
+ const CSS_CALC_RE = new RegExp(
1068
+ `^calc\\(\\s*${CSS_CALC_TERM}(?:\\s*[+\\-*/]\\s*${CSS_CALC_TERM})*\\s*\\)$`,
1069
+ "i",
1070
+ );
1071
+
1072
+ /**
1073
+ * Validate a user-supplied CSS length for per-image presentation overrides
1074
+ * (max height, border width). These values are emitted into inline styles on
1075
+ * published pages, so anything outside this strict grammar is rejected:
1076
+ * unitless `0`, a non-negative length, `var(--token)`, `calc(...)` of those
1077
+ * terms, and — when `allowNone` is set — the `none` keyword (max-height).
1078
+ * Returns the trimmed value, or null when invalid or set to the site default.
1079
+ */
1080
+ export function normalizeCssLengthOverride(
1081
+ value: unknown,
1082
+ options?: { allowNone?: boolean },
1083
+ ): string | null {
1084
+ if (typeof value !== "string") return null;
1085
+ const trimmed = value.trim();
1086
+ if (!trimmed || trimmed === TIPTAP_SITE_DEFAULT) return null;
1087
+ if (trimmed === "0") return trimmed;
1088
+ if (options?.allowNone && trimmed.toLowerCase() === "none") return "none";
1089
+ if (CSS_LENGTH_RE.test(trimmed)) return trimmed;
1090
+ if (CSS_VAR_RE.test(trimmed)) return trimmed;
1091
+ if (CSS_CALC_RE.test(trimmed)) return trimmed;
1092
+ return null;
1093
+ }
1094
+
1095
+ /** CSS value for the esp-image border hook given a width; zero collapses to none. */
1096
+ export function imageBorderValue(width: string): string {
1097
+ return /^0(?:\.0+)?(?:[a-z%]+)?$/i.test(width) ? "none" : `${width} solid var(--esp-color-border)`;
1098
+ }
1099
+
1100
+ export function normalizeImagePresentationPlacement(value: unknown): TiptapImagePresentationPlacement {
1101
+ switch (value) {
1102
+ case "left":
1103
+ case "LEFT":
1104
+ return "left";
1105
+ case "left-text-right":
1106
+ case "left-wrap":
1107
+ case "LEFT_TEXT_RIGHT":
1108
+ return "left-text-right";
1109
+ case "center":
1110
+ case "CENTER":
1111
+ return "center";
1112
+ case "right":
1113
+ case "RIGHT":
1114
+ return "right";
1115
+ case "right-text-left":
1116
+ case "right-wrap":
1117
+ case "RIGHT_TEXT_LEFT":
1118
+ return "right-text-left";
1119
+ default:
1120
+ return "left";
1121
+ }
1122
+ }
1123
+
1124
+ export function imagePresentationPlacementForAttrs(attrs: {
1125
+ imageAlign?: unknown;
1126
+ imagePlacement?: unknown;
1127
+ textAlign?: unknown;
1128
+ }): TiptapImagePresentationPlacement {
1129
+ if (attrs.imagePlacement === "float-start") return "left-text-right";
1130
+ if (attrs.imagePlacement === "float-end") return "right-text-left";
1131
+ if (attrs.imageAlign === "center" || attrs.textAlign === "center") return "center";
1132
+ if (attrs.imageAlign === "end" || attrs.imageAlign === "right" || attrs.textAlign === "right") return "right";
1133
+ return "left";
1134
+ }
1135
+
1136
+ export function imagePresentationAttributesForPlacement(value: unknown): {
1137
+ textAlign: string | null;
1138
+ imageAlign: string | null;
1139
+ imagePlacement: string | null;
1140
+ } {
1141
+ switch (normalizeImagePresentationPlacement(value)) {
1142
+ case "left-text-right":
1143
+ return { textAlign: null, imageAlign: null, imagePlacement: "float-start" };
1144
+ case "center":
1145
+ return { textAlign: "center", imageAlign: "center", imagePlacement: null };
1146
+ case "right":
1147
+ return { textAlign: "right", imageAlign: "end", imagePlacement: null };
1148
+ case "right-text-left":
1149
+ return { textAlign: "right", imageAlign: "end", imagePlacement: "float-end" };
1150
+ case "left":
1151
+ default:
1152
+ return { textAlign: null, imageAlign: null, imagePlacement: null };
1153
+ }
1154
+ }
1155
+
1156
+ function validResponsiveImageUrls(value: unknown): Array<{ minWidth: number; url: string; type?: string }> {
1157
+ if (!Array.isArray(value)) return [];
1158
+ const urls: Array<{ minWidth: number; url: string; type?: string }> = [];
1159
+ for (const option of value) {
1160
+ if (!isRecord(option)) continue;
1161
+ const minWidth = numberAttr(option.minWidth);
1162
+ const url = stringAttr(option.url);
1163
+ if (!minWidth || !url) continue;
1164
+ const type = stringAttr(option.type);
1165
+ urls.push({ minWidth, url, ...(type ? { type } : {}) });
1166
+ }
1167
+ return urls;
1168
+ }
1169
+
1170
+ function textBlockAttrs(attrs: Record<string, JsonValue | undefined> | undefined): Record<string, string> {
1171
+ const styleParts: string[] = [];
1172
+ const textAlign = stringAttr(attrs?.textAlign);
1173
+ if (["left", "center", "right", "justify"].includes(textAlign ?? "")) {
1174
+ styleParts.push(`text-align: ${textAlign}`);
1175
+ }
1176
+ // Advanced text setting: per-block font-size override. Validated because it
1177
+ // is emitted into published inline styles.
1178
+ const fontSize = normalizeCssLengthOverride(attrs?.fontSize);
1179
+ if (fontSize) styleParts.push(`font-size: ${fontSize}`);
1180
+ return styleParts.length > 0 ? { style: styleParts.join("; ") } : {};
1181
+ }
1182
+
1183
+ function withProseMeasure(
1184
+ attrs: Record<string, string>,
1185
+ nodeType: string,
1186
+ options: RenderProseMirrorOptions,
1187
+ ): Record<string, string> {
1188
+ if (
1189
+ !options.freeFormSections
1190
+ || !Object.prototype.hasOwnProperty.call(freeFormSectionRegistry.rootNodes, nodeType)
1191
+ ) return attrs;
1192
+ const presentation = freeFormSectionRegistry.rootNodes[nodeType as keyof typeof freeFormSectionRegistry.rootNodes];
1193
+ if (presentation.measure !== "prose") return attrs;
1194
+ return {
1195
+ ...attrs,
1196
+ "data-content-measure": "prose",
1197
+ style: attrs.style ? `${attrs.style}; max-width: var(--esp-measure)` : "max-width: var(--esp-measure)",
1198
+ };
1199
+ }
1200
+
1201
+ function renderElement(
1202
+ tag: string,
1203
+ attrs: Record<string, string | number | boolean | null | undefined>,
1204
+ content: string,
1205
+ ): string {
1206
+ return `<${tag}${serializeAttributes(attrs)}>${content}</${tag}>`;
1207
+ }
1208
+
1209
+ function renderVoidElement(
1210
+ tag: string,
1211
+ attrs: Record<string, string | number | boolean | null | undefined>,
1212
+ ): string {
1213
+ return `<${tag}${serializeAttributes(attrs)}>`;
1214
+ }
1215
+
1216
+ function serializeAttributes(attrs: Record<string, string | number | boolean | null | undefined>): string {
1217
+ const serialized = Object.entries(attrs)
1218
+ .filter(([, value]) => value !== undefined && value !== null && value !== false)
1219
+ .map(([key, value]) => value === true ? key : `${key}="${escapeAttribute(String(value))}"`);
1220
+ return serialized.length ? ` ${serialized.join(" ")}` : "";
1221
+ }
1222
+
1223
+ function isSafeUrl(url: string): boolean {
1224
+ if (/[\x00-\x1f\x7f\\]/u.test(url)) return false;
1225
+ const cleaned = url.trim();
1226
+ if (!cleaned) return false;
1227
+ if (cleaned.startsWith("//")) return false;
1228
+ if (cleaned.startsWith("/") || cleaned.startsWith("./")) return true;
1229
+ if (cleaned.startsWith("#") || cleaned.startsWith("?")) return true;
1230
+
1231
+ try {
1232
+ const parsed = new URL(cleaned);
1233
+ return ["http:", "https:", "mailto:", "tel:"].includes(parsed.protocol);
1234
+ } catch {
1235
+ return true;
1236
+ }
1237
+ }
1238
+
1239
+ function jsonAttr(value: unknown): string | null {
1240
+ if (value === undefined || value === null) return null;
1241
+ if (typeof value === "string") return value;
1242
+ return JSON.stringify(value);
1243
+ }
1244
+
1245
+ function stringAttr(value: unknown): string | undefined {
1246
+ return typeof value === "string" ? value : undefined;
1247
+ }
1248
+
1249
+ function numberAttr(value: unknown): number | undefined {
1250
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
1251
+ }
1252
+
1253
+ function escapeHtml(value: string): string {
1254
+ return value
1255
+ .replace(/&/g, "&amp;")
1256
+ .replace(/</g, "&lt;")
1257
+ .replace(/>/g, "&gt;");
1258
+ }
1259
+
1260
+ function escapeAttribute(value: string): string {
1261
+ return escapeHtml(value).replace(/"/g, "&quot;");
1262
+ }
1263
+
1264
+ function isRecord(value: unknown): value is Record<string, JsonValue | undefined> {
1265
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1266
+ }