@bettercms-ai/richtext 0.12.1 → 0.16.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 +4 -1
- package/src/accept.ts +68 -9
- package/src/columns.ts +117 -0
- package/src/embed.ts +242 -0
- package/src/features.ts +6 -23
- package/src/footnote.ts +70 -0
- package/src/gallery.ts +167 -0
- package/src/html.ts +234 -12
- package/src/image-layout.ts +54 -0
- package/src/index.ts +7 -0
- package/src/ingest.ts +406 -5
- package/src/plain.ts +16 -0
- package/src/read.ts +63 -0
- package/src/references.ts +147 -0
- package/src/schema.ts +67 -1
- package/src/toc.ts +146 -0
- package/src/types.ts +157 -2
- package/src/write-refusals.ts +114 -0
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,13 @@ 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, isHeadingAnchor, outlineOf } from "./toc";
|
|
24
|
+
import { PT_RELATED_LIMITS } from "./references";
|
|
25
|
+
import { readImageFrame } from "./image-layout";
|
|
20
26
|
import type { PtSchema } from "./schema";
|
|
21
27
|
|
|
22
28
|
/**
|
|
@@ -48,6 +54,25 @@ export interface IngestOptions {
|
|
|
48
54
|
/** What the field accepts. Anything outside it produces no node. */
|
|
49
55
|
schema?: PtSchema;
|
|
50
56
|
keyGenerator?: KeyGenerator;
|
|
57
|
+
/**
|
|
58
|
+
* Keep parts the site renders (`builtin:site` markers) where the schema has no generic block.
|
|
59
|
+
* Only the marker is admitted: the schema still decides every other node.
|
|
60
|
+
*/
|
|
61
|
+
siteParts?: boolean;
|
|
62
|
+
/**
|
|
63
|
+
* Called once per fact an import receipt records, today `embed.legacy_iframe:<host>`: an
|
|
64
|
+
* `<iframe>` the embed allowlist does not frame, kept on the legacy read-only path. The evidence
|
|
65
|
+
* the allowlist grows from.
|
|
66
|
+
*/
|
|
67
|
+
onReceipt?: (line: string) => void;
|
|
68
|
+
/**
|
|
69
|
+
* RT9: keep a top-level h2/h3's own `id` as the block's `anchor` (only when it differs from the
|
|
70
|
+
* id its words make). OFF by default, because a value stored before anchors existed was read
|
|
71
|
+
* without them: a comparison of such a value with its page must read the page the same way, or
|
|
72
|
+
* an unedited page reads as changed (R15). An import turns it on; a comparison turns it on only
|
|
73
|
+
* when the stored value has an anchor (`hasHeadingAnchors`).
|
|
74
|
+
*/
|
|
75
|
+
headingAnchors?: boolean;
|
|
51
76
|
}
|
|
52
77
|
|
|
53
78
|
/**
|
|
@@ -460,6 +485,29 @@ const codeLanguage = (pre: Element): string | undefined =>
|
|
|
460
485
|
* deserialiser's default flattens a code block to a paragraph with a `code` mark and its
|
|
461
486
|
* newlines collapsed: every imported code fence became one line of inline code.
|
|
462
487
|
*/
|
|
488
|
+
/**
|
|
489
|
+
* The marker a part the SITE renders is written as (`<div class="bcms-block" data-bcms-schema-key=
|
|
490
|
+
* "builtin:site">`, @see BUILTIN_SITE_KEY) → that locked block, never its contents as paragraphs.
|
|
491
|
+
* The repo source and the card's label ride on `data-bcms-site-source` / `data-bcms-site-label`,
|
|
492
|
+
* which only the source lane writes; a rendered marker carries neither and still comes back locked.
|
|
493
|
+
*/
|
|
494
|
+
function siteRule(key: KeyGenerator): DeserializerRule {
|
|
495
|
+
return {
|
|
496
|
+
deserialize(el: Node, _next, createBlock) {
|
|
497
|
+
if (!isElement(el) || el.nodeName.toLowerCase() !== "div" || el.getAttribute("data-bcms-schema-key") !== BUILTIN_SITE_KEY) return undefined;
|
|
498
|
+
const source = el.getAttribute("data-bcms-site-source") ?? "";
|
|
499
|
+
const label = el.getAttribute("data-bcms-site-label") ?? "";
|
|
500
|
+
return createBlock({
|
|
501
|
+
_type: PT_TYPES.block,
|
|
502
|
+
_key: key(),
|
|
503
|
+
schemaKey: BUILTIN_SITE_KEY,
|
|
504
|
+
schemaLabel: label || "Site component",
|
|
505
|
+
fields: source ? { source } : {},
|
|
506
|
+
});
|
|
507
|
+
},
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
|
|
463
511
|
function codeRule(key: KeyGenerator): DeserializerRule {
|
|
464
512
|
return {
|
|
465
513
|
deserialize(el: Node, _next, createBlock) {
|
|
@@ -513,6 +561,216 @@ function styledRule(key: KeyGenerator): DeserializerRule {
|
|
|
513
561
|
};
|
|
514
562
|
}
|
|
515
563
|
|
|
564
|
+
/**
|
|
565
|
+
* The two decorators the renderer writes in markup the standard rules do not read back (R1):
|
|
566
|
+
* - `<mark>`: the deserialiser names it `mark`, the schema calls it `highlight`, so it was
|
|
567
|
+
* dropped on every round trip;
|
|
568
|
+
* - `<span style="text-decoration:underline">`: what `portableTextToHtml` emits for `underline`,
|
|
569
|
+
* which the styled rule then recorded as the site's own design instead of the decorator.
|
|
570
|
+
* Bare only: a classed `<mark>` or a span with anything else on it is still the styled rule's.
|
|
571
|
+
* Gated on the schema, because a custom rule's output is never filtered by it.
|
|
572
|
+
*/
|
|
573
|
+
function ownDecoratorRule(schema: PtSchema | undefined): DeserializerRule {
|
|
574
|
+
const allows = (name: string) => !schema || schema.decorators.some((d) => d.name === name);
|
|
575
|
+
return {
|
|
576
|
+
deserialize(el: Node, next) {
|
|
577
|
+
if (!isElement(el)) return undefined;
|
|
578
|
+
const tag = el.nodeName.toLowerCase();
|
|
579
|
+
const attrs = el.getAttributeNames();
|
|
580
|
+
if (tag === "mark" && attrs.length === 0 && allows("highlight")) {
|
|
581
|
+
return { _type: "__decorator", name: "highlight", children: next(el.childNodes) } as never;
|
|
582
|
+
}
|
|
583
|
+
const style = el.getAttribute("style")?.replace(/\s+/g, "").replace(/;$/, "").toLowerCase();
|
|
584
|
+
if (tag === "span" && attrs.length === 1 && style === "text-decoration:underline" && allows("underline")) {
|
|
585
|
+
return { _type: "__decorator", name: "underline", children: next(el.childNodes) } as never;
|
|
586
|
+
}
|
|
587
|
+
return undefined;
|
|
588
|
+
},
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* A V2 checklist item, `<ul data-task="true"><li data-checked="true|false">`, read as
|
|
594
|
+
* `listItem: "check"` + `checked`; a nested list's depth is added when it is flattened. The box in front of the words (the
|
|
595
|
+
* renderer's disabled checkbox) is skipped: the state is `data-checked`, never the input. Gated on
|
|
596
|
+
* the schema, because a custom rule's output is never filtered by it: a field without the
|
|
597
|
+
* checklist falls through to the standard rule and reads the items as bullets.
|
|
598
|
+
*/
|
|
599
|
+
function checklistItemRule(schema: PtSchema | undefined): DeserializerRule {
|
|
600
|
+
const allowed = !schema || schema.lists.some((l) => l.name === "check");
|
|
601
|
+
return {
|
|
602
|
+
deserialize(el: Node, next) {
|
|
603
|
+
if (!allowed || !isElement(el) || el.nodeName.toLowerCase() !== "li") return undefined;
|
|
604
|
+
const list = el.parentNode;
|
|
605
|
+
if (!list || !isElement(list) || list.nodeName.toLowerCase() !== "ul" || !list.hasAttribute("data-task")) return undefined;
|
|
606
|
+
const words = Array.from(el.childNodes).filter((n) => !(isElement(n) && n.nodeName.toLowerCase() === "input"));
|
|
607
|
+
return {
|
|
608
|
+
_type: "block",
|
|
609
|
+
style: "normal",
|
|
610
|
+
markDefs: [],
|
|
611
|
+
listItem: "check",
|
|
612
|
+
// 1, as the standard list rule writes it: flattening a nested list adds the depth.
|
|
613
|
+
level: 1,
|
|
614
|
+
checked: el.getAttribute("data-checked") === "true",
|
|
615
|
+
children: next(words as never),
|
|
616
|
+
} as never;
|
|
617
|
+
},
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* RT8: `<aside data-rt="related">` → `relatedPosts` and `<aside data-rt="author">` → `authorBio`, the
|
|
623
|
+
* inverses of their renderers and of V2's markup. Only what is stored is read: the title (≤ 40),
|
|
624
|
+
* the mode (V2's "auto" is "topic"; else "newest" or "manual"), the count (V2 clamps it to 1–6,
|
|
625
|
+
* default 3) and the picks (`data-posts`); the author's id (`data-author`). The listed posts and
|
|
626
|
+
* the author's name, avatar and bio are resolved at render and ignored.
|
|
627
|
+
*/
|
|
628
|
+
function referenceRule(key: KeyGenerator, schema: PtSchema | undefined): DeserializerRule {
|
|
629
|
+
const related = allowsBlockObject(schema, PT_TYPES.relatedPosts);
|
|
630
|
+
const author = allowsBlockObject(schema, PT_TYPES.authorBio);
|
|
631
|
+
return {
|
|
632
|
+
deserialize(el: Node, _next, createBlock) {
|
|
633
|
+
if (!isElement(el) || el.nodeName.toLowerCase() !== "aside") return undefined;
|
|
634
|
+
const rt = el.getAttribute("data-rt");
|
|
635
|
+
if (rt === "related" && related) {
|
|
636
|
+
// Our placeholder (no `resolved`) carries the title as an attribute; a filled aside, and V2's, as `[data-title]`.
|
|
637
|
+
const title = (el.getAttribute("data-title") ?? el.querySelector("[data-title]")?.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, PT_RELATED_LIMITS.titleChars);
|
|
638
|
+
const m = el.getAttribute("data-mode");
|
|
639
|
+
const mode = m === "manual" || m === "newest" ? m : "topic";
|
|
640
|
+
const count = Math.min(PT_RELATED_LIMITS.maxCount, Math.max(PT_RELATED_LIMITS.minCount, Math.trunc(Number(el.getAttribute("data-count"))) || 3));
|
|
641
|
+
const posts = (el.getAttribute("data-posts") ?? "").split(",").map((id) => id.trim()).filter(Boolean).map((_ref) => ({ _key: key(), _ref }));
|
|
642
|
+
return createBlock({ _type: PT_TYPES.relatedPosts, _key: key(), ...(title ? { title } : {}), mode, count, posts });
|
|
643
|
+
}
|
|
644
|
+
if (rt === "author" && author) {
|
|
645
|
+
const id = (el.getAttribute("data-author") ?? "").trim();
|
|
646
|
+
return createBlock({ _type: PT_TYPES.authorBio, _key: key(), ...(id ? { author: { _ref: id } } : {}) });
|
|
647
|
+
}
|
|
648
|
+
return undefined;
|
|
649
|
+
},
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* RT7: `<div data-rt="columns" data-gap>` → the `columns` block, the inverse of `columnsHtml` and
|
|
655
|
+
* of V2's own markup: one column per `[data-col]` child, its html read with the same options as
|
|
656
|
+
* the body (the field's schema too), and the gap (s, m, l; else m). The count is derived. Nothing
|
|
657
|
+
* is dropped here: a column holding more than text, or a count outside 2 to 4, is the write check's
|
|
658
|
+
* to refuse by name.
|
|
659
|
+
*/
|
|
660
|
+
function columnsRule(key: KeyGenerator, inner: (html: string) => PortableText): DeserializerRule {
|
|
661
|
+
return {
|
|
662
|
+
deserialize(el: Node, _next, createBlock) {
|
|
663
|
+
if (!isElement(el) || el.nodeName.toLowerCase() !== "div" || el.getAttribute("data-rt") !== "columns") return undefined;
|
|
664
|
+
const g = el.getAttribute("data-gap");
|
|
665
|
+
const columns = Array.from(el.children)
|
|
666
|
+
.filter((c) => c.hasAttribute("data-col"))
|
|
667
|
+
.map((c) => ({ _key: key(), content: inner(c.innerHTML) }));
|
|
668
|
+
return createBlock({ _type: PT_TYPES.columns, _key: key(), gap: g === "s" || g === "l" ? g : "m", columns });
|
|
669
|
+
},
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* RT5: `<nav data-rt="toc" data-depth>` → the `tableOfContents` block, the inverse of `tocHtml`
|
|
675
|
+
* and of V2's own markup. Only what is stored is read: the title (`[data-title]`, ≤ 40, none when
|
|
676
|
+
* empty) and the depth ("2", else "3"). The list of links is the derived outline and is ignored.
|
|
677
|
+
*/
|
|
678
|
+
function tocRule(key: KeyGenerator): DeserializerRule {
|
|
679
|
+
return {
|
|
680
|
+
deserialize(el: Node, _next, createBlock) {
|
|
681
|
+
if (!isElement(el) || el.nodeName.toLowerCase() !== "nav" || el.getAttribute("data-rt") !== "toc") return undefined;
|
|
682
|
+
const title = (el.querySelector("[data-title]")?.textContent ?? "").trim().slice(0, PT_TOC_LIMITS.titleChars);
|
|
683
|
+
const depth = el.getAttribute("data-depth") === "2" ? "2" : "3";
|
|
684
|
+
return createBlock({ _type: PT_TYPES.tableOfContents, _key: key(), ...(title ? { title } : {}), depth });
|
|
685
|
+
},
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* RT4: `<figure data-rt="gallery" data-layout>` → the `gallery` block, the inverse of `galleryHtml`
|
|
691
|
+
* and of V2's own markup. One image per `<li>`: its `<img>` src and alt, and its figcaption as the
|
|
692
|
+
* caption (≤ 160). A `<li>` with no `<img>` is an image not yet chosen, as V2 reads it. The
|
|
693
|
+
* layout is a carousel only when it says so.
|
|
694
|
+
*/
|
|
695
|
+
function galleryFigureRule(key: KeyGenerator): DeserializerRule {
|
|
696
|
+
return {
|
|
697
|
+
deserialize(el: Node, _next, createBlock) {
|
|
698
|
+
if (!isElement(el) || el.nodeName.toLowerCase() !== "figure" || el.getAttribute("data-rt") !== "gallery") return undefined;
|
|
699
|
+
const images = Array.from(el.querySelectorAll("li")).map((li) => {
|
|
700
|
+
const img = li.querySelector("img");
|
|
701
|
+
const caption = (li.querySelector("figcaption")?.textContent ?? "").trim().slice(0, PT_GALLERY_LIMITS.captionChars);
|
|
702
|
+
const frame = img ? readImageFrame(img) : null;
|
|
703
|
+
return {
|
|
704
|
+
_key: key(),
|
|
705
|
+
image: img
|
|
706
|
+
? {
|
|
707
|
+
src: frame?.src ?? (img.getAttribute("src") ?? "").trim(),
|
|
708
|
+
alt: img.getAttribute("alt") ?? "",
|
|
709
|
+
...(frame?.crop ? { crop: frame.crop } : {}),
|
|
710
|
+
...(frame?.focal ? { focal: frame.focal } : {}),
|
|
711
|
+
}
|
|
712
|
+
: null,
|
|
713
|
+
...(caption ? { caption } : {}),
|
|
714
|
+
};
|
|
715
|
+
});
|
|
716
|
+
const layout = el.getAttribute("data-layout") === "carousel" ? "carousel" : "grid";
|
|
717
|
+
return createBlock({ _type: PT_TYPES.gallery, _key: key(), layout, images });
|
|
718
|
+
},
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* RT3: `<figure data-rt="embed">` → the `embed` block, the inverse of `embedFigureHtml` and of V2's
|
|
724
|
+
* own `<figure data-rt="embed" data-url data-aspect><a href>title</a></figure>`. Only what is
|
|
725
|
+
* stored is read: the url (`data-url`, else the link's href), the title (`aria-label` when the
|
|
726
|
+
* figure has one, which ours always does, else the link's words, as V2 reads it) and the aspect
|
|
727
|
+
* (V2's four, else 16:9). The frame, the card and `data-provider` are derived and ignored.
|
|
728
|
+
*/
|
|
729
|
+
function embedFigureRule(key: KeyGenerator, schema: PtSchema | undefined): DeserializerRule {
|
|
730
|
+
const allowed = allowsBlockObject(schema, PT_TYPES.embed);
|
|
731
|
+
return {
|
|
732
|
+
deserialize(el: Node, _next, createBlock) {
|
|
733
|
+
if (!allowed || !isElement(el) || el.nodeName.toLowerCase() !== "figure" || el.getAttribute("data-rt") !== "embed") return undefined;
|
|
734
|
+
const link = el.querySelector("a[href]");
|
|
735
|
+
const url = (el.getAttribute("data-url") ?? link?.getAttribute("href") ?? "").trim();
|
|
736
|
+
const title = (el.hasAttribute("aria-label") ? el.getAttribute("aria-label") ?? "" : (link?.textContent ?? "")).trim().slice(0, EMBED_TITLE_MAX);
|
|
737
|
+
const aspect = el.getAttribute("data-aspect");
|
|
738
|
+
return createBlock({ _type: PT_TYPES.embed, _key: key(), url, title, aspect: isEmbedAspect(aspect) ? aspect : "16:9" });
|
|
739
|
+
},
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* RT2: V2's button, `<a data-button="true" href>`, → a `link` annotation with `button: true` (and
|
|
745
|
+
* the site's class, a new tab and `rel`, as the classed link keeps them). Gated on the schema's
|
|
746
|
+
* link annotation having the `button` field: a field without the button feature reads it as the
|
|
747
|
+
* plain link it also is.
|
|
748
|
+
*/
|
|
749
|
+
function buttonLinkRule(key: KeyGenerator, schema: PtSchema | undefined): DeserializerRule {
|
|
750
|
+
const allowed =
|
|
751
|
+
!schema || schema.annotations.some((a) => a.name === PT_TYPES.link && (a.fields ?? []).some((f) => f.name === "button"));
|
|
752
|
+
return {
|
|
753
|
+
deserialize(el: Node, next) {
|
|
754
|
+
if (!allowed || !isElement(el) || el.nodeName.toLowerCase() !== "a") return undefined;
|
|
755
|
+
const flag = el.getAttribute("data-button");
|
|
756
|
+
const href = el.getAttribute("href")?.trim();
|
|
757
|
+
if (flag === null || flag === "false" || !href) return undefined;
|
|
758
|
+
const className = el.getAttribute("class")?.trim();
|
|
759
|
+
const rel = el.getAttribute("rel")?.trim();
|
|
760
|
+
const markDef = {
|
|
761
|
+
_type: PT_TYPES.link,
|
|
762
|
+
_key: key(),
|
|
763
|
+
href,
|
|
764
|
+
button: true,
|
|
765
|
+
...(className ? { className } : {}),
|
|
766
|
+
...(el.getAttribute("target") === "_blank" ? { target: "_blank" } : {}),
|
|
767
|
+
...(rel ? { rel } : {}),
|
|
768
|
+
};
|
|
769
|
+
return { _type: "__annotation", markDef, children: next(el.childNodes) } as never;
|
|
770
|
+
},
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
|
|
516
774
|
/**
|
|
517
775
|
* `<a class>` → a `link` annotation that keeps the site's class as `className`.
|
|
518
776
|
*
|
|
@@ -595,7 +853,7 @@ const PDF_HREF = /\.pdf(?:[?#]|$)/i;
|
|
|
595
853
|
* and four rules would each have to ask that question. `undefined` hands the node back to the
|
|
596
854
|
* deserialiser untouched, which is what keeps a mid-sentence PDF link a link.
|
|
597
855
|
*/
|
|
598
|
-
function mediaRule(key: KeyGenerator, schema: PtSchema | undefined): DeserializerRule {
|
|
856
|
+
function mediaRule(key: KeyGenerator, schema: PtSchema | undefined, onReceipt?: (line: string) => void): DeserializerRule {
|
|
599
857
|
const allows = (name: string): boolean =>
|
|
600
858
|
!schema || schema.blockObjects.some((o) => o.name === name);
|
|
601
859
|
return {
|
|
@@ -628,6 +886,15 @@ function mediaRule(key: KeyGenerator, schema: PtSchema | undefined): Deserialize
|
|
|
628
886
|
...(caption ? { title: caption } : {}),
|
|
629
887
|
});
|
|
630
888
|
}
|
|
889
|
+
// RT3: a frame the embed allowlist recognises is an `embed`, in a field that takes one.
|
|
890
|
+
if (subject === "iframe" && allows(PT_TYPES.embed)) {
|
|
891
|
+
const src = (el.getAttribute("src") ?? "").trim();
|
|
892
|
+
if (src && resolveEmbed(src).kind === "frame") {
|
|
893
|
+
const title = ((el.getAttribute("title") ?? "").trim() || caption).slice(0, EMBED_TITLE_MAX);
|
|
894
|
+
const aspect = nearestEmbedAspect(el.getAttribute("width"), el.getAttribute("height"));
|
|
895
|
+
return createBlock({ _type: PT_TYPES.embed, _key: key(), url: src, title, aspect });
|
|
896
|
+
}
|
|
897
|
+
}
|
|
631
898
|
// Image and embed keep the GENERIC block object under their existing `builtin:` keys —
|
|
632
899
|
// thousands of stored values already spell them that way, and nothing is gained by a
|
|
633
900
|
// migration when the renderer reads both shapes from the same fields.
|
|
@@ -636,6 +903,18 @@ function mediaRule(key: KeyGenerator, schema: PtSchema | undefined): Deserialize
|
|
|
636
903
|
if (!src) return undefined;
|
|
637
904
|
if (subject === "iframe") {
|
|
638
905
|
const title = (el.getAttribute("title") ?? "").trim() || caption;
|
|
906
|
+
// Any other frame keeps the legacy, read-only `builtin:video` path: a working Calendly-like
|
|
907
|
+
// embed must not turn into a link (R15). Recorded, so the allowlist can grow from evidence.
|
|
908
|
+
// Only a frame the allowlist does not know is evidence; a known one here just met a field without the embed.
|
|
909
|
+
if (resolveEmbed(src).kind !== "frame") {
|
|
910
|
+
let host = "invalid";
|
|
911
|
+
try {
|
|
912
|
+
host = new URL(src).hostname;
|
|
913
|
+
} catch {
|
|
914
|
+
/* an unparsable src is recorded as such */
|
|
915
|
+
}
|
|
916
|
+
onReceipt?.(`embed.legacy_iframe:${host}`);
|
|
917
|
+
}
|
|
639
918
|
return createBlock({
|
|
640
919
|
_type: PT_TYPES.block,
|
|
641
920
|
_key: key(),
|
|
@@ -643,14 +922,18 @@ function mediaRule(key: KeyGenerator, schema: PtSchema | undefined): Deserialize
|
|
|
643
922
|
fields: { url: src, ...(title ? { title } : {}) },
|
|
644
923
|
});
|
|
645
924
|
}
|
|
925
|
+
// RT9: an image we drew with a crop or focal point carries what was stored back.
|
|
926
|
+
const frame = readImageFrame(el);
|
|
646
927
|
return createBlock({
|
|
647
928
|
_type: PT_TYPES.block,
|
|
648
929
|
_key: key(),
|
|
649
930
|
schemaKey: BUILTIN_IMAGE_KEY,
|
|
650
931
|
fields: {
|
|
651
|
-
src,
|
|
932
|
+
src: frame.src ?? src,
|
|
652
933
|
alt: (el.getAttribute("alt") ?? "").trim(),
|
|
653
934
|
...(caption ? { caption } : {}),
|
|
935
|
+
...(frame.crop ? { crop: frame.crop } : {}),
|
|
936
|
+
...(frame.focal ? { focal: frame.focal } : {}),
|
|
654
937
|
},
|
|
655
938
|
});
|
|
656
939
|
},
|
|
@@ -792,6 +1075,104 @@ const allowsStyled = (schema: PtSchema | undefined): boolean =>
|
|
|
792
1075
|
const allowsBlockObject = (schema: PtSchema | undefined, name: string): boolean =>
|
|
793
1076
|
!schema || schema.blockObjects.some((o) => o.name === name);
|
|
794
1077
|
|
|
1078
|
+
const words = (s: string): string => s.replace(/\u200b/g, "").replace(/\s+/g, " ").trim();
|
|
1079
|
+
|
|
1080
|
+
/**
|
|
1081
|
+
* RT9: each top-level h2/h3 of the page being read, in order, with its own `id` ("" when none or
|
|
1082
|
+
* not a plain id). The words exclude a footnote marker, as the stored heading's do.
|
|
1083
|
+
*/
|
|
1084
|
+
const withHeadingIds =
|
|
1085
|
+
(parse: (html: string) => Document, into: { text: string; id: string }[]) =>
|
|
1086
|
+
(html: string): Document => {
|
|
1087
|
+
const doc = parse(html);
|
|
1088
|
+
for (const h of Array.from(doc.body.querySelectorAll(":scope > h2, :scope > h3"))) {
|
|
1089
|
+
const copy = h.cloneNode(true) as Element;
|
|
1090
|
+
copy.querySelectorAll("sup").forEach((s) => s.remove());
|
|
1091
|
+
const id = h.getAttribute("id") ?? "";
|
|
1092
|
+
into.push({ text: words(copy.textContent ?? ""), id: isHeadingAnchor(id) ? id : "" });
|
|
1093
|
+
}
|
|
1094
|
+
return doc;
|
|
1095
|
+
};
|
|
1096
|
+
|
|
1097
|
+
/**
|
|
1098
|
+
* RT9: an imported heading keeps its page's id as `anchor`, so the site's own links to it keep
|
|
1099
|
+
* working, but only when it differs from the id its words make: our own markup reads back with
|
|
1100
|
+
* no anchor at all, and a heading's id follows its words unless the page said otherwise.
|
|
1101
|
+
*/
|
|
1102
|
+
function keepHeadingIds(value: PortableText, ids: { text: string; id: string }[]): PortableText {
|
|
1103
|
+
if (!ids.some((h) => h.id)) return value;
|
|
1104
|
+
const heads = value.filter(
|
|
1105
|
+
(n): n is PortableTextBlock => n._type === "block" && !(n as PortableTextBlock).listItem && ((n as PortableTextBlock).style === "h2" || (n as PortableTextBlock).style === "h3"),
|
|
1106
|
+
);
|
|
1107
|
+
let at = 0;
|
|
1108
|
+
const marked: PortableTextBlock[] = [];
|
|
1109
|
+
for (const head of heads) {
|
|
1110
|
+
const text = words(head.children.map((c) => (typeof (c as { text?: unknown }).text === "string" ? (c as { text: string }).text : "")).join(""));
|
|
1111
|
+
while (at < ids.length && ids[at].text !== text) at++;
|
|
1112
|
+
if (at >= ids.length) break;
|
|
1113
|
+
if (ids[at].id) {
|
|
1114
|
+
head.anchor = ids[at].id;
|
|
1115
|
+
marked.push(head);
|
|
1116
|
+
}
|
|
1117
|
+
at++;
|
|
1118
|
+
}
|
|
1119
|
+
// The ids the words alone would make: an anchor equal to its heading's is not kept.
|
|
1120
|
+
const set = new Set<unknown>(marked);
|
|
1121
|
+
const plain = value.map((n) => (set.has(n) ? { ...n, anchor: undefined } : n));
|
|
1122
|
+
const auto = outlineOf(plain).nodes;
|
|
1123
|
+
value.forEach((n, i) => {
|
|
1124
|
+
if (set.has(n) && auto.get(plain[i]) === (n as PortableTextBlock).anchor) delete (n as PortableTextBlock).anchor;
|
|
1125
|
+
});
|
|
1126
|
+
return value;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
const allowsInlineObject = (schema: PtSchema | undefined, name: string): boolean =>
|
|
1130
|
+
!schema || schema.inlineObjects.some((o) => o.name === name);
|
|
1131
|
+
|
|
1132
|
+
/**
|
|
1133
|
+
* RT6: V2's end list (`<ol data-rt="footnotes">`, or a legacy `ol.cs-footnotes`) is derived, so it
|
|
1134
|
+
* is dropped before the deserialiser sees it (V2 `hydrate`). A legacy marker that kept its note only
|
|
1135
|
+
* in the list (`<sup id="fnref-N">`, no `data-note`) is lent that note first.
|
|
1136
|
+
*/
|
|
1137
|
+
const withFootnoteNotes =
|
|
1138
|
+
(parse: (html: string) => Document) =>
|
|
1139
|
+
(html: string): Document => {
|
|
1140
|
+
const doc = parse(html);
|
|
1141
|
+
const lists = Array.from(doc.body.querySelectorAll(":scope > ol[data-rt=footnotes], :scope > ol.cs-footnotes"));
|
|
1142
|
+
if (lists.length === 0) return doc;
|
|
1143
|
+
const legacy: Record<number, string> = {};
|
|
1144
|
+
for (const ol of lists) {
|
|
1145
|
+
for (const li of Array.from(ol.querySelectorAll(":scope > li[id^='fn-']"))) {
|
|
1146
|
+
const n = Number(li.id.slice(3));
|
|
1147
|
+
const copy = li.cloneNode(true) as Element;
|
|
1148
|
+
copy.querySelectorAll("a[href^='#fnref-']").forEach((a) => a.remove());
|
|
1149
|
+
if (n) legacy[n] = (copy.textContent ?? "").replace(/\u200b/g, "").trim();
|
|
1150
|
+
}
|
|
1151
|
+
ol.remove();
|
|
1152
|
+
}
|
|
1153
|
+
for (const sup of Array.from(doc.body.querySelectorAll("sup[data-rt=fn], sup[id^='fnref-']"))) {
|
|
1154
|
+
if (sup.hasAttribute("data-note")) continue;
|
|
1155
|
+
const n = Number(sup.id.replace("fnref-", "")) || Number(sup.textContent) || 0;
|
|
1156
|
+
sup.setAttribute("data-note", legacy[n] ?? "");
|
|
1157
|
+
}
|
|
1158
|
+
return doc;
|
|
1159
|
+
};
|
|
1160
|
+
|
|
1161
|
+
/**
|
|
1162
|
+
* RT6: a footnote marker, ours or V2's `<sup data-rt="fn" data-note>` (or a legacy `<sup
|
|
1163
|
+
* id="fnref-N">`) → the `footnote` inline object. Only the note is read: the number, the ids and
|
|
1164
|
+
* the link inside are derived.
|
|
1165
|
+
*/
|
|
1166
|
+
function footnoteRule(key: KeyGenerator): DeserializerRule {
|
|
1167
|
+
return {
|
|
1168
|
+
deserialize(el: Node) {
|
|
1169
|
+
if (!isElement(el) || el.nodeName.toLowerCase() !== "sup") return undefined;
|
|
1170
|
+
if (el.getAttribute("data-rt") !== "fn" && !(el.getAttribute("id") ?? "").startsWith("fnref-")) return undefined;
|
|
1171
|
+
return { _type: PT_TYPES.footnote, _key: key(), note: el.getAttribute("data-note") ?? "" } as never;
|
|
1172
|
+
},
|
|
1173
|
+
};
|
|
1174
|
+
}
|
|
1175
|
+
|
|
795
1176
|
const isElement = (n: unknown): n is Element =>
|
|
796
1177
|
!!n && typeof n === "object" && (n as Node).nodeType === 1;
|
|
797
1178
|
|
|
@@ -823,11 +1204,18 @@ const withoutBlockWhitespace =
|
|
|
823
1204
|
export function htmlToPortableText(html: string, opts: IngestOptions = {}): PortableText {
|
|
824
1205
|
if (typeof html !== "string" || html.trim() === "") return [];
|
|
825
1206
|
const key = opts.keyGenerator ?? randomKey;
|
|
1207
|
+
const headingIds: { text: string; id: string }[] = [];
|
|
826
1208
|
const parse =
|
|
827
1209
|
opts.parseHtml ?? (typeof DOMParser !== "undefined" ? (markup: string) => new DOMParser().parseFromString(markup, "text/html") : undefined);
|
|
828
1210
|
const converted = ptHtmlToPortableText(html, {
|
|
829
1211
|
// The browser's own parser when none is passed (Node must pass one), so both drop the same whitespace.
|
|
830
|
-
...(parse
|
|
1212
|
+
...(parse
|
|
1213
|
+
? {
|
|
1214
|
+
parseHtml: ((p) => (opts.headingAnchors ? withHeadingIds(p, headingIds) : p))(
|
|
1215
|
+
allowsInlineObject(opts.schema, PT_TYPES.footnote) ? withFootnoteNotes(withoutBlockWhitespace(parse)) : withoutBlockWhitespace(parse),
|
|
1216
|
+
),
|
|
1217
|
+
}
|
|
1218
|
+
: {}),
|
|
831
1219
|
// `ptSchemaFor` returns a schema DEFINITION — the same object `EditorProvider` takes, so
|
|
832
1220
|
// the editor and this filter are literally configured from one value. The deserialiser
|
|
833
1221
|
// wants the COMPILED form, so the compile happens here rather than at every call site.
|
|
@@ -845,19 +1233,32 @@ export function htmlToPortableText(html: string, opts: IngestOptions = {}): Port
|
|
|
845
1233
|
* accepts only images.
|
|
846
1234
|
*/
|
|
847
1235
|
rules: [
|
|
1236
|
+
// First: a marker's content is the site's, never this field's text.
|
|
1237
|
+
...(opts.siteParts || allowsBlockObject(opts.schema, PT_TYPES.block) ? [siteRule(key)] : []),
|
|
848
1238
|
...(allowsBlockObject(opts.schema, PT_TYPES.table)
|
|
849
1239
|
? [tableFigureRule(key, (cell) => htmlToPortableText(cell, opts)), tableRule(key, (cell) => htmlToPortableText(cell, opts))]
|
|
850
1240
|
: []),
|
|
1241
|
+
...(allowsInlineObject(opts.schema, PT_TYPES.footnote) ? [footnoteRule(key)] : []),
|
|
851
1242
|
...(allowsBlockObject(opts.schema, PT_TYPES.code) ? [codeRule(key)] : []),
|
|
852
1243
|
...(allowsBlockObject(opts.schema, PT_TYPES.pullQuote) ? [pullQuoteRule(key)] : []),
|
|
853
1244
|
...(allowsBlockObject(opts.schema, PT_TYPES.keyTakeaways) ? [keyTakeawaysRule(key)] : []),
|
|
854
|
-
|
|
1245
|
+
// Before the media rule, which would take the iframe out of our own embed figure (and an image out of a gallery).
|
|
1246
|
+
...(allowsBlockObject(opts.schema, PT_TYPES.gallery) ? [galleryFigureRule(key)] : []),
|
|
1247
|
+
...(allowsBlockObject(opts.schema, PT_TYPES.tableOfContents) ? [tocRule(key)] : []),
|
|
1248
|
+
referenceRule(key, opts.schema),
|
|
1249
|
+
...(allowsBlockObject(opts.schema, PT_TYPES.columns) ? [columnsRule(key, (inner) => htmlToPortableText(inner, opts))] : []),
|
|
1250
|
+
embedFigureRule(key, opts.schema),
|
|
1251
|
+
mediaRule(key, opts.schema, opts.onReceipt),
|
|
1252
|
+
checklistItemRule(opts.schema),
|
|
1253
|
+
ownDecoratorRule(opts.schema),
|
|
855
1254
|
...(allowsStyled(opts.schema) ? [styledRule(key)] : []),
|
|
1255
|
+
buttonLinkRule(key, opts.schema),
|
|
856
1256
|
...(allowsLinks(opts.schema) ? [classedLinkRule(key)] : []),
|
|
857
1257
|
],
|
|
858
1258
|
keyGenerator: key,
|
|
859
1259
|
});
|
|
860
|
-
|
|
1260
|
+
const value = normalise(converted, key);
|
|
1261
|
+
return opts.headingAnchors ? keepHeadingIds(value, headingIds) : value;
|
|
861
1262
|
}
|
|
862
1263
|
|
|
863
1264
|
/**
|
package/src/plain.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
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";
|
|
5
|
+
import { columnsView } from "./columns";
|
|
4
6
|
import { componentCopyLines, detachedComponentCopy } from "./component-copy";
|
|
5
7
|
|
|
6
8
|
/**
|
|
@@ -49,6 +51,20 @@ export function portableTextToPlainText(value: PortableText | null | undefined):
|
|
|
49
51
|
lines.push(...(title ? [title] : []), ...points);
|
|
50
52
|
continue;
|
|
51
53
|
}
|
|
54
|
+
/** A gallery's captions are words a reader sees; its images are not. */
|
|
55
|
+
if (node._type === PT_TYPES.gallery) {
|
|
56
|
+
lines.push(...galleryView(node).images.map((x) => x.caption).filter(Boolean));
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
/** A column's text is words a reader sees, in column order (a legacy `builtin:columns` too). */
|
|
60
|
+
const cols = columnsView(node);
|
|
61
|
+
if (cols) {
|
|
62
|
+
for (const c of cols.columns) {
|
|
63
|
+
const text = portableTextToPlainText(c.content);
|
|
64
|
+
if (text) lines.push(text);
|
|
65
|
+
}
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
52
68
|
const table = migrateTable(node);
|
|
53
69
|
if (table) {
|
|
54
70
|
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, ResolvedAuthor, ResolvedPost, RichTextEmbeds, RichTextResolved } 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";
|
|
@@ -272,3 +298,40 @@ export function embeddedComponentRender(
|
|
|
272
298
|
const copy = detachedComponentCopy(value);
|
|
273
299
|
return copy ? { kind: "copy", copy } : null;
|
|
274
300
|
}
|
|
301
|
+
|
|
302
|
+
const strings = <K extends string>(o: Record<string, unknown>, keys: readonly K[]): Partial<Record<K, string>> =>
|
|
303
|
+
Object.fromEntries(keys.filter((k) => typeof o[k] === "string").map((k) => [k, o[k]])) as Partial<Record<K, string>>;
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* RT8 (G-RT8): the entries a DELIVERED value's author bios and related posts point at, which the
|
|
307
|
+
* delivery puts beside the value (`value.resolved`); undefined when there are none. Never read from
|
|
308
|
+
* a stored envelope: nothing stores it. Checked, not trusted: a post keeps only its string fields
|
|
309
|
+
* and needs a title and url, an author a name, and `related` only lists of ids.
|
|
310
|
+
*/
|
|
311
|
+
export function readStructuredResolved(props: unknown): RichTextResolved | undefined {
|
|
312
|
+
if (!isRecord(props)) return undefined;
|
|
313
|
+
const candidate = props.content ?? props.value;
|
|
314
|
+
const raw = isRecord(candidate) ? candidate.resolved : undefined;
|
|
315
|
+
if (!isRecord(raw)) return undefined;
|
|
316
|
+
const posts: Record<string, ResolvedPost> = {};
|
|
317
|
+
if (isRecord(raw.posts)) {
|
|
318
|
+
for (const [id, p] of Object.entries(raw.posts)) {
|
|
319
|
+
if (isRecord(p) && typeof p.title === "string" && typeof p.url === "string") {
|
|
320
|
+
posts[id] = { title: p.title, url: p.url, ...strings(p, ["cover", "category"] as const) };
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
const authors: Record<string, ResolvedAuthor> = {};
|
|
325
|
+
if (isRecord(raw.authors)) {
|
|
326
|
+
for (const [id, a] of Object.entries(raw.authors)) {
|
|
327
|
+
if (isRecord(a) && typeof a.name === "string") authors[id] = { name: a.name, ...strings(a, ["avatar", "bio", "url"] as const) };
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
const related: Record<string, string[]> = {};
|
|
331
|
+
if (isRecord(raw.related)) {
|
|
332
|
+
for (const [key, ids] of Object.entries(raw.related)) {
|
|
333
|
+
if (Array.isArray(ids)) related[key] = ids.filter((id): id is string => typeof id === "string");
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return { posts, authors, related };
|
|
337
|
+
}
|