@bettercms-ai/richtext 0.12.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -1
- package/src/accept.ts +63 -10
- package/src/embed.ts +242 -0
- package/src/features.ts +6 -23
- package/src/from-lexical.ts +2 -1
- package/src/gallery.ts +147 -0
- package/src/html.ts +138 -9
- package/src/index.ts +4 -0
- package/src/ingest.ts +249 -3
- package/src/plain.ts +6 -0
- package/src/read.ts +26 -0
- package/src/schema.ts +38 -1
- package/src/toc.ts +129 -0
- package/src/types.ts +85 -2
- package/src/write-refusals.ts +106 -0
package/src/html.ts
CHANGED
|
@@ -16,6 +16,9 @@ import { safeHref } from "./href";
|
|
|
16
16
|
import { isStyledTag, styledAttrEntries } from "./styled";
|
|
17
17
|
import { richTextImageLayout } from "./image-layout";
|
|
18
18
|
import { keyTakeawaysView } from "./key-takeaways";
|
|
19
|
+
import { GALLERY_STYLES, galleryView } from "./gallery";
|
|
20
|
+
import { outlineOf, tocTree, tocView, type OutlineItem } from "./toc";
|
|
21
|
+
import { isEmbedAspect, resolveEmbed, type EmbedCard, type EmbedRender } from "./embed";
|
|
19
22
|
|
|
20
23
|
/**
|
|
21
24
|
* Portable Text → HTML.
|
|
@@ -420,12 +423,97 @@ function keyTakeawaysHtml(value: unknown, classes: HtmlClasses): string {
|
|
|
420
423
|
return `<aside data-rt="takeaways">${title ? `<p data-title>${esc(title)}</p>` : ""}<ul${cls(classes, "ul")}>${items}</ul></aside>`;
|
|
421
424
|
}
|
|
422
425
|
|
|
426
|
+
/**
|
|
427
|
+
* RT4: a gallery, V2's `<figure data-rt="gallery" data-layout><ul><li><figure><img><figcaption>`.
|
|
428
|
+
* The layout needs no script: a grid, or a carousel that is a scroll-snap row (swipe, scroll, or
|
|
429
|
+
* the keyboard once the row has focus). The styles are inline because a hosted page has no
|
|
430
|
+
* stylesheet for them; the ingest reads only the markup back. `src` comes last on the `<img>`, as
|
|
431
|
+
* React writes a lazy image's, so both renderers give the same bytes (R6).
|
|
432
|
+
*/
|
|
433
|
+
function galleryHtml(value: unknown): string {
|
|
434
|
+
const { layout, images } = galleryView(value);
|
|
435
|
+
const s = GALLERY_STYLES[layout];
|
|
436
|
+
const items = images
|
|
437
|
+
.map((x) => `<li style="${s.li}"><figure style="${s.figure}"><img alt="${esc(x.alt)}" style="${s.img}" loading="lazy" src="${esc(x.src)}">${
|
|
438
|
+
x.caption ? `<figcaption>${esc(x.caption)}</figcaption>` : ""
|
|
439
|
+
}</figure></li>`)
|
|
440
|
+
.join("");
|
|
441
|
+
const focus = layout === "carousel" ? ' tabindex="0"' : "";
|
|
442
|
+
return `<figure data-rt="gallery" data-layout="${layout}"><ul style="${s.ul}"${focus}>${items}</ul></figure>`;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* RT5: the outline of the value being rendered. Set by `portableTextToHtml` for a whole-value
|
|
447
|
+
* render and restored after it (a render is synchronous, and a table cell renders inside another
|
|
448
|
+
* one); an inline render (a cell, a heading field) keeps the value's outline it sits in.
|
|
449
|
+
*/
|
|
450
|
+
let outline: { items: OutlineItem[]; nodes: WeakMap<object, string> } = { items: [], nodes: new WeakMap() };
|
|
451
|
+
|
|
452
|
+
/** An h2 or h3 with its outline id, so a table of contents (or any link) can point at it. */
|
|
453
|
+
const headingWithId = (tag: "h2" | "h3", classes: HtmlClasses) =>
|
|
454
|
+
({ value, children }: { value: object; children?: string }) => {
|
|
455
|
+
const id = outline.nodes.get(value);
|
|
456
|
+
return `<${tag}${id ? ` id="${esc(id)}"` : ""}${cls(classes, tag)}>${children ?? ""}</${tag}>`;
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* RT5: a table of contents, V2's `<nav data-rt="toc" data-depth aria-label><p data-title>…</p>
|
|
461
|
+
* <ol><li><a href="#id">…</a><ol>…</ol></li></ol></nav>`, listing the outline of the value it is in.
|
|
462
|
+
*/
|
|
463
|
+
function tocHtml(value: unknown): string {
|
|
464
|
+
const { title, label, depth } = tocView(value);
|
|
465
|
+
const link = (o: OutlineItem) => `<a href="#${esc(o.id)}">${esc(o.text)}</a>`;
|
|
466
|
+
const items = tocTree(outline.items, depth)
|
|
467
|
+
.map((e) => `<li>${link(e)}${e.children.length ? `<ol>${e.children.map((c) => `<li>${link(c)}</li>`).join("")}</ol>` : ""}</li>`)
|
|
468
|
+
.join("");
|
|
469
|
+
return `<nav data-rt="toc" data-depth="${depth}" aria-label="${esc(label)}">${title ? `<p data-title>${esc(title)}</p>` : ""}<ol>${items}</ol></nav>`;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* An X post's card, from the oEmbed data the server fetched (plain strings, escaped here; never
|
|
474
|
+
* X's markup). Always the blockquote X's widget recognises: it reads as a quote without
|
|
475
|
+
* JavaScript, and on a site that opted in, the page's one widgets.js (next `BcmsEmbedScripts`)
|
|
476
|
+
* upgrades it in place. No script here: the html is a function of the value and its cards only,
|
|
477
|
+
* so turning the opt-in on or off re-bakes no html cache.
|
|
478
|
+
*/
|
|
479
|
+
function xCardHtml(url: string, card: EmbedCard): string {
|
|
480
|
+
return `<blockquote class="twitter-tweet bcms-embed-card" cite="${esc(url)}"><p>${esc(card.text)}</p>— ${esc(card.authorName)} <a href="${esc(url)}">${esc(card.date || "View on X")}</a></blockquote>`;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* RT3: an embed, V2's `<figure data-rt="embed" data-url data-provider data-aspect>` around what
|
|
485
|
+
* its url resolves to (`resolveEmbed`): the provider's frame, an audio player, an X card, or a
|
|
486
|
+
* link card. `aria-label` carries the stored title, present even when empty, so the ingest reads
|
|
487
|
+
* back exactly what was stored (V2's own markup has the title in the link instead).
|
|
488
|
+
*/
|
|
489
|
+
function embedFigureHtml(value: unknown, embeds: EmbedRender): string {
|
|
490
|
+
const v = (value ?? {}) as { url?: unknown; title?: unknown; aspect?: unknown };
|
|
491
|
+
const url = typeof v.url === "string" ? v.url.trim() : "";
|
|
492
|
+
const aspect = isEmbedAspect(v.aspect) ? v.aspect : "16:9";
|
|
493
|
+
const title = typeof v.title === "string" ? v.title.trim() : "";
|
|
494
|
+
const r = resolveEmbed(url, aspect);
|
|
495
|
+
const label = title || r.label;
|
|
496
|
+
let body: string;
|
|
497
|
+
if (r.kind === "frame") body = `<iframe src="${esc(r.src)}" title="${esc(label)}" width="${r.width}" height="${r.height}"${IFRAME_ATTRS}></iframe>`;
|
|
498
|
+
else if (r.kind === "audio") body = `<audio controls preload="none" src="${esc(r.src)}" title="${esc(label)}"></audio>`;
|
|
499
|
+
// Cards are keyed by the canonical url (`r.url`), so a post pasted two ways finds its card.
|
|
500
|
+
else if (r.kind === "card" && embeds.cards?.[r.url]) body = xCardHtml(r.url, embeds.cards[r.url]);
|
|
501
|
+
// A link card: no rule frames this url, or it is an X post with no card data for this render.
|
|
502
|
+
else body = r.url ? `<a href="${esc(r.url)}">${esc(label)}</a>` : "";
|
|
503
|
+
return `<figure data-rt="embed" data-url="${esc(url)}" data-provider="${r.provider}" data-aspect="${aspect}" aria-label="${esc(title)}">${body}</figure>`;
|
|
504
|
+
}
|
|
505
|
+
|
|
423
506
|
export interface RenderOptions {
|
|
424
507
|
/**
|
|
425
508
|
* Render a single value with no wrapping `<p>`, for a field whose consumer supplies its own
|
|
426
509
|
* heading or button. Mirrors `allowMultipleParagraphs: false` and the old `toInlineHtml`.
|
|
427
510
|
*/
|
|
428
511
|
inline?: boolean;
|
|
512
|
+
/**
|
|
513
|
+
* RT3: what an embed needs that the value does not hold: the X cards the server fetched for this
|
|
514
|
+
* render (by url), and the site's opt-in to X's script. Absent: every X post is a link card.
|
|
515
|
+
*/
|
|
516
|
+
embeds?: EmbedRender;
|
|
429
517
|
/**
|
|
430
518
|
* The site's own class per tag, for a value derived FROM that site.
|
|
431
519
|
*
|
|
@@ -480,6 +568,17 @@ const bindingAttrs = (base: string, key: unknown): string =>
|
|
|
480
568
|
? ` data-bcms-field="${esc(base)}#${esc(key)}" data-bcms-kind="richtext"`
|
|
481
569
|
: "";
|
|
482
570
|
|
|
571
|
+
/**
|
|
572
|
+
* A checklist item, as V2 stores it (`<li data-checked="true|false">`), with a disabled checkbox in
|
|
573
|
+
* front of the words so a page without the V2 stylesheet still shows the box and its state, and a
|
|
574
|
+
* screen reader announces it. The ingest reads `data-checked` back and skips the box.
|
|
575
|
+
*/
|
|
576
|
+
const checkItemHtml = (classes: HtmlClasses, value: { checked?: unknown }, children: string | undefined, address: string): string => {
|
|
577
|
+
const checked = value.checked === true;
|
|
578
|
+
// `disabled` before `type`: the order React writes an input's attributes in, so the two renderers match (R6).
|
|
579
|
+
return `<li${cls(classes, "li")}${address} data-checked="${checked}"><input disabled type="checkbox"${checked ? " checked" : ""}>${children ?? ""}</li>`;
|
|
580
|
+
};
|
|
581
|
+
|
|
483
582
|
/**
|
|
484
583
|
* `keys` is the set of `_key`s the value actually carries: to-html invents one (`li-0-1`) for an
|
|
485
584
|
* item that has none, and an invented key is an address that resolves to nothing.
|
|
@@ -488,14 +587,26 @@ const buildComponents = (
|
|
|
488
587
|
classes: HtmlClasses = {},
|
|
489
588
|
binding?: { base: string; keys: ReadonlySet<unknown> },
|
|
490
589
|
renderBlock?: RenderOptions["renderBlock"],
|
|
590
|
+
embeds: EmbedRender = {},
|
|
491
591
|
): Partial<PortableTextHtmlComponents> => ({
|
|
492
|
-
block: tagOverrides(classes, STYLE_TAGS),
|
|
493
|
-
list:
|
|
592
|
+
block: { ...tagOverrides(classes, STYLE_TAGS), h2: headingWithId("h2", classes), h3: headingWithId("h3", classes) },
|
|
593
|
+
list: {
|
|
594
|
+
...tagOverrides(classes, { bullet: "ul", number: "ol" }),
|
|
595
|
+
// A checklist is V2's `<ul data-task="true">`, dressed or not: it is not a bullet list.
|
|
596
|
+
check: ({ children }: { children?: string }) => `<ul data-task="true"${cls(classes, "ul")}>${children ?? ""}</ul>`,
|
|
597
|
+
},
|
|
494
598
|
// With a base, every item carries its own address ON the `<li>` — see `bindingBase`.
|
|
495
599
|
listItem: binding
|
|
496
|
-
? ({ value, children }: { value: { _key?: unknown }; children?: string }) =>
|
|
497
|
-
|
|
498
|
-
|
|
600
|
+
? ({ value, children }: { value: { _key?: unknown; listItem?: unknown; checked?: unknown }; children?: string }) => {
|
|
601
|
+
const address = binding.keys.has(value._key) ? bindingAttrs(binding.base, value._key) : "";
|
|
602
|
+
return value.listItem === "check"
|
|
603
|
+
? checkItemHtml(classes, value, children, address)
|
|
604
|
+
: `<li${cls(classes, "li")}${address}>${children ?? ""}</li>`;
|
|
605
|
+
}
|
|
606
|
+
: {
|
|
607
|
+
...tagOverrides(classes, { bullet: "li", number: "li" }),
|
|
608
|
+
check: ({ value, children }) => checkItemHtml(classes, value as { checked?: unknown }, children, ""),
|
|
609
|
+
},
|
|
499
610
|
types: {
|
|
500
611
|
[PT_TYPES.divider]: () => "<hr>",
|
|
501
612
|
[PT_TYPES.table]: ({ value }) => tableHtml(value as PortableTextTable),
|
|
@@ -510,6 +621,9 @@ const buildComponents = (
|
|
|
510
621
|
[PT_TYPES.file]: ({ value }) => fileHtml(value, classes),
|
|
511
622
|
[PT_TYPES.pullQuote]: ({ value }) => pullQuoteHtml(value, classes),
|
|
512
623
|
[PT_TYPES.keyTakeaways]: ({ value }) => keyTakeawaysHtml(value, classes),
|
|
624
|
+
[PT_TYPES.embed]: ({ value }) => embedFigureHtml(value, embeds),
|
|
625
|
+
[PT_TYPES.gallery]: ({ value }) => galleryHtml(value),
|
|
626
|
+
[PT_TYPES.tableOfContents]: ({ value }) => tocHtml(value),
|
|
513
627
|
/**
|
|
514
628
|
* A generic embedded block. A block whose payload this serialiser can READ renders as
|
|
515
629
|
* content; one carrying a reference it would have to resolve against a project renders as a
|
|
@@ -557,9 +671,11 @@ const buildComponents = (
|
|
|
557
671
|
* renders the LABEL rather than deleting it — a blocked link must not lose the words.
|
|
558
672
|
*/
|
|
559
673
|
[PT_TYPES.link]: ({ value, children }) => {
|
|
560
|
-
const v = (value ?? {}) as { href?: unknown; target?: unknown; rel?: unknown; className?: unknown };
|
|
674
|
+
const v = (value ?? {}) as { href?: unknown; target?: unknown; rel?: unknown; className?: unknown; button?: unknown };
|
|
561
675
|
const href = typeof v.href === "string" ? safeHref(v.href) : null;
|
|
562
676
|
if (!href) return children;
|
|
677
|
+
// RT2: a button is a link the author marked as one; V2's `data-button` is the site's style hook.
|
|
678
|
+
const button = v.button === true ? ' data-button="true"' : "";
|
|
563
679
|
// `rel` is author data too (the importer keeps `prev`/`next` on pagination links), so only
|
|
564
680
|
// plain link-type tokens pass; a new tab always adds noopener + noreferrer.
|
|
565
681
|
const rel = new Set(
|
|
@@ -573,7 +689,7 @@ const buildComponents = (
|
|
|
573
689
|
const relAttr = rel.size ? ` rel="${esc([...rel].join(" "))}"` : "";
|
|
574
690
|
// The link's own class (kept on ingest) wins over the caller's default: it IS the site.
|
|
575
691
|
const own = typeof v.className === "string" && v.className ? ` class="${esc(v.className)}"` : "";
|
|
576
|
-
return `<a${own || cls(classes, "a")} href="${esc(href)}"${target}${relAttr}>${children}</a>`;
|
|
692
|
+
return `<a${own || cls(classes, "a")}${button} href="${esc(href)}"${target}${relAttr}>${children}</a>`;
|
|
577
693
|
},
|
|
578
694
|
/**
|
|
579
695
|
* The site's own inline markup, written back out as itself.
|
|
@@ -617,12 +733,25 @@ export function portableTextToHtml(
|
|
|
617
733
|
opts: RenderOptions = {},
|
|
618
734
|
): string {
|
|
619
735
|
if (!Array.isArray(value) || value.length === 0) return "";
|
|
736
|
+
if (opts.inline) return render(value, opts);
|
|
737
|
+
const outer = outline;
|
|
738
|
+
outline = outlineOf(value);
|
|
739
|
+
try {
|
|
740
|
+
return render(value, opts);
|
|
741
|
+
} finally {
|
|
742
|
+
outline = outer;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function render(value: PortableText, opts: RenderOptions): string {
|
|
620
747
|
const components =
|
|
621
|
-
opts.classes || opts.renderBlock
|
|
748
|
+
opts.classes || opts.renderBlock || opts.embeds
|
|
749
|
+
? buildComponents(opts.classes, undefined, opts.renderBlock, opts.embeds)
|
|
750
|
+
: defaultComponents;
|
|
622
751
|
|
|
623
752
|
if (opts.bindingBase) {
|
|
624
753
|
const base = opts.bindingBase;
|
|
625
|
-
const bound = buildComponents(opts.classes, { base, keys: new Set(value.map((n) => n._key)) }, opts.renderBlock);
|
|
754
|
+
const bound = buildComponents(opts.classes, { base, keys: new Set(value.map((n) => n._key)) }, opts.renderBlock, opts.embeds);
|
|
626
755
|
const out: string[] = [];
|
|
627
756
|
for (let i = 0; i < value.length; ) {
|
|
628
757
|
const node = value[i];
|
package/src/index.ts
CHANGED
|
@@ -6,10 +6,14 @@ export * from "./from-lexical";
|
|
|
6
6
|
export * from "./plain";
|
|
7
7
|
export * from "./table";
|
|
8
8
|
export * from "./key-takeaways";
|
|
9
|
+
export * from "./gallery";
|
|
10
|
+
export * from "./toc";
|
|
9
11
|
export * from "./styled";
|
|
10
12
|
export * from "./read";
|
|
11
13
|
export * from "./component-copy";
|
|
12
14
|
export * from "./accept";
|
|
13
15
|
export * from "./features";
|
|
16
|
+
export * from "./write-refusals";
|
|
14
17
|
export * from "./video";
|
|
18
|
+
export * from "./embed";
|
|
15
19
|
export * from "./image-layout";
|
package/src/ingest.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { compileSchema } from "@portabletext/schema";
|
|
|
3
3
|
import {
|
|
4
4
|
BUILTIN_EMBED_KEY,
|
|
5
5
|
BUILTIN_IMAGE_KEY,
|
|
6
|
+
BUILTIN_SITE_KEY,
|
|
6
7
|
PT_TYPES,
|
|
7
8
|
type PortableText,
|
|
8
9
|
type PortableTextBlock,
|
|
@@ -15,8 +16,11 @@ import {
|
|
|
15
16
|
import { randomKey, isValidKey, type KeyGenerator } from "./key";
|
|
16
17
|
import { safeHref } from "./href";
|
|
17
18
|
import { BARE_IS_DESIGN, isStyledTag, styledAttrsOf, type StyledMarkDef } from "./styled";
|
|
19
|
+
import { EMBED_TITLE_MAX, isEmbedAspect, nearestEmbedAspect, resolveEmbed } from "./embed";
|
|
18
20
|
import { cellTypeOf, columnOf, designOf, isColumnType, isValueType, jsonLdOf, PT_TABLE_LIMITS, typedCellText } from "./table";
|
|
19
21
|
import { PT_KEY_TAKEAWAYS_LIMITS } from "./key-takeaways";
|
|
22
|
+
import { PT_GALLERY_LIMITS } from "./gallery";
|
|
23
|
+
import { PT_TOC_LIMITS } from "./toc";
|
|
20
24
|
import type { PtSchema } from "./schema";
|
|
21
25
|
|
|
22
26
|
/**
|
|
@@ -48,6 +52,17 @@ export interface IngestOptions {
|
|
|
48
52
|
/** What the field accepts. Anything outside it produces no node. */
|
|
49
53
|
schema?: PtSchema;
|
|
50
54
|
keyGenerator?: KeyGenerator;
|
|
55
|
+
/**
|
|
56
|
+
* Keep parts the site renders (`builtin:site` markers) where the schema has no generic block.
|
|
57
|
+
* Only the marker is admitted: the schema still decides every other node.
|
|
58
|
+
*/
|
|
59
|
+
siteParts?: boolean;
|
|
60
|
+
/**
|
|
61
|
+
* Called once per fact an import receipt records, today `embed.legacy_iframe:<host>`: an
|
|
62
|
+
* `<iframe>` the embed allowlist does not frame, kept on the legacy read-only path. The evidence
|
|
63
|
+
* the allowlist grows from.
|
|
64
|
+
*/
|
|
65
|
+
onReceipt?: (line: string) => void;
|
|
51
66
|
}
|
|
52
67
|
|
|
53
68
|
/**
|
|
@@ -460,6 +475,29 @@ const codeLanguage = (pre: Element): string | undefined =>
|
|
|
460
475
|
* deserialiser's default flattens a code block to a paragraph with a `code` mark and its
|
|
461
476
|
* newlines collapsed: every imported code fence became one line of inline code.
|
|
462
477
|
*/
|
|
478
|
+
/**
|
|
479
|
+
* The marker a part the SITE renders is written as (`<div class="bcms-block" data-bcms-schema-key=
|
|
480
|
+
* "builtin:site">`, @see BUILTIN_SITE_KEY) → that locked block, never its contents as paragraphs.
|
|
481
|
+
* The repo source and the card's label ride on `data-bcms-site-source` / `data-bcms-site-label`,
|
|
482
|
+
* which only the source lane writes; a rendered marker carries neither and still comes back locked.
|
|
483
|
+
*/
|
|
484
|
+
function siteRule(key: KeyGenerator): DeserializerRule {
|
|
485
|
+
return {
|
|
486
|
+
deserialize(el: Node, _next, createBlock) {
|
|
487
|
+
if (!isElement(el) || el.nodeName.toLowerCase() !== "div" || el.getAttribute("data-bcms-schema-key") !== BUILTIN_SITE_KEY) return undefined;
|
|
488
|
+
const source = el.getAttribute("data-bcms-site-source") ?? "";
|
|
489
|
+
const label = el.getAttribute("data-bcms-site-label") ?? "";
|
|
490
|
+
return createBlock({
|
|
491
|
+
_type: PT_TYPES.block,
|
|
492
|
+
_key: key(),
|
|
493
|
+
schemaKey: BUILTIN_SITE_KEY,
|
|
494
|
+
schemaLabel: label || "Site component",
|
|
495
|
+
fields: source ? { source } : {},
|
|
496
|
+
});
|
|
497
|
+
},
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
|
|
463
501
|
function codeRule(key: KeyGenerator): DeserializerRule {
|
|
464
502
|
return {
|
|
465
503
|
deserialize(el: Node, _next, createBlock) {
|
|
@@ -513,6 +551,156 @@ function styledRule(key: KeyGenerator): DeserializerRule {
|
|
|
513
551
|
};
|
|
514
552
|
}
|
|
515
553
|
|
|
554
|
+
/**
|
|
555
|
+
* The two decorators the renderer writes in markup the standard rules do not read back (R1):
|
|
556
|
+
* - `<mark>`: the deserialiser names it `mark`, the schema calls it `highlight`, so it was
|
|
557
|
+
* dropped on every round trip;
|
|
558
|
+
* - `<span style="text-decoration:underline">`: what `portableTextToHtml` emits for `underline`,
|
|
559
|
+
* which the styled rule then recorded as the site's own design instead of the decorator.
|
|
560
|
+
* Bare only: a classed `<mark>` or a span with anything else on it is still the styled rule's.
|
|
561
|
+
* Gated on the schema, because a custom rule's output is never filtered by it.
|
|
562
|
+
*/
|
|
563
|
+
function ownDecoratorRule(schema: PtSchema | undefined): DeserializerRule {
|
|
564
|
+
const allows = (name: string) => !schema || schema.decorators.some((d) => d.name === name);
|
|
565
|
+
return {
|
|
566
|
+
deserialize(el: Node, next) {
|
|
567
|
+
if (!isElement(el)) return undefined;
|
|
568
|
+
const tag = el.nodeName.toLowerCase();
|
|
569
|
+
const attrs = el.getAttributeNames();
|
|
570
|
+
if (tag === "mark" && attrs.length === 0 && allows("highlight")) {
|
|
571
|
+
return { _type: "__decorator", name: "highlight", children: next(el.childNodes) } as never;
|
|
572
|
+
}
|
|
573
|
+
const style = el.getAttribute("style")?.replace(/\s+/g, "").replace(/;$/, "").toLowerCase();
|
|
574
|
+
if (tag === "span" && attrs.length === 1 && style === "text-decoration:underline" && allows("underline")) {
|
|
575
|
+
return { _type: "__decorator", name: "underline", children: next(el.childNodes) } as never;
|
|
576
|
+
}
|
|
577
|
+
return undefined;
|
|
578
|
+
},
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* A V2 checklist item, `<ul data-task="true"><li data-checked="true|false">`, read as
|
|
584
|
+
* `listItem: "check"` + `checked`; a nested list's depth is added when it is flattened. The box in front of the words (the
|
|
585
|
+
* renderer's disabled checkbox) is skipped: the state is `data-checked`, never the input. Gated on
|
|
586
|
+
* the schema, because a custom rule's output is never filtered by it: a field without the
|
|
587
|
+
* checklist falls through to the standard rule and reads the items as bullets.
|
|
588
|
+
*/
|
|
589
|
+
function checklistItemRule(schema: PtSchema | undefined): DeserializerRule {
|
|
590
|
+
const allowed = !schema || schema.lists.some((l) => l.name === "check");
|
|
591
|
+
return {
|
|
592
|
+
deserialize(el: Node, next) {
|
|
593
|
+
if (!allowed || !isElement(el) || el.nodeName.toLowerCase() !== "li") return undefined;
|
|
594
|
+
const list = el.parentNode;
|
|
595
|
+
if (!list || !isElement(list) || list.nodeName.toLowerCase() !== "ul" || !list.hasAttribute("data-task")) return undefined;
|
|
596
|
+
const words = Array.from(el.childNodes).filter((n) => !(isElement(n) && n.nodeName.toLowerCase() === "input"));
|
|
597
|
+
return {
|
|
598
|
+
_type: "block",
|
|
599
|
+
style: "normal",
|
|
600
|
+
markDefs: [],
|
|
601
|
+
listItem: "check",
|
|
602
|
+
// 1, as the standard list rule writes it: flattening a nested list adds the depth.
|
|
603
|
+
level: 1,
|
|
604
|
+
checked: el.getAttribute("data-checked") === "true",
|
|
605
|
+
children: next(words as never),
|
|
606
|
+
} as never;
|
|
607
|
+
},
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* RT5: `<nav data-rt="toc" data-depth>` → the `tableOfContents` block, the inverse of `tocHtml`
|
|
613
|
+
* and of V2's own markup. Only what is stored is read: the title (`[data-title]`, ≤ 40, none when
|
|
614
|
+
* empty) and the depth ("2", else "3"). The list of links is the derived outline and is ignored.
|
|
615
|
+
*/
|
|
616
|
+
function tocRule(key: KeyGenerator): DeserializerRule {
|
|
617
|
+
return {
|
|
618
|
+
deserialize(el: Node, _next, createBlock) {
|
|
619
|
+
if (!isElement(el) || el.nodeName.toLowerCase() !== "nav" || el.getAttribute("data-rt") !== "toc") return undefined;
|
|
620
|
+
const title = (el.querySelector("[data-title]")?.textContent ?? "").trim().slice(0, PT_TOC_LIMITS.titleChars);
|
|
621
|
+
const depth = el.getAttribute("data-depth") === "2" ? "2" : "3";
|
|
622
|
+
return createBlock({ _type: PT_TYPES.tableOfContents, _key: key(), ...(title ? { title } : {}), depth });
|
|
623
|
+
},
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* RT4: `<figure data-rt="gallery" data-layout>` → the `gallery` block, the inverse of `galleryHtml`
|
|
629
|
+
* and of V2's own markup. One image per `<li>`: its `<img>` src and alt, and its figcaption as the
|
|
630
|
+
* caption (≤ 160). A `<li>` with no `<img>` is an image not yet chosen, as V2 reads it. The
|
|
631
|
+
* layout is a carousel only when it says so.
|
|
632
|
+
*/
|
|
633
|
+
function galleryFigureRule(key: KeyGenerator): DeserializerRule {
|
|
634
|
+
return {
|
|
635
|
+
deserialize(el: Node, _next, createBlock) {
|
|
636
|
+
if (!isElement(el) || el.nodeName.toLowerCase() !== "figure" || el.getAttribute("data-rt") !== "gallery") return undefined;
|
|
637
|
+
const images = Array.from(el.querySelectorAll("li")).map((li) => {
|
|
638
|
+
const img = li.querySelector("img");
|
|
639
|
+
const caption = (li.querySelector("figcaption")?.textContent ?? "").trim().slice(0, PT_GALLERY_LIMITS.captionChars);
|
|
640
|
+
return {
|
|
641
|
+
_key: key(),
|
|
642
|
+
image: img ? { src: (img.getAttribute("src") ?? "").trim(), alt: img.getAttribute("alt") ?? "" } : null,
|
|
643
|
+
...(caption ? { caption } : {}),
|
|
644
|
+
};
|
|
645
|
+
});
|
|
646
|
+
const layout = el.getAttribute("data-layout") === "carousel" ? "carousel" : "grid";
|
|
647
|
+
return createBlock({ _type: PT_TYPES.gallery, _key: key(), layout, images });
|
|
648
|
+
},
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* RT3: `<figure data-rt="embed">` → the `embed` block, the inverse of `embedFigureHtml` and of V2's
|
|
654
|
+
* own `<figure data-rt="embed" data-url data-aspect><a href>title</a></figure>`. Only what is
|
|
655
|
+
* stored is read: the url (`data-url`, else the link's href), the title (`aria-label` when the
|
|
656
|
+
* figure has one, which ours always does, else the link's words, as V2 reads it) and the aspect
|
|
657
|
+
* (V2's four, else 16:9). The frame, the card and `data-provider` are derived and ignored.
|
|
658
|
+
*/
|
|
659
|
+
function embedFigureRule(key: KeyGenerator, schema: PtSchema | undefined): DeserializerRule {
|
|
660
|
+
const allowed = allowsBlockObject(schema, PT_TYPES.embed);
|
|
661
|
+
return {
|
|
662
|
+
deserialize(el: Node, _next, createBlock) {
|
|
663
|
+
if (!allowed || !isElement(el) || el.nodeName.toLowerCase() !== "figure" || el.getAttribute("data-rt") !== "embed") return undefined;
|
|
664
|
+
const link = el.querySelector("a[href]");
|
|
665
|
+
const url = (el.getAttribute("data-url") ?? link?.getAttribute("href") ?? "").trim();
|
|
666
|
+
const title = (el.hasAttribute("aria-label") ? el.getAttribute("aria-label") ?? "" : (link?.textContent ?? "")).trim().slice(0, EMBED_TITLE_MAX);
|
|
667
|
+
const aspect = el.getAttribute("data-aspect");
|
|
668
|
+
return createBlock({ _type: PT_TYPES.embed, _key: key(), url, title, aspect: isEmbedAspect(aspect) ? aspect : "16:9" });
|
|
669
|
+
},
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* RT2: V2's button, `<a data-button="true" href>`, → a `link` annotation with `button: true` (and
|
|
675
|
+
* the site's class, a new tab and `rel`, as the classed link keeps them). Gated on the schema's
|
|
676
|
+
* link annotation having the `button` field: a field without the button feature reads it as the
|
|
677
|
+
* plain link it also is.
|
|
678
|
+
*/
|
|
679
|
+
function buttonLinkRule(key: KeyGenerator, schema: PtSchema | undefined): DeserializerRule {
|
|
680
|
+
const allowed =
|
|
681
|
+
!schema || schema.annotations.some((a) => a.name === PT_TYPES.link && (a.fields ?? []).some((f) => f.name === "button"));
|
|
682
|
+
return {
|
|
683
|
+
deserialize(el: Node, next) {
|
|
684
|
+
if (!allowed || !isElement(el) || el.nodeName.toLowerCase() !== "a") return undefined;
|
|
685
|
+
const flag = el.getAttribute("data-button");
|
|
686
|
+
const href = el.getAttribute("href")?.trim();
|
|
687
|
+
if (flag === null || flag === "false" || !href) return undefined;
|
|
688
|
+
const className = el.getAttribute("class")?.trim();
|
|
689
|
+
const rel = el.getAttribute("rel")?.trim();
|
|
690
|
+
const markDef = {
|
|
691
|
+
_type: PT_TYPES.link,
|
|
692
|
+
_key: key(),
|
|
693
|
+
href,
|
|
694
|
+
button: true,
|
|
695
|
+
...(className ? { className } : {}),
|
|
696
|
+
...(el.getAttribute("target") === "_blank" ? { target: "_blank" } : {}),
|
|
697
|
+
...(rel ? { rel } : {}),
|
|
698
|
+
};
|
|
699
|
+
return { _type: "__annotation", markDef, children: next(el.childNodes) } as never;
|
|
700
|
+
},
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
|
|
516
704
|
/**
|
|
517
705
|
* `<a class>` → a `link` annotation that keeps the site's class as `className`.
|
|
518
706
|
*
|
|
@@ -595,7 +783,7 @@ const PDF_HREF = /\.pdf(?:[?#]|$)/i;
|
|
|
595
783
|
* and four rules would each have to ask that question. `undefined` hands the node back to the
|
|
596
784
|
* deserialiser untouched, which is what keeps a mid-sentence PDF link a link.
|
|
597
785
|
*/
|
|
598
|
-
function mediaRule(key: KeyGenerator, schema: PtSchema | undefined): DeserializerRule {
|
|
786
|
+
function mediaRule(key: KeyGenerator, schema: PtSchema | undefined, onReceipt?: (line: string) => void): DeserializerRule {
|
|
599
787
|
const allows = (name: string): boolean =>
|
|
600
788
|
!schema || schema.blockObjects.some((o) => o.name === name);
|
|
601
789
|
return {
|
|
@@ -628,6 +816,15 @@ function mediaRule(key: KeyGenerator, schema: PtSchema | undefined): Deserialize
|
|
|
628
816
|
...(caption ? { title: caption } : {}),
|
|
629
817
|
});
|
|
630
818
|
}
|
|
819
|
+
// RT3: a frame the embed allowlist recognises is an `embed`, in a field that takes one.
|
|
820
|
+
if (subject === "iframe" && allows(PT_TYPES.embed)) {
|
|
821
|
+
const src = (el.getAttribute("src") ?? "").trim();
|
|
822
|
+
if (src && resolveEmbed(src).kind === "frame") {
|
|
823
|
+
const title = ((el.getAttribute("title") ?? "").trim() || caption).slice(0, EMBED_TITLE_MAX);
|
|
824
|
+
const aspect = nearestEmbedAspect(el.getAttribute("width"), el.getAttribute("height"));
|
|
825
|
+
return createBlock({ _type: PT_TYPES.embed, _key: key(), url: src, title, aspect });
|
|
826
|
+
}
|
|
827
|
+
}
|
|
631
828
|
// Image and embed keep the GENERIC block object under their existing `builtin:` keys —
|
|
632
829
|
// thousands of stored values already spell them that way, and nothing is gained by a
|
|
633
830
|
// migration when the renderer reads both shapes from the same fields.
|
|
@@ -636,6 +833,18 @@ function mediaRule(key: KeyGenerator, schema: PtSchema | undefined): Deserialize
|
|
|
636
833
|
if (!src) return undefined;
|
|
637
834
|
if (subject === "iframe") {
|
|
638
835
|
const title = (el.getAttribute("title") ?? "").trim() || caption;
|
|
836
|
+
// Any other frame keeps the legacy, read-only `builtin:video` path: a working Calendly-like
|
|
837
|
+
// embed must not turn into a link (R15). Recorded, so the allowlist can grow from evidence.
|
|
838
|
+
// Only a frame the allowlist does not know is evidence; a known one here just met a field without the embed.
|
|
839
|
+
if (resolveEmbed(src).kind !== "frame") {
|
|
840
|
+
let host = "invalid";
|
|
841
|
+
try {
|
|
842
|
+
host = new URL(src).hostname;
|
|
843
|
+
} catch {
|
|
844
|
+
/* an unparsable src is recorded as such */
|
|
845
|
+
}
|
|
846
|
+
onReceipt?.(`embed.legacy_iframe:${host}`);
|
|
847
|
+
}
|
|
639
848
|
return createBlock({
|
|
640
849
|
_type: PT_TYPES.block,
|
|
641
850
|
_key: key(),
|
|
@@ -795,11 +1004,39 @@ const allowsBlockObject = (schema: PtSchema | undefined, name: string): boolean
|
|
|
795
1004
|
const isElement = (n: unknown): n is Element =>
|
|
796
1005
|
!!n && typeof n === "object" && (n as Node).nodeType === 1;
|
|
797
1006
|
|
|
1007
|
+
const BLOCK_TAGS = new Set([
|
|
1008
|
+
"ADDRESS", "ARTICLE", "ASIDE", "BLOCKQUOTE", "DD", "DETAILS", "DIV", "DL", "DT", "FIGCAPTION", "FIGURE", "FOOTER",
|
|
1009
|
+
"H1", "H2", "H3", "H4", "H5", "H6", "HEADER", "HR", "LI", "MAIN", "NAV", "OL", "P", "PRE", "SECTION", "TABLE", "UL",
|
|
1010
|
+
]);
|
|
1011
|
+
|
|
1012
|
+
/**
|
|
1013
|
+
* The parser, minus the indentation between block elements. The converter skips that whitespace
|
|
1014
|
+
* at the top level but turns it into an empty block inside a wrapper, so `<div><p>a</p>\n<p>b</p></div>`
|
|
1015
|
+
* came back as three blocks. Only ASCII whitespace beside a block element goes: a space between two
|
|
1016
|
+
* inline elements is a word break, and ` ` or a `<br>` is an author's blank line.
|
|
1017
|
+
*/
|
|
1018
|
+
const withoutBlockWhitespace =
|
|
1019
|
+
(parse: (html: string) => Document) =>
|
|
1020
|
+
(html: string): Document => {
|
|
1021
|
+
const doc = parse(html);
|
|
1022
|
+
const walker = doc.createTreeWalker(doc.body ?? doc, 4 /* NodeFilter.SHOW_TEXT */);
|
|
1023
|
+
const blank: Node[] = [];
|
|
1024
|
+
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
1025
|
+
const beside = [n.previousSibling, n.nextSibling].some((s) => !!s && BLOCK_TAGS.has(s.nodeName));
|
|
1026
|
+
if (beside && /^[ \t\n\r]*$/.test(n.nodeValue ?? "") && n.parentNode?.nodeName !== "PRE") blank.push(n);
|
|
1027
|
+
}
|
|
1028
|
+
for (const n of blank) n.parentNode?.removeChild(n);
|
|
1029
|
+
return doc;
|
|
1030
|
+
};
|
|
1031
|
+
|
|
798
1032
|
export function htmlToPortableText(html: string, opts: IngestOptions = {}): PortableText {
|
|
799
1033
|
if (typeof html !== "string" || html.trim() === "") return [];
|
|
800
1034
|
const key = opts.keyGenerator ?? randomKey;
|
|
1035
|
+
const parse =
|
|
1036
|
+
opts.parseHtml ?? (typeof DOMParser !== "undefined" ? (markup: string) => new DOMParser().parseFromString(markup, "text/html") : undefined);
|
|
801
1037
|
const converted = ptHtmlToPortableText(html, {
|
|
802
|
-
|
|
1038
|
+
// The browser's own parser when none is passed (Node must pass one), so both drop the same whitespace.
|
|
1039
|
+
...(parse ? { parseHtml: withoutBlockWhitespace(parse) } : {}),
|
|
803
1040
|
// `ptSchemaFor` returns a schema DEFINITION — the same object `EditorProvider` takes, so
|
|
804
1041
|
// the editor and this filter are literally configured from one value. The deserialiser
|
|
805
1042
|
// wants the COMPILED form, so the compile happens here rather than at every call site.
|
|
@@ -817,14 +1054,23 @@ export function htmlToPortableText(html: string, opts: IngestOptions = {}): Port
|
|
|
817
1054
|
* accepts only images.
|
|
818
1055
|
*/
|
|
819
1056
|
rules: [
|
|
1057
|
+
// First: a marker's content is the site's, never this field's text.
|
|
1058
|
+
...(opts.siteParts || allowsBlockObject(opts.schema, PT_TYPES.block) ? [siteRule(key)] : []),
|
|
820
1059
|
...(allowsBlockObject(opts.schema, PT_TYPES.table)
|
|
821
1060
|
? [tableFigureRule(key, (cell) => htmlToPortableText(cell, opts)), tableRule(key, (cell) => htmlToPortableText(cell, opts))]
|
|
822
1061
|
: []),
|
|
823
1062
|
...(allowsBlockObject(opts.schema, PT_TYPES.code) ? [codeRule(key)] : []),
|
|
824
1063
|
...(allowsBlockObject(opts.schema, PT_TYPES.pullQuote) ? [pullQuoteRule(key)] : []),
|
|
825
1064
|
...(allowsBlockObject(opts.schema, PT_TYPES.keyTakeaways) ? [keyTakeawaysRule(key)] : []),
|
|
826
|
-
|
|
1065
|
+
// Before the media rule, which would take the iframe out of our own embed figure (and an image out of a gallery).
|
|
1066
|
+
...(allowsBlockObject(opts.schema, PT_TYPES.gallery) ? [galleryFigureRule(key)] : []),
|
|
1067
|
+
...(allowsBlockObject(opts.schema, PT_TYPES.tableOfContents) ? [tocRule(key)] : []),
|
|
1068
|
+
embedFigureRule(key, opts.schema),
|
|
1069
|
+
mediaRule(key, opts.schema, opts.onReceipt),
|
|
1070
|
+
checklistItemRule(opts.schema),
|
|
1071
|
+
ownDecoratorRule(opts.schema),
|
|
827
1072
|
...(allowsStyled(opts.schema) ? [styledRule(key)] : []),
|
|
1073
|
+
buttonLinkRule(key, opts.schema),
|
|
828
1074
|
...(allowsLinks(opts.schema) ? [classedLinkRule(key)] : []),
|
|
829
1075
|
],
|
|
830
1076
|
keyGenerator: key,
|
package/src/plain.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { isPortableTextBlock, isPortableTextSpan, PT_TYPES, type PortableText, type PortableTextTable } from "./types";
|
|
2
2
|
import { migrateTable, tableGroupLayout } from "./table";
|
|
3
3
|
import { keyTakeawaysView } from "./key-takeaways";
|
|
4
|
+
import { galleryView } from "./gallery";
|
|
4
5
|
import { componentCopyLines, detachedComponentCopy } from "./component-copy";
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -49,6 +50,11 @@ export function portableTextToPlainText(value: PortableText | null | undefined):
|
|
|
49
50
|
lines.push(...(title ? [title] : []), ...points);
|
|
50
51
|
continue;
|
|
51
52
|
}
|
|
53
|
+
/** A gallery's captions are words a reader sees; its images are not. */
|
|
54
|
+
if (node._type === PT_TYPES.gallery) {
|
|
55
|
+
lines.push(...galleryView(node).images.map((x) => x.caption).filter(Boolean));
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
52
58
|
const table = migrateTable(node);
|
|
53
59
|
if (table) {
|
|
54
60
|
lines.push(...tableLines(table));
|
package/src/read.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { lexicalToPortableText } from "./from-lexical";
|
|
|
3
3
|
import { migrateTable } from "./table";
|
|
4
4
|
import { detachedComponentCopy, type DetachedComponentCopy } from "./component-copy";
|
|
5
5
|
import { randomKey, type KeyGenerator } from "./key";
|
|
6
|
+
import type { EmbedCard, RichTextEmbeds } from "@bettercms-ai/types";
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* THE reader. One function, every stored shape, and it never throws.
|
|
@@ -233,6 +234,31 @@ export function readStructuredContent(props: unknown): PortableText | null {
|
|
|
233
234
|
return arr && arr.length > 0 ? (arr as PortableText) : null;
|
|
234
235
|
}
|
|
235
236
|
|
|
237
|
+
/**
|
|
238
|
+
* The embeds a delivered value carries beside its html (`RichTextEmbeds`), from the same envelope
|
|
239
|
+
* `readStructuredContent` reads; undefined when there are none. Delivered data is checked, not
|
|
240
|
+
* trusted: a card keeps only string fields.
|
|
241
|
+
*/
|
|
242
|
+
export function readStructuredEmbeds(props: unknown): RichTextEmbeds | undefined {
|
|
243
|
+
if (!isRecord(props)) return undefined;
|
|
244
|
+
const candidate = props.content ?? props.value;
|
|
245
|
+
const raw = isRecord(candidate) ? candidate.embeds : undefined;
|
|
246
|
+
if (!isRecord(raw)) return undefined;
|
|
247
|
+
const cards: Record<string, EmbedCard> = {};
|
|
248
|
+
if (isRecord(raw.cards)) {
|
|
249
|
+
for (const [url, c] of Object.entries(raw.cards)) {
|
|
250
|
+
if (!isRecord(c) || typeof c.text !== "string" || typeof c.authorName !== "string") continue;
|
|
251
|
+
cards[url] = {
|
|
252
|
+
text: c.text,
|
|
253
|
+
authorName: c.authorName,
|
|
254
|
+
...(typeof c.authorUrl === "string" ? { authorUrl: c.authorUrl } : {}),
|
|
255
|
+
...(typeof c.date === "string" ? { date: c.date } : {}),
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return { cards };
|
|
260
|
+
}
|
|
261
|
+
|
|
236
262
|
/** The `component` block an embed in rich text stands for. */
|
|
237
263
|
export interface EmbeddedComponentBlock {
|
|
238
264
|
type: "component";
|