@bettercms-ai/richtext 0.14.0 → 0.17.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 -2
- package/src/accept.ts +12 -6
- package/src/columns.ts +117 -0
- package/src/footnote.ts +70 -0
- package/src/gallery.ts +24 -4
- package/src/href.ts +39 -0
- package/src/html.ts +152 -51
- package/src/image-layout.ts +54 -0
- package/src/index.ts +3 -0
- package/src/ingest.ts +210 -33
- package/src/plain.ts +10 -0
- package/src/read.ts +59 -1
- package/src/references.ts +147 -0
- package/src/schema.ts +32 -0
- package/src/toc.ts +25 -8
- package/src/types.ts +74 -2
- package/src/write-refusals.ts +14 -6
package/src/ingest.ts
CHANGED
|
@@ -14,13 +14,15 @@ import {
|
|
|
14
14
|
type PortableTextTableRow,
|
|
15
15
|
} from "./types";
|
|
16
16
|
import { randomKey, isValidKey, type KeyGenerator } from "./key";
|
|
17
|
-
import { safeHref } from "./href";
|
|
17
|
+
import { readLinkRel, safeHref } from "./href";
|
|
18
18
|
import { BARE_IS_DESIGN, isStyledTag, styledAttrsOf, type StyledMarkDef } from "./styled";
|
|
19
19
|
import { EMBED_TITLE_MAX, isEmbedAspect, nearestEmbedAspect, resolveEmbed } from "./embed";
|
|
20
20
|
import { cellTypeOf, columnOf, designOf, isColumnType, isValueType, jsonLdOf, PT_TABLE_LIMITS, typedCellText } from "./table";
|
|
21
21
|
import { PT_KEY_TAKEAWAYS_LIMITS } from "./key-takeaways";
|
|
22
22
|
import { PT_GALLERY_LIMITS } from "./gallery";
|
|
23
|
-
import { PT_TOC_LIMITS } from "./toc";
|
|
23
|
+
import { PT_TOC_LIMITS, isHeadingAnchor, outlineOf } from "./toc";
|
|
24
|
+
import { PT_RELATED_LIMITS } from "./references";
|
|
25
|
+
import { readImageFrame } from "./image-layout";
|
|
24
26
|
import type { PtSchema } from "./schema";
|
|
25
27
|
|
|
26
28
|
/**
|
|
@@ -63,6 +65,14 @@ export interface IngestOptions {
|
|
|
63
65
|
* the allowlist grows from.
|
|
64
66
|
*/
|
|
65
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;
|
|
66
76
|
}
|
|
67
77
|
|
|
68
78
|
/**
|
|
@@ -608,6 +618,58 @@ function checklistItemRule(schema: PtSchema | undefined): DeserializerRule {
|
|
|
608
618
|
};
|
|
609
619
|
}
|
|
610
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
|
+
|
|
611
673
|
/**
|
|
612
674
|
* RT5: `<nav data-rt="toc" data-depth>` → the `tableOfContents` block, the inverse of `tocHtml`
|
|
613
675
|
* and of V2's own markup. Only what is stored is read: the title (`[data-title]`, ≤ 40, none when
|
|
@@ -637,9 +699,17 @@ function galleryFigureRule(key: KeyGenerator): DeserializerRule {
|
|
|
637
699
|
const images = Array.from(el.querySelectorAll("li")).map((li) => {
|
|
638
700
|
const img = li.querySelector("img");
|
|
639
701
|
const caption = (li.querySelector("figcaption")?.textContent ?? "").trim().slice(0, PT_GALLERY_LIMITS.captionChars);
|
|
702
|
+
const frame = img ? readImageFrame(img) : null;
|
|
640
703
|
return {
|
|
641
704
|
_key: key(),
|
|
642
|
-
image: img
|
|
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,
|
|
643
713
|
...(caption ? { caption } : {}),
|
|
644
714
|
};
|
|
645
715
|
});
|
|
@@ -685,45 +755,39 @@ function buttonLinkRule(key: KeyGenerator, schema: PtSchema | undefined): Deseri
|
|
|
685
755
|
const flag = el.getAttribute("data-button");
|
|
686
756
|
const href = el.getAttribute("href")?.trim();
|
|
687
757
|
if (flag === null || flag === "false" || !href) return undefined;
|
|
688
|
-
const
|
|
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
|
-
};
|
|
758
|
+
const markDef = { _type: PT_TYPES.link, _key: key(), href, button: true, ...linkExtras(el) };
|
|
699
759
|
return { _type: "__annotation", markDef, children: next(el.childNodes) } as never;
|
|
700
760
|
},
|
|
701
761
|
};
|
|
702
762
|
}
|
|
703
763
|
|
|
704
764
|
/**
|
|
705
|
-
* `<a
|
|
765
|
+
* What an `<a>` stores beyond its href: the site's class, a new tab (only `_blank`) and its
|
|
766
|
+
* `rel` (author tokens; the noopener/noreferrer a new tab adds on render are not kept).
|
|
767
|
+
*/
|
|
768
|
+
function linkExtras(el: Element): { className?: string; target?: "_blank"; rel?: string } {
|
|
769
|
+
const className = el.getAttribute("class")?.trim();
|
|
770
|
+
const blank = el.getAttribute("target")?.trim().toLowerCase() === "_blank";
|
|
771
|
+
const rel = readLinkRel(el.getAttribute("rel"), blank);
|
|
772
|
+
return { ...(className ? { className } : {}), ...(blank ? { target: "_blank" } : {}), ...(rel ? { rel } : {}) };
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* `<a class>`, `<a target="_blank">`, `<a rel>` → a `link` annotation that keeps them.
|
|
706
777
|
*
|
|
707
778
|
* The built-in link rule records `href` and nothing else, so a styled link (`<a class="underline
|
|
708
|
-
* text-brand">`) came back bare and the design died on ingest
|
|
709
|
-
*
|
|
779
|
+
* text-brand">`) came back bare and the design died on ingest, and a link's new tab and its
|
|
780
|
+
* `nofollow` (V2's link form writes both) were lost the same way. Only a link carrying one of
|
|
781
|
+
* them takes this rule; a bare link still goes to the built-in one. `href` is checked by `normalise`.
|
|
710
782
|
*/
|
|
711
|
-
function
|
|
783
|
+
function linkRule(key: KeyGenerator): DeserializerRule {
|
|
712
784
|
return {
|
|
713
785
|
deserialize(el: Node, next) {
|
|
714
786
|
if (!isElement(el) || el.nodeName.toLowerCase() !== "a") return undefined;
|
|
715
|
-
const className = el.getAttribute("class")?.trim();
|
|
716
787
|
const href = el.getAttribute("href")?.trim();
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
const markDef = {
|
|
720
|
-
_type: PT_TYPES.link,
|
|
721
|
-
_key: key(),
|
|
722
|
-
href,
|
|
723
|
-
className,
|
|
724
|
-
...(el.getAttribute("target") === "_blank" ? { target: "_blank" } : {}),
|
|
725
|
-
...(rel ? { rel } : {}),
|
|
726
|
-
};
|
|
788
|
+
const extras = linkExtras(el);
|
|
789
|
+
if (!href || !Object.keys(extras).length) return undefined;
|
|
790
|
+
const markDef = { _type: PT_TYPES.link, _key: key(), href, ...extras };
|
|
727
791
|
return { _type: "__annotation", markDef, children: next(el.childNodes) } as never;
|
|
728
792
|
},
|
|
729
793
|
};
|
|
@@ -852,14 +916,18 @@ function mediaRule(key: KeyGenerator, schema: PtSchema | undefined, onReceipt?:
|
|
|
852
916
|
fields: { url: src, ...(title ? { title } : {}) },
|
|
853
917
|
});
|
|
854
918
|
}
|
|
919
|
+
// RT9: an image we drew with a crop or focal point carries what was stored back.
|
|
920
|
+
const frame = readImageFrame(el);
|
|
855
921
|
return createBlock({
|
|
856
922
|
_type: PT_TYPES.block,
|
|
857
923
|
_key: key(),
|
|
858
924
|
schemaKey: BUILTIN_IMAGE_KEY,
|
|
859
925
|
fields: {
|
|
860
|
-
src,
|
|
926
|
+
src: frame.src ?? src,
|
|
861
927
|
alt: (el.getAttribute("alt") ?? "").trim(),
|
|
862
928
|
...(caption ? { caption } : {}),
|
|
929
|
+
...(frame.crop ? { crop: frame.crop } : {}),
|
|
930
|
+
...(frame.focal ? { focal: frame.focal } : {}),
|
|
863
931
|
},
|
|
864
932
|
});
|
|
865
933
|
},
|
|
@@ -1001,6 +1069,104 @@ const allowsStyled = (schema: PtSchema | undefined): boolean =>
|
|
|
1001
1069
|
const allowsBlockObject = (schema: PtSchema | undefined, name: string): boolean =>
|
|
1002
1070
|
!schema || schema.blockObjects.some((o) => o.name === name);
|
|
1003
1071
|
|
|
1072
|
+
const words = (s: string): string => s.replace(/\u200b/g, "").replace(/\s+/g, " ").trim();
|
|
1073
|
+
|
|
1074
|
+
/**
|
|
1075
|
+
* RT9: each top-level h2/h3 of the page being read, in order, with its own `id` ("" when none or
|
|
1076
|
+
* not a plain id). The words exclude a footnote marker, as the stored heading's do.
|
|
1077
|
+
*/
|
|
1078
|
+
const withHeadingIds =
|
|
1079
|
+
(parse: (html: string) => Document, into: { text: string; id: string }[]) =>
|
|
1080
|
+
(html: string): Document => {
|
|
1081
|
+
const doc = parse(html);
|
|
1082
|
+
for (const h of Array.from(doc.body.querySelectorAll(":scope > h2, :scope > h3"))) {
|
|
1083
|
+
const copy = h.cloneNode(true) as Element;
|
|
1084
|
+
copy.querySelectorAll("sup").forEach((s) => s.remove());
|
|
1085
|
+
const id = h.getAttribute("id") ?? "";
|
|
1086
|
+
into.push({ text: words(copy.textContent ?? ""), id: isHeadingAnchor(id) ? id : "" });
|
|
1087
|
+
}
|
|
1088
|
+
return doc;
|
|
1089
|
+
};
|
|
1090
|
+
|
|
1091
|
+
/**
|
|
1092
|
+
* RT9: an imported heading keeps its page's id as `anchor`, so the site's own links to it keep
|
|
1093
|
+
* working, but only when it differs from the id its words make: our own markup reads back with
|
|
1094
|
+
* no anchor at all, and a heading's id follows its words unless the page said otherwise.
|
|
1095
|
+
*/
|
|
1096
|
+
function keepHeadingIds(value: PortableText, ids: { text: string; id: string }[]): PortableText {
|
|
1097
|
+
if (!ids.some((h) => h.id)) return value;
|
|
1098
|
+
const heads = value.filter(
|
|
1099
|
+
(n): n is PortableTextBlock => n._type === "block" && !(n as PortableTextBlock).listItem && ((n as PortableTextBlock).style === "h2" || (n as PortableTextBlock).style === "h3"),
|
|
1100
|
+
);
|
|
1101
|
+
let at = 0;
|
|
1102
|
+
const marked: PortableTextBlock[] = [];
|
|
1103
|
+
for (const head of heads) {
|
|
1104
|
+
const text = words(head.children.map((c) => (typeof (c as { text?: unknown }).text === "string" ? (c as { text: string }).text : "")).join(""));
|
|
1105
|
+
while (at < ids.length && ids[at].text !== text) at++;
|
|
1106
|
+
if (at >= ids.length) break;
|
|
1107
|
+
if (ids[at].id) {
|
|
1108
|
+
head.anchor = ids[at].id;
|
|
1109
|
+
marked.push(head);
|
|
1110
|
+
}
|
|
1111
|
+
at++;
|
|
1112
|
+
}
|
|
1113
|
+
// The ids the words alone would make: an anchor equal to its heading's is not kept.
|
|
1114
|
+
const set = new Set<unknown>(marked);
|
|
1115
|
+
const plain = value.map((n) => (set.has(n) ? { ...n, anchor: undefined } : n));
|
|
1116
|
+
const auto = outlineOf(plain).nodes;
|
|
1117
|
+
value.forEach((n, i) => {
|
|
1118
|
+
if (set.has(n) && auto.get(plain[i]) === (n as PortableTextBlock).anchor) delete (n as PortableTextBlock).anchor;
|
|
1119
|
+
});
|
|
1120
|
+
return value;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
const allowsInlineObject = (schema: PtSchema | undefined, name: string): boolean =>
|
|
1124
|
+
!schema || schema.inlineObjects.some((o) => o.name === name);
|
|
1125
|
+
|
|
1126
|
+
/**
|
|
1127
|
+
* RT6: V2's end list (`<ol data-rt="footnotes">`, or a legacy `ol.cs-footnotes`) is derived, so it
|
|
1128
|
+
* is dropped before the deserialiser sees it (V2 `hydrate`). A legacy marker that kept its note only
|
|
1129
|
+
* in the list (`<sup id="fnref-N">`, no `data-note`) is lent that note first.
|
|
1130
|
+
*/
|
|
1131
|
+
const withFootnoteNotes =
|
|
1132
|
+
(parse: (html: string) => Document) =>
|
|
1133
|
+
(html: string): Document => {
|
|
1134
|
+
const doc = parse(html);
|
|
1135
|
+
const lists = Array.from(doc.body.querySelectorAll(":scope > ol[data-rt=footnotes], :scope > ol.cs-footnotes"));
|
|
1136
|
+
if (lists.length === 0) return doc;
|
|
1137
|
+
const legacy: Record<number, string> = {};
|
|
1138
|
+
for (const ol of lists) {
|
|
1139
|
+
for (const li of Array.from(ol.querySelectorAll(":scope > li[id^='fn-']"))) {
|
|
1140
|
+
const n = Number(li.id.slice(3));
|
|
1141
|
+
const copy = li.cloneNode(true) as Element;
|
|
1142
|
+
copy.querySelectorAll("a[href^='#fnref-']").forEach((a) => a.remove());
|
|
1143
|
+
if (n) legacy[n] = (copy.textContent ?? "").replace(/\u200b/g, "").trim();
|
|
1144
|
+
}
|
|
1145
|
+
ol.remove();
|
|
1146
|
+
}
|
|
1147
|
+
for (const sup of Array.from(doc.body.querySelectorAll("sup[data-rt=fn], sup[id^='fnref-']"))) {
|
|
1148
|
+
if (sup.hasAttribute("data-note")) continue;
|
|
1149
|
+
const n = Number(sup.id.replace("fnref-", "")) || Number(sup.textContent) || 0;
|
|
1150
|
+
sup.setAttribute("data-note", legacy[n] ?? "");
|
|
1151
|
+
}
|
|
1152
|
+
return doc;
|
|
1153
|
+
};
|
|
1154
|
+
|
|
1155
|
+
/**
|
|
1156
|
+
* RT6: a footnote marker, ours or V2's `<sup data-rt="fn" data-note>` (or a legacy `<sup
|
|
1157
|
+
* id="fnref-N">`) → the `footnote` inline object. Only the note is read: the number, the ids and
|
|
1158
|
+
* the link inside are derived.
|
|
1159
|
+
*/
|
|
1160
|
+
function footnoteRule(key: KeyGenerator): DeserializerRule {
|
|
1161
|
+
return {
|
|
1162
|
+
deserialize(el: Node) {
|
|
1163
|
+
if (!isElement(el) || el.nodeName.toLowerCase() !== "sup") return undefined;
|
|
1164
|
+
if (el.getAttribute("data-rt") !== "fn" && !(el.getAttribute("id") ?? "").startsWith("fnref-")) return undefined;
|
|
1165
|
+
return { _type: PT_TYPES.footnote, _key: key(), note: el.getAttribute("data-note") ?? "" } as never;
|
|
1166
|
+
},
|
|
1167
|
+
};
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1004
1170
|
const isElement = (n: unknown): n is Element =>
|
|
1005
1171
|
!!n && typeof n === "object" && (n as Node).nodeType === 1;
|
|
1006
1172
|
|
|
@@ -1032,11 +1198,18 @@ const withoutBlockWhitespace =
|
|
|
1032
1198
|
export function htmlToPortableText(html: string, opts: IngestOptions = {}): PortableText {
|
|
1033
1199
|
if (typeof html !== "string" || html.trim() === "") return [];
|
|
1034
1200
|
const key = opts.keyGenerator ?? randomKey;
|
|
1201
|
+
const headingIds: { text: string; id: string }[] = [];
|
|
1035
1202
|
const parse =
|
|
1036
1203
|
opts.parseHtml ?? (typeof DOMParser !== "undefined" ? (markup: string) => new DOMParser().parseFromString(markup, "text/html") : undefined);
|
|
1037
1204
|
const converted = ptHtmlToPortableText(html, {
|
|
1038
1205
|
// The browser's own parser when none is passed (Node must pass one), so both drop the same whitespace.
|
|
1039
|
-
...(parse
|
|
1206
|
+
...(parse
|
|
1207
|
+
? {
|
|
1208
|
+
parseHtml: ((p) => (opts.headingAnchors ? withHeadingIds(p, headingIds) : p))(
|
|
1209
|
+
allowsInlineObject(opts.schema, PT_TYPES.footnote) ? withFootnoteNotes(withoutBlockWhitespace(parse)) : withoutBlockWhitespace(parse),
|
|
1210
|
+
),
|
|
1211
|
+
}
|
|
1212
|
+
: {}),
|
|
1040
1213
|
// `ptSchemaFor` returns a schema DEFINITION — the same object `EditorProvider` takes, so
|
|
1041
1214
|
// the editor and this filter are literally configured from one value. The deserialiser
|
|
1042
1215
|
// wants the COMPILED form, so the compile happens here rather than at every call site.
|
|
@@ -1059,23 +1232,27 @@ export function htmlToPortableText(html: string, opts: IngestOptions = {}): Port
|
|
|
1059
1232
|
...(allowsBlockObject(opts.schema, PT_TYPES.table)
|
|
1060
1233
|
? [tableFigureRule(key, (cell) => htmlToPortableText(cell, opts)), tableRule(key, (cell) => htmlToPortableText(cell, opts))]
|
|
1061
1234
|
: []),
|
|
1235
|
+
...(allowsInlineObject(opts.schema, PT_TYPES.footnote) ? [footnoteRule(key)] : []),
|
|
1062
1236
|
...(allowsBlockObject(opts.schema, PT_TYPES.code) ? [codeRule(key)] : []),
|
|
1063
1237
|
...(allowsBlockObject(opts.schema, PT_TYPES.pullQuote) ? [pullQuoteRule(key)] : []),
|
|
1064
1238
|
...(allowsBlockObject(opts.schema, PT_TYPES.keyTakeaways) ? [keyTakeawaysRule(key)] : []),
|
|
1065
1239
|
// Before the media rule, which would take the iframe out of our own embed figure (and an image out of a gallery).
|
|
1066
1240
|
...(allowsBlockObject(opts.schema, PT_TYPES.gallery) ? [galleryFigureRule(key)] : []),
|
|
1067
1241
|
...(allowsBlockObject(opts.schema, PT_TYPES.tableOfContents) ? [tocRule(key)] : []),
|
|
1242
|
+
referenceRule(key, opts.schema),
|
|
1243
|
+
...(allowsBlockObject(opts.schema, PT_TYPES.columns) ? [columnsRule(key, (inner) => htmlToPortableText(inner, opts))] : []),
|
|
1068
1244
|
embedFigureRule(key, opts.schema),
|
|
1069
1245
|
mediaRule(key, opts.schema, opts.onReceipt),
|
|
1070
1246
|
checklistItemRule(opts.schema),
|
|
1071
1247
|
ownDecoratorRule(opts.schema),
|
|
1072
1248
|
...(allowsStyled(opts.schema) ? [styledRule(key)] : []),
|
|
1073
1249
|
buttonLinkRule(key, opts.schema),
|
|
1074
|
-
...(allowsLinks(opts.schema) ? [
|
|
1250
|
+
...(allowsLinks(opts.schema) ? [linkRule(key)] : []),
|
|
1075
1251
|
],
|
|
1076
1252
|
keyGenerator: key,
|
|
1077
1253
|
});
|
|
1078
|
-
|
|
1254
|
+
const value = normalise(converted, key);
|
|
1255
|
+
return opts.headingAnchors ? keepHeadingIds(value, headingIds) : value;
|
|
1079
1256
|
}
|
|
1080
1257
|
|
|
1081
1258
|
/**
|
package/src/plain.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { isPortableTextBlock, isPortableTextSpan, PT_TYPES, type PortableText, t
|
|
|
2
2
|
import { migrateTable, tableGroupLayout } from "./table";
|
|
3
3
|
import { keyTakeawaysView } from "./key-takeaways";
|
|
4
4
|
import { galleryView } from "./gallery";
|
|
5
|
+
import { columnsView } from "./columns";
|
|
5
6
|
import { componentCopyLines, detachedComponentCopy } from "./component-copy";
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -55,6 +56,15 @@ export function portableTextToPlainText(value: PortableText | null | undefined):
|
|
|
55
56
|
lines.push(...galleryView(node).images.map((x) => x.caption).filter(Boolean));
|
|
56
57
|
continue;
|
|
57
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
|
+
}
|
|
58
68
|
const table = migrateTable(node);
|
|
59
69
|
if (table) {
|
|
60
70
|
lines.push(...tableLines(table));
|
package/src/read.ts
CHANGED
|
@@ -3,7 +3,8 @@ 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
|
+
import type { EmbedCard, ResolvedAuthor, ResolvedPost, RichTextEmbeds, RichTextResolved } from "@bettercms-ai/types";
|
|
7
|
+
import type { ClassTag, HtmlClasses } from "./html";
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* THE reader. One function, every stored shape, and it never throws.
|
|
@@ -298,3 +299,60 @@ export function embeddedComponentRender(
|
|
|
298
299
|
const copy = detachedComponentCopy(value);
|
|
299
300
|
return copy ? { kind: "copy", copy } : null;
|
|
300
301
|
}
|
|
302
|
+
|
|
303
|
+
const strings = <K extends string>(o: Record<string, unknown>, keys: readonly K[]): Partial<Record<K, string>> =>
|
|
304
|
+
Object.fromEntries(keys.filter((k) => typeof o[k] === "string").map((k) => [k, o[k]])) as Partial<Record<K, string>>;
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* RT8 (G-RT8): the entries a DELIVERED value's author bios and related posts point at, which the
|
|
308
|
+
* delivery puts beside the value (`value.resolved`); undefined when there are none. Never read from
|
|
309
|
+
* a stored envelope: nothing stores it. Checked, not trusted: a post keeps only its string fields
|
|
310
|
+
* and needs a title and url, an author a name, and `related` only lists of ids.
|
|
311
|
+
*/
|
|
312
|
+
export function readStructuredResolved(props: unknown): RichTextResolved | undefined {
|
|
313
|
+
if (!isRecord(props)) return undefined;
|
|
314
|
+
const candidate = props.content ?? props.value;
|
|
315
|
+
const raw = isRecord(candidate) ? candidate.resolved : undefined;
|
|
316
|
+
if (!isRecord(raw)) return undefined;
|
|
317
|
+
const posts: Record<string, ResolvedPost> = {};
|
|
318
|
+
if (isRecord(raw.posts)) {
|
|
319
|
+
for (const [id, p] of Object.entries(raw.posts)) {
|
|
320
|
+
if (isRecord(p) && typeof p.title === "string" && typeof p.url === "string") {
|
|
321
|
+
posts[id] = { title: p.title, url: p.url, ...strings(p, ["cover", "category"] as const) };
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
const authors: Record<string, ResolvedAuthor> = {};
|
|
326
|
+
if (isRecord(raw.authors)) {
|
|
327
|
+
for (const [id, a] of Object.entries(raw.authors)) {
|
|
328
|
+
if (isRecord(a) && typeof a.name === "string") authors[id] = { name: a.name, ...strings(a, ["avatar", "bio", "url"] as const) };
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
const related: Record<string, string[]> = {};
|
|
332
|
+
if (isRecord(raw.related)) {
|
|
333
|
+
for (const [key, ids] of Object.entries(raw.related)) {
|
|
334
|
+
if (Array.isArray(ids)) related[key] = ids.filter((id): id is string => typeof id === "string");
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return { posts, authors, related };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Every tag a caller may class (html.ts `ClassTag`); a Record so a tag added there must be added here. */
|
|
341
|
+
const CLASS_TAGS = Object.keys({
|
|
342
|
+
p: 1, h1: 1, h2: 1, h3: 1, h4: 1, h5: 1, h6: 1, ul: 1, ol: 1, li: 1, a: 1, img: 1, figure: 1, blockquote: 1,
|
|
343
|
+
} satisfies Record<ClassTag, 1>) as ClassTag[];
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* The site's own class per tag for a DELIVERED value (`value.classes`, the field's
|
|
347
|
+
* `config.htmlClasses`, which the delivery puts beside the value); undefined when there are none.
|
|
348
|
+
* Checked, not trusted: only the tags a renderer dresses, with non-empty string classes.
|
|
349
|
+
*/
|
|
350
|
+
export function readStructuredClasses(props: unknown): HtmlClasses | undefined {
|
|
351
|
+
if (!isRecord(props)) return undefined;
|
|
352
|
+
const candidate = props.content ?? props.value;
|
|
353
|
+
const raw = isRecord(candidate) ? candidate.classes : undefined;
|
|
354
|
+
if (!isRecord(raw)) return undefined;
|
|
355
|
+
const out: HtmlClasses = {};
|
|
356
|
+
for (const tag of CLASS_TAGS) if (typeof raw[tag] === "string" && raw[tag]) out[tag] = raw[tag] as string;
|
|
357
|
+
return Object.keys(out).length ? out : undefined;
|
|
358
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import type { ResolvedAuthor, ResolvedPost, RichTextResolved } from "@bettercms-ai/types";
|
|
2
|
+
import { PT_TYPES } from "./types";
|
|
3
|
+
import { safeHref } from "./href";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* RT8: the two blocks that point at entries. An author bio stores its author as a reference;
|
|
7
|
+
* related posts store a title, a mode, a count and (hand-picked) references. What a reader sees
|
|
8
|
+
* of the entries (names, titles, urls, covers, bios) is derived, and NEVER stored (G-RT8): the
|
|
9
|
+
* caller resolves it whenever a page is produced (delivery, the hosted apply, a draft preview) and
|
|
10
|
+
* passes `RichTextResolved` in. The renderers never fetch. Three branches, the same on both:
|
|
11
|
+
* - no `resolved` at all: a placeholder aside carrying only what is stored, so a stored html
|
|
12
|
+
* cache stays a pure function of the value and reads back exactly;
|
|
13
|
+
* - resolved, nothing to show (no post resolves, the author is missing): nothing;
|
|
14
|
+
* - resolved: V2's aside, filled.
|
|
15
|
+
*/
|
|
16
|
+
export const PT_RELATED_LIMITS = { titleChars: 40, minCount: 1, maxCount: 6, posts: 6 } as const;
|
|
17
|
+
export const RELATED_MODES = ["topic", "newest", "manual"] as const;
|
|
18
|
+
export type RelatedMode = (typeof RELATED_MODES)[number];
|
|
19
|
+
|
|
20
|
+
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
|
21
|
+
!!v && typeof v === "object" && !Array.isArray(v);
|
|
22
|
+
const refOf = (v: unknown): string => (isRecord(v) && typeof v._ref === "string" ? v._ref : "");
|
|
23
|
+
|
|
24
|
+
/** The same widened check a rich-text image gets: http(s), or a site-relative path. */
|
|
25
|
+
const imageSrc = (raw: string): string | null =>
|
|
26
|
+
safeHref(raw) ?? (raw !== "" && !raw.startsWith("//") && !/[:\s]/.test(raw) ? raw : null);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* What a reader sees of a related-posts block: its title, mode and count, the hand-picked ids
|
|
30
|
+
* (V2's `data-posts`), and the posts to list, in order: the block's own `posts` when manual,
|
|
31
|
+
* else the ids the server chose (`resolved.related[_key]`), at most `count`. An id the server
|
|
32
|
+
* could not resolve (deleted, unpublished) is skipped, and so is a post whose url is refused.
|
|
33
|
+
*/
|
|
34
|
+
export function relatedPostsView(value: unknown, resolved: RichTextResolved = {}) {
|
|
35
|
+
const v = isRecord(value) ? value : {};
|
|
36
|
+
const title = typeof v.title === "string" ? v.title.trim() : "";
|
|
37
|
+
const mode: RelatedMode = v.mode === "manual" || v.mode === "newest" ? v.mode : "topic";
|
|
38
|
+
const n = typeof v.count === "number" && Number.isInteger(v.count) ? v.count : 3;
|
|
39
|
+
const count = Math.min(PT_RELATED_LIMITS.maxCount, Math.max(PT_RELATED_LIMITS.minCount, n));
|
|
40
|
+
const picked = (Array.isArray(v.posts) ? v.posts : []).map(refOf).filter(Boolean);
|
|
41
|
+
const key = typeof v._key === "string" ? v._key : "";
|
|
42
|
+
const ids = mode === "manual" ? picked : (resolved.related?.[key] ?? []).slice(0, count);
|
|
43
|
+
const items: (ResolvedPost & { id: string })[] = [];
|
|
44
|
+
for (const id of ids) {
|
|
45
|
+
const post = resolved.posts?.[id];
|
|
46
|
+
const url = post && typeof post.url === "string" ? safeHref(post.url) : null;
|
|
47
|
+
if (post && typeof post.title === "string" && url) items.push({ ...post, id, url });
|
|
48
|
+
}
|
|
49
|
+
return { key, title, mode, count, picked, items };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** What a reader sees of an author bio: the referenced id, and the author when the server resolved one. */
|
|
53
|
+
export function authorBioView(value: unknown, resolved: RichTextResolved = {}): { key: string; id: string; author: (ResolvedAuthor & { avatar?: string }) | null } {
|
|
54
|
+
const key = isRecord(value) && typeof value._key === "string" ? value._key : "";
|
|
55
|
+
const id = refOf(isRecord(value) ? value.author : undefined);
|
|
56
|
+
const a = id ? resolved.authors?.[id] : undefined;
|
|
57
|
+
if (!a || typeof a.name !== "string") return { key, id, author: null };
|
|
58
|
+
const avatar = typeof a.avatar === "string" ? imageSrc(a.avatar.trim()) : null;
|
|
59
|
+
return {
|
|
60
|
+
key,
|
|
61
|
+
id,
|
|
62
|
+
author: {
|
|
63
|
+
name: a.name,
|
|
64
|
+
...(avatar ? { avatar } : {}),
|
|
65
|
+
...(typeof a.bio === "string" && a.bio.trim() ? { bio: a.bio.trim() } : {}),
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** One reason a stored author bio or related-posts block is refused, with its block key. */
|
|
71
|
+
export type PortableTextReferenceProblem = { block: string; path: Array<string | number>; code: string; message: string };
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Every reason a value's author bios and related posts cannot be stored, by block and path:
|
|
75
|
+
* `author_bio_invalid`, `related_posts_invalid` (a shape problem, a duplicated pick) or
|
|
76
|
+
* `related_posts_limit_exceeded` (V2's 40-character title, 1 to 6, at most 6 picks). As with key
|
|
77
|
+
* takeaways, V2's required author and its "at least one pick" are the editor's, not the write's:
|
|
78
|
+
* an author still choosing must be able to autosave. Derived keys are refused by R8, by name.
|
|
79
|
+
*/
|
|
80
|
+
export function portableTextReferenceProblems(value: unknown): PortableTextReferenceProblem[] {
|
|
81
|
+
const out: PortableTextReferenceProblem[] = [];
|
|
82
|
+
for (const node of Array.isArray(value) ? value : []) {
|
|
83
|
+
if (!isRecord(node)) continue;
|
|
84
|
+
const block = typeof node._key === "string" ? node._key : "";
|
|
85
|
+
const bad = (path: Array<string | number>, code: string, message: string) => out.push({ block, path, code, message: `${code}: ${message}` });
|
|
86
|
+
if (node._type === PT_TYPES.authorBio) {
|
|
87
|
+
// name, avatar and bio are derived: R8 refuses them by name.
|
|
88
|
+
for (const k of Object.keys(node)) if (!["_type", "_key", "author", "name", "avatar", "bio", "resolved"].includes(k)) bad([k], "author_bio_invalid", `unknown key "${k}" (author)`);
|
|
89
|
+
if (node.author !== undefined && (!isRecord(node.author) || typeof node.author._ref !== "string" || !node.author._ref)) {
|
|
90
|
+
bad(["author"], "author_bio_invalid", "author must be a reference { _ref }");
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (node._type === PT_TYPES.relatedPosts) {
|
|
94
|
+
const keys = ["_type", "_key", "title", "mode", "count", "posts", "resolved"];
|
|
95
|
+
for (const k of Object.keys(node)) if (!keys.includes(k)) bad([k], "related_posts_invalid", `unknown key "${k}" (title, mode, count, posts)`);
|
|
96
|
+
if (node.title !== undefined) {
|
|
97
|
+
if (typeof node.title !== "string") bad(["title"], "related_posts_invalid", "title must be a string");
|
|
98
|
+
else if (node.title.length > PT_RELATED_LIMITS.titleChars) {
|
|
99
|
+
bad(["title"], "related_posts_limit_exceeded", `title is ${node.title.length} characters; at most ${PT_RELATED_LIMITS.titleChars}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (!(RELATED_MODES as readonly unknown[]).includes(node.mode)) bad(["mode"], "related_posts_invalid", 'mode must be "topic", "newest" or "manual"');
|
|
103
|
+
if (typeof node.count !== "number" || !Number.isInteger(node.count) || node.count < PT_RELATED_LIMITS.minCount || node.count > PT_RELATED_LIMITS.maxCount) {
|
|
104
|
+
bad(["count"], "related_posts_limit_exceeded", `count must be a whole number from ${PT_RELATED_LIMITS.minCount} to ${PT_RELATED_LIMITS.maxCount}`);
|
|
105
|
+
}
|
|
106
|
+
// Absent is no picks (an automatic block needs none).
|
|
107
|
+
if (node.posts === undefined) continue;
|
|
108
|
+
if (!Array.isArray(node.posts)) {
|
|
109
|
+
bad(["posts"], "related_posts_invalid", "posts must be an array of references");
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
const seen = new Set<string>();
|
|
113
|
+
node.posts.forEach((p, i) => {
|
|
114
|
+
const ref = refOf(p);
|
|
115
|
+
if (!ref || !isRecord(p) || typeof p._key !== "string") return bad(["posts", i], "related_posts_invalid", "a post is { _key, _ref }");
|
|
116
|
+
// A post's title, slug, url, cover and category are derived: R8 refuses them by name.
|
|
117
|
+
for (const k of Object.keys(p)) if (!["_key", "_ref", "title", "slug", "url", "cover", "category"].includes(k)) bad(["posts", i, k], "related_posts_invalid", `unknown key "${k}" (_ref)`);
|
|
118
|
+
if (seen.has(ref)) bad(["posts", i], "related_posts_invalid", `"${ref}" is picked twice`);
|
|
119
|
+
seen.add(ref);
|
|
120
|
+
});
|
|
121
|
+
if (node.posts.length > PT_RELATED_LIMITS.posts) {
|
|
122
|
+
bad(["posts"], "related_posts_limit_exceeded", `${node.posts.length} posts; at most ${PT_RELATED_LIMITS.posts}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* `relatedPosts.no_topic_field`: a `topic` block in a collection with no topic field has nothing to
|
|
131
|
+
* rank by, so it is refused by name rather than quietly listing nothing. The caller knows the
|
|
132
|
+
* collection's config and says whether it has one.
|
|
133
|
+
*/
|
|
134
|
+
export function relatedPostsTopicProblems(value: unknown, opts: { topicField: boolean }): PortableTextReferenceProblem[] {
|
|
135
|
+
if (opts.topicField) return [];
|
|
136
|
+
const out: PortableTextReferenceProblem[] = [];
|
|
137
|
+
for (const node of Array.isArray(value) ? value : []) {
|
|
138
|
+
if (!isRecord(node) || node._type !== PT_TYPES.relatedPosts || node.mode !== "topic") continue;
|
|
139
|
+
out.push({
|
|
140
|
+
block: typeof node._key === "string" ? node._key : "",
|
|
141
|
+
path: ["mode"],
|
|
142
|
+
code: "relatedPosts.no_topic_field",
|
|
143
|
+
message: "relatedPosts.no_topic_field: the collection has no topic field to rank related posts by; choose newest or hand-picked",
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
package/src/schema.ts
CHANGED
|
@@ -111,6 +111,9 @@ export function ptSchemaFor(accept: readonly AcceptElement[]): PtSchema {
|
|
|
111
111
|
fields: [
|
|
112
112
|
{ name: "href", type: "string" },
|
|
113
113
|
{ name: "target", type: "string" },
|
|
114
|
+
// The author's link-type tokens (V2's link form: nofollow, sponsored, ugc); a new tab's
|
|
115
|
+
// noopener/noreferrer are the renderer's and are not stored (href.ts `readLinkRel`).
|
|
116
|
+
{ name: "rel", type: "string" },
|
|
114
117
|
// The site's own class on the `<a>`, kept so a styled link round-trips.
|
|
115
118
|
{ name: "className", type: "string" },
|
|
116
119
|
// RT2: V2's button is a link the author marked as one (`<a data-button="true">`), edited as
|
|
@@ -248,6 +251,33 @@ export function ptSchemaFor(accept: readonly AcceptElement[]): PtSchema {
|
|
|
248
251
|
],
|
|
249
252
|
});
|
|
250
253
|
}
|
|
254
|
+
// RT7: columns of text; the generic block above still carries a legacy `builtin:columns`.
|
|
255
|
+
if (has("columns")) {
|
|
256
|
+
blockObjects.push({
|
|
257
|
+
name: PT_TYPES.columns,
|
|
258
|
+
title: "Columns",
|
|
259
|
+
fields: [
|
|
260
|
+
{ name: "gap", type: "string" },
|
|
261
|
+
{ name: "columns", type: "object" },
|
|
262
|
+
],
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
// RT8: the two reference blocks; what they point at is resolved at render (references.ts).
|
|
266
|
+
if (has("authorBio")) {
|
|
267
|
+
blockObjects.push({ name: PT_TYPES.authorBio, title: "Author bio", fields: [{ name: "author", type: "object" }] });
|
|
268
|
+
}
|
|
269
|
+
if (has("relatedPosts")) {
|
|
270
|
+
blockObjects.push({
|
|
271
|
+
name: PT_TYPES.relatedPosts,
|
|
272
|
+
title: "Related posts",
|
|
273
|
+
fields: [
|
|
274
|
+
{ name: "title", type: "string" },
|
|
275
|
+
{ name: "mode", type: "string" },
|
|
276
|
+
{ name: "count", type: "number" },
|
|
277
|
+
{ name: "posts", type: "object" },
|
|
278
|
+
],
|
|
279
|
+
});
|
|
280
|
+
}
|
|
251
281
|
// RT5: the table of contents; its outline is derived from the headings (toc.ts).
|
|
252
282
|
if (has("tableOfContents")) {
|
|
253
283
|
blockObjects.push({
|
|
@@ -284,6 +314,8 @@ export function ptSchemaFor(accept: readonly AcceptElement[]): PtSchema {
|
|
|
284
314
|
// content element, and a stored mention must survive in a field that never offered one.
|
|
285
315
|
inlineObjects: [
|
|
286
316
|
{ name: PT_TYPES.mention, title: "Mention", fields: [{ name: "label", type: "string" }] },
|
|
317
|
+
// RT6: a footnote stores only its note; its number and the end list are derived (footnote.ts).
|
|
318
|
+
...(has("footnotes") ? [{ name: PT_TYPES.footnote, title: "Footnote", fields: [{ name: "note", type: "string" as const }] }] : []),
|
|
287
319
|
],
|
|
288
320
|
};
|
|
289
321
|
}
|