@bettercms-ai/richtext 0.12.1 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -1
- package/src/accept.ts +59 -10
- package/src/embed.ts +160 -0
- package/src/features.ts +6 -23
- package/src/html.ts +85 -8
- package/src/index.ts +2 -0
- package/src/ingest.ts +115 -0
- package/src/schema.ts +16 -1
- package/src/types.ts +27 -0
- package/src/write-refusals.ts +106 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bettercms-ai/richtext",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"description": "The one Portable Text contract for BetterCMS: schema, converters, and rendering. Shared by the backend, the dashboard and every delivery SDK.",
|
|
@@ -26,11 +26,13 @@
|
|
|
26
26
|
"test:watch": "vitest"
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
|
+
"@bettercms-ai/types": "^1.54.0",
|
|
29
30
|
"@portabletext/to-html": "^6.0.0",
|
|
30
31
|
"@portabletext/html": "^2.0.0",
|
|
31
32
|
"@portabletext/schema": "^3.0.0"
|
|
32
33
|
},
|
|
33
34
|
"devDependencies": {
|
|
35
|
+
"fast-check": "^4.10.2",
|
|
34
36
|
"tsup": "^8.5.1",
|
|
35
37
|
"vitest": "^4.1.4"
|
|
36
38
|
},
|
package/src/accept.ts
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
import {
|
|
2
|
+
RICHTEXT_ADVANCED_ELEMENTS,
|
|
3
|
+
RICHTEXT_BLOCK_ELEMENTS,
|
|
4
|
+
RICHTEXT_CORE_ELEMENTS,
|
|
5
|
+
RICHTEXT_ELEMENTS,
|
|
6
|
+
normaliseRichTextFeatures,
|
|
7
|
+
type RichTextFeature as FeatureName,
|
|
8
|
+
} from '@bettercms-ai/types'
|
|
1
9
|
import { RICH_TEXT_CAPABILITY_MANIFEST, type RichTextFeature } from './features'
|
|
2
10
|
import type { AllowEntry } from './features'
|
|
3
11
|
import { STYLED_TAGS } from './styled'
|
|
@@ -12,21 +20,23 @@ import { STYLED_TAGS } from './styled'
|
|
|
12
20
|
* moment content arrives by paste, from the AI hook, or from before the list was narrowed.
|
|
13
21
|
*/
|
|
14
22
|
|
|
15
|
-
/** The elements Prismic exposes — the default-visible set. */
|
|
16
|
-
export const CORE_ELEMENTS =
|
|
17
|
-
'p', 'pre', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
|
18
|
-
'b', 'i', 'link', 'image', 'code', 'ul', 'ol', 'rtl',
|
|
19
|
-
] as const
|
|
23
|
+
/** The elements Prismic exposes — the default-visible set. (Names: @bettercms-ai/types.) */
|
|
24
|
+
export const CORE_ELEMENTS = RICHTEXT_CORE_ELEMENTS
|
|
20
25
|
|
|
21
26
|
/**
|
|
22
27
|
* What this repo can do beyond Prismic. Behind "Advanced" so the panel reads like the spec at
|
|
23
28
|
* rest, but present so enabling them is not a capability deletion.
|
|
24
29
|
*/
|
|
25
|
-
export const ADVANCED_ELEMENTS =
|
|
26
|
-
'u', 'mark', 's', 'checklist', 'table', 'columns', 'embed', 'internalLink',
|
|
27
|
-
] as const
|
|
30
|
+
export const ADVANCED_ELEMENTS = RICHTEXT_ADVANCED_ELEMENTS
|
|
28
31
|
|
|
29
|
-
|
|
32
|
+
/**
|
|
33
|
+
* The V2 blocks' elements. Each is reachable from its feature through `acceptFor`, but none is in
|
|
34
|
+
* the panel, the defaults or the sanitiser until its RT step ships the renderer and the ingest:
|
|
35
|
+
* a toggle with nothing behind it is the fake button the owner rule forbids.
|
|
36
|
+
*/
|
|
37
|
+
export const BLOCK_ELEMENTS = RICHTEXT_BLOCK_ELEMENTS
|
|
38
|
+
|
|
39
|
+
export const ALL_ELEMENTS = RICHTEXT_ELEMENTS
|
|
30
40
|
export type AcceptElement = (typeof ALL_ELEMENTS)[number]
|
|
31
41
|
|
|
32
42
|
/** Labels name the CAPABILITY, never the key — an author reads "Headings", not `accept.h1`. */
|
|
@@ -37,6 +47,8 @@ export const ELEMENT_LABELS: Record<AcceptElement, string> = {
|
|
|
37
47
|
ul: 'Bulleted list', ol: 'Numbered list', rtl: 'Right-to-left',
|
|
38
48
|
u: 'Underline', mark: 'Highlight', s: 'Strikethrough', checklist: 'Checklist',
|
|
39
49
|
table: 'Table', columns: 'Columns', embed: 'Video embed', internalLink: 'Internal link',
|
|
50
|
+
gallery: 'Gallery', providerEmbed: 'Embed', tableOfContents: 'Table of contents', relatedPosts: 'Related posts',
|
|
51
|
+
authorBio: 'Author bio', footnotes: 'Footnotes', button: 'Button',
|
|
40
52
|
}
|
|
41
53
|
|
|
42
54
|
/**
|
|
@@ -76,6 +88,14 @@ const ELEMENT_TAGS: Record<AcceptElement, string[]> = {
|
|
|
76
88
|
checklist: ['ul', 'li', 'input'], table: ['table', 'thead', 'tbody', 'tr', 'th', 'td'],
|
|
77
89
|
columns: ['section', 'div', 'p', 'br'],
|
|
78
90
|
embed: ['figure', 'iframe', 'video', 'source', 'figcaption'],
|
|
91
|
+
// None until its RT step teaches the ingest the block's markup: a tag the sanitiser lets through
|
|
92
|
+
// and the ingest cannot read is content an author loses on save.
|
|
93
|
+
// RT3: the embed's figure and what it draws. Never `script`: X's widget script is written by the
|
|
94
|
+
// renderer for a site that opted in, and no submitted markup may bring one.
|
|
95
|
+
providerEmbed: ['figure', 'iframe', 'audio', 'a', 'blockquote', 'p', 'footer'],
|
|
96
|
+
gallery: [], tableOfContents: [], relatedPosts: [], authorBio: [], footnotes: [],
|
|
97
|
+
// RT2: a button is an `<a data-button>`; the ingest reads it back (buttonLinkRule).
|
|
98
|
+
button: ['a'],
|
|
79
99
|
}
|
|
80
100
|
|
|
81
101
|
/**
|
|
@@ -92,7 +112,7 @@ export const HTML_BACKED_UNAVAILABLE_ACCEPT_REASONS: Partial<Record<AcceptElemen
|
|
|
92
112
|
columns: 'Embedded blocks are available in entry rich text; component rich text stores HTML.',
|
|
93
113
|
}
|
|
94
114
|
|
|
95
|
-
const NEW_AUTHORING_EXCLUSIONS = new Set<AcceptElement>(['internalLink', 'rtl'])
|
|
115
|
+
const NEW_AUTHORING_EXCLUSIONS = new Set<AcceptElement>(['internalLink', 'rtl', ...BLOCK_ELEMENTS])
|
|
96
116
|
|
|
97
117
|
/** New defaults contain every capability whose complete consumer pipeline is currently live. */
|
|
98
118
|
export const DEFAULT_ACCEPT: AcceptElement[] = ALL_ELEMENTS.filter(
|
|
@@ -122,6 +142,35 @@ export const DEFAULT_RICHTEXT_CONFIG: RichTextConfig = {
|
|
|
122
142
|
allowMultipleParagraphs: true,
|
|
123
143
|
}
|
|
124
144
|
|
|
145
|
+
/**
|
|
146
|
+
* Each feature → the elements it grants. Total over the vocabulary (`satisfies` fails the build on
|
|
147
|
+
* a feature with no row); an alias (`headings`) has none, it is expanded before the lookup. Quote
|
|
148
|
+
* and divider grant nothing: `p` carries them, as `featuresFor` has always given both to any field
|
|
149
|
+
* with paragraphs.
|
|
150
|
+
*/
|
|
151
|
+
const FEATURE_ELEMENTS = {
|
|
152
|
+
bold: ['b'], italic: ['i'], underline: ['u'], strikethrough: ['s'], code: ['code'], highlight: ['mark'],
|
|
153
|
+
blockquote: [], horizontalRule: [],
|
|
154
|
+
orderedList: ['ol'], unorderedList: ['ul'], checklist: ['checklist'],
|
|
155
|
+
link: ['link'], internalLink: ['internalLink'], inlineImage: ['image'], videoEmbed: ['embed'],
|
|
156
|
+
codeBlock: ['pre'], table: ['table'], columns: ['columns'],
|
|
157
|
+
h1: ['h1'], h2: ['h2'], h3: ['h3'], h4: ['h4'], h5: ['h5'], h6: ['h6'],
|
|
158
|
+
gallery: ['gallery'], embed: ['providerEmbed'], tableOfContents: ['tableOfContents'], relatedPosts: ['relatedPosts'],
|
|
159
|
+
authorBio: ['authorBio'], footnotes: ['footnotes'], button: ['button'],
|
|
160
|
+
} as const satisfies Record<FeatureName, readonly AcceptElement[]>
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* A field's features → the elements it accepts: the ONE projection the sanitiser, the PT schema
|
|
164
|
+
* (`ptSchemaFor`), the dashboard codec and the kit consume, so `features` is the only stored gate.
|
|
165
|
+
* Aliases are expanded first (`headings` → the six levels) and unknown names grant nothing. `p` is
|
|
166
|
+
* the floor and never a feature, so `[]` is a field of paragraphs.
|
|
167
|
+
*/
|
|
168
|
+
export function acceptFor(features: readonly string[]): AcceptElement[] {
|
|
169
|
+
const granted = new Set<AcceptElement>(['p'])
|
|
170
|
+
for (const f of normaliseRichTextFeatures(features).features) for (const e of FEATURE_ELEMENTS[f]) granted.add(e)
|
|
171
|
+
return ALL_ELEMENTS.filter((e) => granted.has(e))
|
|
172
|
+
}
|
|
173
|
+
|
|
125
174
|
/** The tag allowlist the sanitiser enforces for a given Accept set. */
|
|
126
175
|
export function allowedTags(accept: AcceptElement[]): string[] {
|
|
127
176
|
// `span` carries marks and is never the reason content is stripped; the rest of `STYLED_TAGS`
|
package/src/embed.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RT3: the embed. Stored `{ url, title, aspect }`; everything else is DERIVED from the url at
|
|
3
|
+
* render: the provider, its shape and the frame src (never stored; R8 refuses them on write).
|
|
4
|
+
*
|
|
5
|
+
* The resolver is pure and the frame src comes only from code: each provider's player URL is
|
|
6
|
+
* BUILT from ids parsed out of the link, for a host on this file's allowlist, over https. A link
|
|
7
|
+
* no rule recognises is a link card, never an iframe of whatever it points at.
|
|
8
|
+
*
|
|
9
|
+
* An X post is a card: its words come from an oEmbed fetch the server makes when it builds the
|
|
10
|
+
* html cache (never stored in the value). Without that data, it is a link card.
|
|
11
|
+
*/
|
|
12
|
+
import { resolveVideoSource } from "./video";
|
|
13
|
+
import { safeHref } from "./href";
|
|
14
|
+
|
|
15
|
+
export const EMBED_ASPECTS = ["16:9", "4:3", "1:1", "9:16"] as const;
|
|
16
|
+
export type EmbedAspect = (typeof EMBED_ASPECTS)[number];
|
|
17
|
+
export const isEmbedAspect = (v: unknown): v is EmbedAspect => (EMBED_ASPECTS as readonly unknown[]).includes(v);
|
|
18
|
+
/** V2's title cap: screen-reader only, never shown. */
|
|
19
|
+
export const EMBED_TITLE_MAX = 120;
|
|
20
|
+
|
|
21
|
+
export type EmbedProvider = "youtube" | "vimeo" | "x" | "codepen" | "figma" | "maps" | "spotify" | "podcast" | "audio" | "link";
|
|
22
|
+
|
|
23
|
+
/** V2's detectEmbed (kit rich-text article.ts), verbatim: which provider a link is, and its label. */
|
|
24
|
+
export function detectEmbed(url: string): { provider: EmbedProvider; label: string; host: string } {
|
|
25
|
+
const u = url.trim();
|
|
26
|
+
let host = "";
|
|
27
|
+
try {
|
|
28
|
+
host = new URL(u).hostname.replace(/^www\./, "");
|
|
29
|
+
} catch {
|
|
30
|
+
host = "";
|
|
31
|
+
}
|
|
32
|
+
const is = (re: RegExp) => re.test(u);
|
|
33
|
+
if (is(/(?:youtube\.com\/(?:watch\?|embed\/|shorts\/|live\/)|youtu\.be\/)/i)) return { provider: "youtube", label: "YouTube", host };
|
|
34
|
+
if (is(/vimeo\.com\/(?:video\/)?\d+/i)) return { provider: "vimeo", label: "Vimeo", host };
|
|
35
|
+
if (is(/(?:twitter|x)\.com\/[^/]+\/status\/\d+/i)) return { provider: "x", label: "X post", host };
|
|
36
|
+
if (is(/codepen\.io\/[^/]+\/(?:pen|embed)\//i)) return { provider: "codepen", label: "CodePen", host };
|
|
37
|
+
if (is(/figma\.com\/(?:file|design|proto|board)\//i)) return { provider: "figma", label: "Figma", host };
|
|
38
|
+
if (is(/(?:google\.[a-z.]+\/maps|maps\.app\.goo\.gl|goo\.gl\/maps)/i)) return { provider: "maps", label: "Google Maps", host };
|
|
39
|
+
if (is(/open\.spotify\.com\/(?:episode|track|show|playlist|album)\//i)) return { provider: "spotify", label: "Spotify", host };
|
|
40
|
+
if (is(/podcasts\.apple\.com\//i)) return { provider: "podcast", label: "Apple Podcasts", host };
|
|
41
|
+
if (is(/\.(?:mp3|m4a|wav|ogg|aac)(?:\?|$)/i)) return { provider: "audio", label: "Audio", host };
|
|
42
|
+
return { provider: "link", label: host || "Link", host };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** How an embed renders, all of it derived from the stored url. */
|
|
46
|
+
export type ResolvedEmbed =
|
|
47
|
+
| { kind: "frame"; provider: EmbedProvider; label: string; src: string; width: string; height: string }
|
|
48
|
+
| { kind: "audio"; provider: "audio"; label: string; src: string }
|
|
49
|
+
| { kind: "card"; provider: "x"; label: string; url: string }
|
|
50
|
+
| { kind: "link"; provider: EmbedProvider; label: string; url: string | null };
|
|
51
|
+
|
|
52
|
+
/** A video frame's default box per aspect: the size it has with no stylesheet at all. */
|
|
53
|
+
const FRAME_SIZE: Record<EmbedAspect, [string, string]> = {
|
|
54
|
+
"16:9": ["640", "360"],
|
|
55
|
+
"4:3": ["640", "480"],
|
|
56
|
+
"1:1": ["640", "640"],
|
|
57
|
+
"9:16": ["360", "640"],
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const ID = /^[A-Za-z0-9_-]{1,64}$/;
|
|
61
|
+
|
|
62
|
+
/** The provider's player url, built from the parsed link, or null when the link is not one we can frame. */
|
|
63
|
+
function frameSrc(provider: EmbedProvider, url: URL): string | null {
|
|
64
|
+
const parts = url.pathname.split("/").filter(Boolean);
|
|
65
|
+
switch (provider) {
|
|
66
|
+
case "youtube":
|
|
67
|
+
case "vimeo": {
|
|
68
|
+
// The video block's resolver: one place knows YouTube's and Vimeo's player urls.
|
|
69
|
+
const video = resolveVideoSource(url.href);
|
|
70
|
+
return video?.kind === "iframe" ? video.src : null;
|
|
71
|
+
}
|
|
72
|
+
case "codepen": {
|
|
73
|
+
const [user, mode, id] = parts;
|
|
74
|
+
return user && ID.test(user) && (mode === "pen" || mode === "embed") && id && ID.test(id)
|
|
75
|
+
? `https://codepen.io/${user}/embed/${id}?default-tab=result`
|
|
76
|
+
: null;
|
|
77
|
+
}
|
|
78
|
+
case "figma": {
|
|
79
|
+
const [mode, key] = parts;
|
|
80
|
+
if (!mode || !["file", "design", "proto", "board"].includes(mode) || !key || !ID.test(key)) return null;
|
|
81
|
+
const frame = new URL(`https://embed.figma.com/${mode}/${key}`);
|
|
82
|
+
const node = url.searchParams.get("node-id");
|
|
83
|
+
if (node && /^[0-9A-Za-z:_-]{1,64}$/.test(node)) frame.searchParams.set("node-id", node);
|
|
84
|
+
frame.searchParams.set("embed-host", "bettercms");
|
|
85
|
+
return frame.href;
|
|
86
|
+
}
|
|
87
|
+
case "maps": {
|
|
88
|
+
if (!/^(?:www\.)?google\.[a-z.]+$/i.test(url.hostname)) return null; // a short link needs a fetch to resolve
|
|
89
|
+
const at = /@(-?\d{1,3}(?:\.\d+)?),(-?\d{1,3}(?:\.\d+)?)/.exec(url.pathname);
|
|
90
|
+
const place = parts[0] === "maps" && parts[1] === "place" && parts[2] ? decodeURIComponent(parts[2]).replace(/\+/g, " ") : null;
|
|
91
|
+
const q = at ? `${at[1]},${at[2]}` : (url.searchParams.get("q") ?? place);
|
|
92
|
+
if (!q || q.length > 200) return null;
|
|
93
|
+
return `https://maps.google.com/maps?q=${encodeURIComponent(q)}&output=embed`;
|
|
94
|
+
}
|
|
95
|
+
case "spotify": {
|
|
96
|
+
const [type, id] = parts;
|
|
97
|
+
return type && ["episode", "track", "show", "playlist", "album"].includes(type) && id && ID.test(id)
|
|
98
|
+
? `https://open.spotify.com/embed/${type}/${id}`
|
|
99
|
+
: null;
|
|
100
|
+
}
|
|
101
|
+
case "podcast":
|
|
102
|
+
return url.hostname === "podcasts.apple.com" && parts.length >= 2 ? `https://embed.podcasts.apple.com${url.pathname}${url.search}` : null;
|
|
103
|
+
default:
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The audio-shaped players' height: one row for an item, a list for a show or a playlist. */
|
|
109
|
+
const audioHeight = (provider: EmbedProvider, url: URL): string => {
|
|
110
|
+
if (provider === "spotify") return /\/(?:track|episode)\//.test(url.pathname) ? "152" : "352";
|
|
111
|
+
return url.searchParams.has("i") ? "175" : "450";
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The embed's rendering, derived from its stored url and aspect. Pure. An unsafe url is a link
|
|
116
|
+
* card with no href; one no rule recognises (or a short link) is a link card to itself.
|
|
117
|
+
*/
|
|
118
|
+
export function resolveEmbed(rawUrl: unknown, aspect: unknown = "16:9"): ResolvedEmbed {
|
|
119
|
+
const url = typeof rawUrl === "string" ? rawUrl.trim() : "";
|
|
120
|
+
const { provider, label } = detectEmbed(url);
|
|
121
|
+
const safe = url ? safeHref(url) : null;
|
|
122
|
+
let parsed: URL | null = null;
|
|
123
|
+
try {
|
|
124
|
+
parsed = safe && /^https?:\/\//i.test(safe) ? new URL(safe) : null;
|
|
125
|
+
} catch {
|
|
126
|
+
parsed = null;
|
|
127
|
+
}
|
|
128
|
+
const link: ResolvedEmbed = { kind: "link", provider, label, url: safe };
|
|
129
|
+
if (!parsed) return link;
|
|
130
|
+
if (provider === "x") return { kind: "card", provider: "x", label, url: parsed.href };
|
|
131
|
+
if (provider === "audio") return parsed.protocol === "https:" ? { kind: "audio", provider: "audio", label, src: parsed.href } : link;
|
|
132
|
+
const src = frameSrc(provider, parsed);
|
|
133
|
+
if (!src) return link;
|
|
134
|
+
if (provider === "spotify" || provider === "podcast") return { kind: "frame", provider, label, src, width: "100%", height: audioHeight(provider, parsed) };
|
|
135
|
+
const [width, height] = FRAME_SIZE[isEmbedAspect(aspect) ? aspect : "16:9"];
|
|
136
|
+
return { kind: "frame", provider, label, src, width, height };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* An X post's card data, fetched by the server (oEmbed) when it builds the html cache: plain
|
|
141
|
+
* strings, escaped on the way out, never third-party markup. `text` is the post's words.
|
|
142
|
+
*/
|
|
143
|
+
export interface EmbedCard {
|
|
144
|
+
text: string;
|
|
145
|
+
authorName: string;
|
|
146
|
+
authorUrl?: string;
|
|
147
|
+
/** As X shows it ("Oct 24, 2026"). */
|
|
148
|
+
date?: string;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** What a render needs that the value does not hold: the cards fetched for it, and the site's X choice. */
|
|
152
|
+
export interface EmbedRender {
|
|
153
|
+
/** Card data by the embed's stored url. */
|
|
154
|
+
cards?: Readonly<Record<string, EmbedCard>>;
|
|
155
|
+
/** The site's opt-in to X's own script, which turns the card into X's live widget. Default off. */
|
|
156
|
+
xScript?: boolean;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** X's widget script, loaded only on a site that opted in. */
|
|
160
|
+
export const X_WIDGET_SCRIPT = "https://platform.twitter.com/widgets.js";
|
package/src/features.ts
CHANGED
|
@@ -1,29 +1,12 @@
|
|
|
1
1
|
// ─── Feature Catalog ───────────────────────────────────────────────────────────
|
|
2
|
+
// The vocabulary lives in @bettercms-ai/types (RICHTEXT_FEATURES), the one list richtext, the kit
|
|
3
|
+
// and the backend validation read. These are its Lexical-era groups, unchanged: the Lexical editor
|
|
4
|
+
// still switches every heading level with `headings`, which the vocabulary keeps as an alias only.
|
|
5
|
+
import { RICHTEXT_MARK_FEATURES, RICHTEXT_TOGGLE_FEATURES } from '@bettercms-ai/types'
|
|
2
6
|
|
|
3
|
-
export const ALWAYS_ON_FEATURES =
|
|
4
|
-
'bold',
|
|
5
|
-
'italic',
|
|
6
|
-
'underline',
|
|
7
|
-
'strikethrough',
|
|
8
|
-
'code',
|
|
9
|
-
'highlight',
|
|
10
|
-
] as const
|
|
7
|
+
export const ALWAYS_ON_FEATURES = RICHTEXT_MARK_FEATURES
|
|
11
8
|
|
|
12
|
-
export const TOGGLEABLE_FEATURES = [
|
|
13
|
-
'headings',
|
|
14
|
-
'blockquote',
|
|
15
|
-
'horizontalRule',
|
|
16
|
-
'orderedList',
|
|
17
|
-
'unorderedList',
|
|
18
|
-
'checklist',
|
|
19
|
-
'link',
|
|
20
|
-
'internalLink',
|
|
21
|
-
'inlineImage',
|
|
22
|
-
'videoEmbed',
|
|
23
|
-
'codeBlock',
|
|
24
|
-
'table',
|
|
25
|
-
'columns',
|
|
26
|
-
] as const
|
|
9
|
+
export const TOGGLEABLE_FEATURES = ['headings', ...RICHTEXT_TOGGLE_FEATURES] as const
|
|
27
10
|
|
|
28
11
|
export type ToggleableRichTextFeature = (typeof TOGGLEABLE_FEATURES)[number]
|
|
29
12
|
|
package/src/html.ts
CHANGED
|
@@ -16,6 +16,7 @@ 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 { X_WIDGET_SCRIPT, isEmbedAspect, resolveEmbed, type EmbedCard, type EmbedRender } from "./embed";
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
22
|
* Portable Text → HTML.
|
|
@@ -420,12 +421,58 @@ function keyTakeawaysHtml(value: unknown, classes: HtmlClasses): string {
|
|
|
420
421
|
return `<aside data-rt="takeaways">${title ? `<p data-title>${esc(title)}</p>` : ""}<ul${cls(classes, "ul")}>${items}</ul></aside>`;
|
|
421
422
|
}
|
|
422
423
|
|
|
424
|
+
/**
|
|
425
|
+
* An X post's card, from the oEmbed data the server fetched (plain strings, escaped here; never
|
|
426
|
+
* X's markup). Static by default; with the site's `xScript` it is X's own blockquote and widget
|
|
427
|
+
* script, which turn it into the live post. The `<p>` and the link keep the post readable either way.
|
|
428
|
+
*/
|
|
429
|
+
function xCardHtml(url: string, card: EmbedCard, xScript: boolean, once: { script: boolean }): string {
|
|
430
|
+
const name = esc(card.authorName);
|
|
431
|
+
const date = esc(card.date || "View on X");
|
|
432
|
+
if (xScript) {
|
|
433
|
+
// The script once per render, however many posts: it upgrades every blockquote it finds (React dedupes it too).
|
|
434
|
+
const script = once.script ? "" : `<script async src="${X_WIDGET_SCRIPT}" charset="utf-8"></script>`;
|
|
435
|
+
once.script = true;
|
|
436
|
+
return `<blockquote class="twitter-tweet"><p>${esc(card.text)}</p>— ${name} <a href="${esc(url)}">${date}</a></blockquote>${script}`;
|
|
437
|
+
}
|
|
438
|
+
const authorHref = card.authorUrl ? safeHref(card.authorUrl) : null;
|
|
439
|
+
const author = authorHref ? `<a href="${esc(authorHref)}">${name}</a>` : name;
|
|
440
|
+
return `<blockquote class="bcms-embed-card" cite="${esc(url)}"><p>${esc(card.text)}</p><footer>— ${author} <a href="${esc(url)}">${date}</a></footer></blockquote>`;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* RT3: an embed, V2's `<figure data-rt="embed" data-url data-provider data-aspect>` around what
|
|
445
|
+
* its url resolves to (`resolveEmbed`): the provider's frame, an audio player, an X card, or a
|
|
446
|
+
* link card. `aria-label` carries the stored title, present even when empty, so the ingest reads
|
|
447
|
+
* back exactly what was stored (V2's own markup has the title in the link instead).
|
|
448
|
+
*/
|
|
449
|
+
function embedFigureHtml(value: unknown, embeds: EmbedRender, once: { script: boolean }): string {
|
|
450
|
+
const v = (value ?? {}) as { url?: unknown; title?: unknown; aspect?: unknown };
|
|
451
|
+
const url = typeof v.url === "string" ? v.url.trim() : "";
|
|
452
|
+
const aspect = isEmbedAspect(v.aspect) ? v.aspect : "16:9";
|
|
453
|
+
const title = typeof v.title === "string" ? v.title.trim() : "";
|
|
454
|
+
const r = resolveEmbed(url, aspect);
|
|
455
|
+
const label = title || r.label;
|
|
456
|
+
let body: string;
|
|
457
|
+
if (r.kind === "frame") body = `<iframe src="${esc(r.src)}" title="${esc(label)}" width="${r.width}" height="${r.height}"${IFRAME_ATTRS}></iframe>`;
|
|
458
|
+
else if (r.kind === "audio") body = `<audio controls preload="none" src="${esc(r.src)}" title="${esc(label)}"></audio>`;
|
|
459
|
+
else if (r.kind === "card" && embeds.cards?.[url]) body = xCardHtml(r.url, embeds.cards[url], embeds.xScript === true, once);
|
|
460
|
+
// A link card: no rule frames this url, or it is an X post with no card data for this render.
|
|
461
|
+
else body = r.url ? `<a href="${esc(r.url)}">${esc(label)}</a>` : "";
|
|
462
|
+
return `<figure data-rt="embed" data-url="${esc(url)}" data-provider="${r.provider}" data-aspect="${aspect}" aria-label="${esc(title)}">${body}</figure>`;
|
|
463
|
+
}
|
|
464
|
+
|
|
423
465
|
export interface RenderOptions {
|
|
424
466
|
/**
|
|
425
467
|
* Render a single value with no wrapping `<p>`, for a field whose consumer supplies its own
|
|
426
468
|
* heading or button. Mirrors `allowMultipleParagraphs: false` and the old `toInlineHtml`.
|
|
427
469
|
*/
|
|
428
470
|
inline?: boolean;
|
|
471
|
+
/**
|
|
472
|
+
* RT3: what an embed needs that the value does not hold: the X cards the server fetched for this
|
|
473
|
+
* render (by url), and the site's opt-in to X's script. Absent: every X post is a link card.
|
|
474
|
+
*/
|
|
475
|
+
embeds?: EmbedRender;
|
|
429
476
|
/**
|
|
430
477
|
* The site's own class per tag, for a value derived FROM that site.
|
|
431
478
|
*
|
|
@@ -480,6 +527,17 @@ const bindingAttrs = (base: string, key: unknown): string =>
|
|
|
480
527
|
? ` data-bcms-field="${esc(base)}#${esc(key)}" data-bcms-kind="richtext"`
|
|
481
528
|
: "";
|
|
482
529
|
|
|
530
|
+
/**
|
|
531
|
+
* A checklist item, as V2 stores it (`<li data-checked="true|false">`), with a disabled checkbox in
|
|
532
|
+
* front of the words so a page without the V2 stylesheet still shows the box and its state, and a
|
|
533
|
+
* screen reader announces it. The ingest reads `data-checked` back and skips the box.
|
|
534
|
+
*/
|
|
535
|
+
const checkItemHtml = (classes: HtmlClasses, value: { checked?: unknown }, children: string | undefined, address: string): string => {
|
|
536
|
+
const checked = value.checked === true;
|
|
537
|
+
// `disabled` before `type`: the order React writes an input's attributes in, so the two renderers match (R6).
|
|
538
|
+
return `<li${cls(classes, "li")}${address} data-checked="${checked}"><input disabled type="checkbox"${checked ? " checked" : ""}>${children ?? ""}</li>`;
|
|
539
|
+
};
|
|
540
|
+
|
|
483
541
|
/**
|
|
484
542
|
* `keys` is the set of `_key`s the value actually carries: to-html invents one (`li-0-1`) for an
|
|
485
543
|
* item that has none, and an invented key is an address that resolves to nothing.
|
|
@@ -488,14 +546,28 @@ const buildComponents = (
|
|
|
488
546
|
classes: HtmlClasses = {},
|
|
489
547
|
binding?: { base: string; keys: ReadonlySet<unknown> },
|
|
490
548
|
renderBlock?: RenderOptions["renderBlock"],
|
|
549
|
+
embeds: EmbedRender = {},
|
|
550
|
+
// Fresh per build, and a render builds its components once: what "once per render" counts against.
|
|
551
|
+
once: { script: boolean } = { script: false },
|
|
491
552
|
): Partial<PortableTextHtmlComponents> => ({
|
|
492
553
|
block: tagOverrides(classes, STYLE_TAGS),
|
|
493
|
-
list:
|
|
554
|
+
list: {
|
|
555
|
+
...tagOverrides(classes, { bullet: "ul", number: "ol" }),
|
|
556
|
+
// A checklist is V2's `<ul data-task="true">`, dressed or not: it is not a bullet list.
|
|
557
|
+
check: ({ children }: { children?: string }) => `<ul data-task="true"${cls(classes, "ul")}>${children ?? ""}</ul>`,
|
|
558
|
+
},
|
|
494
559
|
// With a base, every item carries its own address ON the `<li>` — see `bindingBase`.
|
|
495
560
|
listItem: binding
|
|
496
|
-
? ({ value, children }: { value: { _key?: unknown }; children?: string }) =>
|
|
497
|
-
|
|
498
|
-
|
|
561
|
+
? ({ value, children }: { value: { _key?: unknown; listItem?: unknown; checked?: unknown }; children?: string }) => {
|
|
562
|
+
const address = binding.keys.has(value._key) ? bindingAttrs(binding.base, value._key) : "";
|
|
563
|
+
return value.listItem === "check"
|
|
564
|
+
? checkItemHtml(classes, value, children, address)
|
|
565
|
+
: `<li${cls(classes, "li")}${address}>${children ?? ""}</li>`;
|
|
566
|
+
}
|
|
567
|
+
: {
|
|
568
|
+
...tagOverrides(classes, { bullet: "li", number: "li" }),
|
|
569
|
+
check: ({ value, children }) => checkItemHtml(classes, value as { checked?: unknown }, children, ""),
|
|
570
|
+
},
|
|
499
571
|
types: {
|
|
500
572
|
[PT_TYPES.divider]: () => "<hr>",
|
|
501
573
|
[PT_TYPES.table]: ({ value }) => tableHtml(value as PortableTextTable),
|
|
@@ -510,6 +582,7 @@ const buildComponents = (
|
|
|
510
582
|
[PT_TYPES.file]: ({ value }) => fileHtml(value, classes),
|
|
511
583
|
[PT_TYPES.pullQuote]: ({ value }) => pullQuoteHtml(value, classes),
|
|
512
584
|
[PT_TYPES.keyTakeaways]: ({ value }) => keyTakeawaysHtml(value, classes),
|
|
585
|
+
[PT_TYPES.embed]: ({ value }) => embedFigureHtml(value, embeds, once),
|
|
513
586
|
/**
|
|
514
587
|
* A generic embedded block. A block whose payload this serialiser can READ renders as
|
|
515
588
|
* content; one carrying a reference it would have to resolve against a project renders as a
|
|
@@ -557,9 +630,11 @@ const buildComponents = (
|
|
|
557
630
|
* renders the LABEL rather than deleting it — a blocked link must not lose the words.
|
|
558
631
|
*/
|
|
559
632
|
[PT_TYPES.link]: ({ value, children }) => {
|
|
560
|
-
const v = (value ?? {}) as { href?: unknown; target?: unknown; rel?: unknown; className?: unknown };
|
|
633
|
+
const v = (value ?? {}) as { href?: unknown; target?: unknown; rel?: unknown; className?: unknown; button?: unknown };
|
|
561
634
|
const href = typeof v.href === "string" ? safeHref(v.href) : null;
|
|
562
635
|
if (!href) return children;
|
|
636
|
+
// RT2: a button is a link the author marked as one; V2's `data-button` is the site's style hook.
|
|
637
|
+
const button = v.button === true ? ' data-button="true"' : "";
|
|
563
638
|
// `rel` is author data too (the importer keeps `prev`/`next` on pagination links), so only
|
|
564
639
|
// plain link-type tokens pass; a new tab always adds noopener + noreferrer.
|
|
565
640
|
const rel = new Set(
|
|
@@ -573,7 +648,7 @@ const buildComponents = (
|
|
|
573
648
|
const relAttr = rel.size ? ` rel="${esc([...rel].join(" "))}"` : "";
|
|
574
649
|
// The link's own class (kept on ingest) wins over the caller's default: it IS the site.
|
|
575
650
|
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>`;
|
|
651
|
+
return `<a${own || cls(classes, "a")}${button} href="${esc(href)}"${target}${relAttr}>${children}</a>`;
|
|
577
652
|
},
|
|
578
653
|
/**
|
|
579
654
|
* The site's own inline markup, written back out as itself.
|
|
@@ -618,11 +693,13 @@ export function portableTextToHtml(
|
|
|
618
693
|
): string {
|
|
619
694
|
if (!Array.isArray(value) || value.length === 0) return "";
|
|
620
695
|
const components =
|
|
621
|
-
opts.classes || opts.renderBlock
|
|
696
|
+
opts.classes || opts.renderBlock || opts.embeds
|
|
697
|
+
? buildComponents(opts.classes, undefined, opts.renderBlock, opts.embeds)
|
|
698
|
+
: defaultComponents;
|
|
622
699
|
|
|
623
700
|
if (opts.bindingBase) {
|
|
624
701
|
const base = opts.bindingBase;
|
|
625
|
-
const bound = buildComponents(opts.classes, { base, keys: new Set(value.map((n) => n._key)) }, opts.renderBlock);
|
|
702
|
+
const bound = buildComponents(opts.classes, { base, keys: new Set(value.map((n) => n._key)) }, opts.renderBlock, opts.embeds);
|
|
626
703
|
const out: string[] = [];
|
|
627
704
|
for (let i = 0; i < value.length; ) {
|
|
628
705
|
const node = value[i];
|
package/src/index.ts
CHANGED
package/src/ingest.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
import { randomKey, isValidKey, type KeyGenerator } from "./key";
|
|
16
16
|
import { safeHref } from "./href";
|
|
17
17
|
import { BARE_IS_DESIGN, isStyledTag, styledAttrsOf, type StyledMarkDef } from "./styled";
|
|
18
|
+
import { EMBED_TITLE_MAX, isEmbedAspect } from "./embed";
|
|
18
19
|
import { cellTypeOf, columnOf, designOf, isColumnType, isValueType, jsonLdOf, PT_TABLE_LIMITS, typedCellText } from "./table";
|
|
19
20
|
import { PT_KEY_TAKEAWAYS_LIMITS } from "./key-takeaways";
|
|
20
21
|
import type { PtSchema } from "./schema";
|
|
@@ -513,6 +514,115 @@ function styledRule(key: KeyGenerator): DeserializerRule {
|
|
|
513
514
|
};
|
|
514
515
|
}
|
|
515
516
|
|
|
517
|
+
/**
|
|
518
|
+
* The two decorators the renderer writes in markup the standard rules do not read back (R1):
|
|
519
|
+
* - `<mark>`: the deserialiser names it `mark`, the schema calls it `highlight`, so it was
|
|
520
|
+
* dropped on every round trip;
|
|
521
|
+
* - `<span style="text-decoration:underline">`: what `portableTextToHtml` emits for `underline`,
|
|
522
|
+
* which the styled rule then recorded as the site's own design instead of the decorator.
|
|
523
|
+
* Bare only: a classed `<mark>` or a span with anything else on it is still the styled rule's.
|
|
524
|
+
* Gated on the schema, because a custom rule's output is never filtered by it.
|
|
525
|
+
*/
|
|
526
|
+
function ownDecoratorRule(schema: PtSchema | undefined): DeserializerRule {
|
|
527
|
+
const allows = (name: string) => !schema || schema.decorators.some((d) => d.name === name);
|
|
528
|
+
return {
|
|
529
|
+
deserialize(el: Node, next) {
|
|
530
|
+
if (!isElement(el)) return undefined;
|
|
531
|
+
const tag = el.nodeName.toLowerCase();
|
|
532
|
+
const attrs = el.getAttributeNames();
|
|
533
|
+
if (tag === "mark" && attrs.length === 0 && allows("highlight")) {
|
|
534
|
+
return { _type: "__decorator", name: "highlight", children: next(el.childNodes) } as never;
|
|
535
|
+
}
|
|
536
|
+
const style = el.getAttribute("style")?.replace(/\s+/g, "").replace(/;$/, "").toLowerCase();
|
|
537
|
+
if (tag === "span" && attrs.length === 1 && style === "text-decoration:underline" && allows("underline")) {
|
|
538
|
+
return { _type: "__decorator", name: "underline", children: next(el.childNodes) } as never;
|
|
539
|
+
}
|
|
540
|
+
return undefined;
|
|
541
|
+
},
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* A V2 checklist item, `<ul data-task="true"><li data-checked="true|false">`, read as
|
|
547
|
+
* `listItem: "check"` + `checked`; a nested list's depth is added when it is flattened. The box in front of the words (the
|
|
548
|
+
* renderer's disabled checkbox) is skipped: the state is `data-checked`, never the input. Gated on
|
|
549
|
+
* the schema, because a custom rule's output is never filtered by it: a field without the
|
|
550
|
+
* checklist falls through to the standard rule and reads the items as bullets.
|
|
551
|
+
*/
|
|
552
|
+
function checklistItemRule(schema: PtSchema | undefined): DeserializerRule {
|
|
553
|
+
const allowed = !schema || schema.lists.some((l) => l.name === "check");
|
|
554
|
+
return {
|
|
555
|
+
deserialize(el: Node, next) {
|
|
556
|
+
if (!allowed || !isElement(el) || el.nodeName.toLowerCase() !== "li") return undefined;
|
|
557
|
+
const list = el.parentNode;
|
|
558
|
+
if (!list || !isElement(list) || list.nodeName.toLowerCase() !== "ul" || !list.hasAttribute("data-task")) return undefined;
|
|
559
|
+
const words = Array.from(el.childNodes).filter((n) => !(isElement(n) && n.nodeName.toLowerCase() === "input"));
|
|
560
|
+
return {
|
|
561
|
+
_type: "block",
|
|
562
|
+
style: "normal",
|
|
563
|
+
markDefs: [],
|
|
564
|
+
listItem: "check",
|
|
565
|
+
// 1, as the standard list rule writes it: flattening a nested list adds the depth.
|
|
566
|
+
level: 1,
|
|
567
|
+
checked: el.getAttribute("data-checked") === "true",
|
|
568
|
+
children: next(words as never),
|
|
569
|
+
} as never;
|
|
570
|
+
},
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* RT3: `<figure data-rt="embed">` → the `embed` block, the inverse of `embedFigureHtml` and of V2's
|
|
576
|
+
* own `<figure data-rt="embed" data-url data-aspect><a href>title</a></figure>`. Only what is
|
|
577
|
+
* stored is read: the url (`data-url`, else the link's href), the title (`aria-label` when the
|
|
578
|
+
* figure has one, which ours always does, else the link's words, as V2 reads it) and the aspect
|
|
579
|
+
* (V2's four, else 16:9). The frame, the card and `data-provider` are derived and ignored.
|
|
580
|
+
*/
|
|
581
|
+
function embedFigureRule(key: KeyGenerator, schema: PtSchema | undefined): DeserializerRule {
|
|
582
|
+
const allowed = allowsBlockObject(schema, PT_TYPES.embed);
|
|
583
|
+
return {
|
|
584
|
+
deserialize(el: Node, _next, createBlock) {
|
|
585
|
+
if (!allowed || !isElement(el) || el.nodeName.toLowerCase() !== "figure" || el.getAttribute("data-rt") !== "embed") return undefined;
|
|
586
|
+
const link = el.querySelector("a[href]");
|
|
587
|
+
const url = (el.getAttribute("data-url") ?? link?.getAttribute("href") ?? "").trim();
|
|
588
|
+
const title = (el.hasAttribute("aria-label") ? el.getAttribute("aria-label") ?? "" : (link?.textContent ?? "")).trim().slice(0, EMBED_TITLE_MAX);
|
|
589
|
+
const aspect = el.getAttribute("data-aspect");
|
|
590
|
+
return createBlock({ _type: PT_TYPES.embed, _key: key(), url, title, aspect: isEmbedAspect(aspect) ? aspect : "16:9" });
|
|
591
|
+
},
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* RT2: V2's button, `<a data-button="true" href>`, → a `link` annotation with `button: true` (and
|
|
597
|
+
* the site's class, a new tab and `rel`, as the classed link keeps them). Gated on the schema's
|
|
598
|
+
* link annotation having the `button` field: a field without the button feature reads it as the
|
|
599
|
+
* plain link it also is.
|
|
600
|
+
*/
|
|
601
|
+
function buttonLinkRule(key: KeyGenerator, schema: PtSchema | undefined): DeserializerRule {
|
|
602
|
+
const allowed =
|
|
603
|
+
!schema || schema.annotations.some((a) => a.name === PT_TYPES.link && (a.fields ?? []).some((f) => f.name === "button"));
|
|
604
|
+
return {
|
|
605
|
+
deserialize(el: Node, next) {
|
|
606
|
+
if (!allowed || !isElement(el) || el.nodeName.toLowerCase() !== "a") return undefined;
|
|
607
|
+
const flag = el.getAttribute("data-button");
|
|
608
|
+
const href = el.getAttribute("href")?.trim();
|
|
609
|
+
if (flag === null || flag === "false" || !href) return undefined;
|
|
610
|
+
const className = el.getAttribute("class")?.trim();
|
|
611
|
+
const rel = el.getAttribute("rel")?.trim();
|
|
612
|
+
const markDef = {
|
|
613
|
+
_type: PT_TYPES.link,
|
|
614
|
+
_key: key(),
|
|
615
|
+
href,
|
|
616
|
+
button: true,
|
|
617
|
+
...(className ? { className } : {}),
|
|
618
|
+
...(el.getAttribute("target") === "_blank" ? { target: "_blank" } : {}),
|
|
619
|
+
...(rel ? { rel } : {}),
|
|
620
|
+
};
|
|
621
|
+
return { _type: "__annotation", markDef, children: next(el.childNodes) } as never;
|
|
622
|
+
},
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
|
|
516
626
|
/**
|
|
517
627
|
* `<a class>` → a `link` annotation that keeps the site's class as `className`.
|
|
518
628
|
*
|
|
@@ -851,8 +961,13 @@ export function htmlToPortableText(html: string, opts: IngestOptions = {}): Port
|
|
|
851
961
|
...(allowsBlockObject(opts.schema, PT_TYPES.code) ? [codeRule(key)] : []),
|
|
852
962
|
...(allowsBlockObject(opts.schema, PT_TYPES.pullQuote) ? [pullQuoteRule(key)] : []),
|
|
853
963
|
...(allowsBlockObject(opts.schema, PT_TYPES.keyTakeaways) ? [keyTakeawaysRule(key)] : []),
|
|
964
|
+
// Before the media rule, which would take the iframe out of our own embed figure.
|
|
965
|
+
embedFigureRule(key, opts.schema),
|
|
854
966
|
mediaRule(key, opts.schema),
|
|
967
|
+
checklistItemRule(opts.schema),
|
|
968
|
+
ownDecoratorRule(opts.schema),
|
|
855
969
|
...(allowsStyled(opts.schema) ? [styledRule(key)] : []),
|
|
970
|
+
buttonLinkRule(key, opts.schema),
|
|
856
971
|
...(allowsLinks(opts.schema) ? [classedLinkRule(key)] : []),
|
|
857
972
|
],
|
|
858
973
|
keyGenerator: key,
|
package/src/schema.ts
CHANGED
|
@@ -102,7 +102,7 @@ export function ptSchemaFor(accept: readonly AcceptElement[]): PtSchema {
|
|
|
102
102
|
],
|
|
103
103
|
},
|
|
104
104
|
];
|
|
105
|
-
if (has("link") || has("internalLink")) {
|
|
105
|
+
if (has("link") || has("internalLink") || has("button")) {
|
|
106
106
|
annotations.push({
|
|
107
107
|
name: PT_TYPES.link,
|
|
108
108
|
title: "Link",
|
|
@@ -113,6 +113,9 @@ export function ptSchemaFor(accept: readonly AcceptElement[]): PtSchema {
|
|
|
113
113
|
{ name: "target", type: "string" },
|
|
114
114
|
// The site's own class on the `<a>`, kept so a styled link round-trips.
|
|
115
115
|
{ name: "className", type: "string" },
|
|
116
|
+
// RT2: V2's button is a link the author marked as one (`<a data-button="true">`), edited as
|
|
117
|
+
// text in the flow. Only a field with the button feature has the field to set.
|
|
118
|
+
...(has("button") ? [{ name: "button", type: "boolean" as const }] : []),
|
|
116
119
|
],
|
|
117
120
|
});
|
|
118
121
|
}
|
|
@@ -222,6 +225,18 @@ export function ptSchemaFor(accept: readonly AcceptElement[]): PtSchema {
|
|
|
222
225
|
* hold and move it; it does not validate the payload, so a deeper declaration would buy
|
|
223
226
|
* nothing and drift from the types that do.
|
|
224
227
|
*/
|
|
228
|
+
// RT3: the third-party embed; its provider and frame are derived from `url` (embed.ts).
|
|
229
|
+
if (has("providerEmbed")) {
|
|
230
|
+
blockObjects.push({
|
|
231
|
+
name: PT_TYPES.embed,
|
|
232
|
+
title: "Embed",
|
|
233
|
+
fields: [
|
|
234
|
+
{ name: "url", type: "string" },
|
|
235
|
+
{ name: "title", type: "string" },
|
|
236
|
+
{ name: "aspect", type: "string" },
|
|
237
|
+
],
|
|
238
|
+
});
|
|
239
|
+
}
|
|
225
240
|
if (has("table")) {
|
|
226
241
|
blockObjects.push({
|
|
227
242
|
name: PT_TYPES.table,
|
package/src/types.ts
CHANGED
|
@@ -338,6 +338,18 @@ export interface PortableTextKeyTakeaways extends PortableTextObject {
|
|
|
338
338
|
points: string[];
|
|
339
339
|
}
|
|
340
340
|
|
|
341
|
+
/**
|
|
342
|
+
* RT3: an embed, as V2's EmbedModel. Only what the author sets is stored: the link, the
|
|
343
|
+
* screen-reader title (≤ EMBED_TITLE_MAX) and the shape. The provider, its kind and the frame src
|
|
344
|
+
* are derived from `url` at render (`resolveEmbed`), never stored.
|
|
345
|
+
*/
|
|
346
|
+
export interface PortableTextEmbed extends PortableTextObject {
|
|
347
|
+
_type: "embed";
|
|
348
|
+
url: string;
|
|
349
|
+
title: string;
|
|
350
|
+
aspect: "16:9" | "4:3" | "1:1" | "9:16";
|
|
351
|
+
}
|
|
352
|
+
|
|
341
353
|
export const isPortableTextBlock = (n: unknown): n is PortableTextBlock =>
|
|
342
354
|
!!n && typeof n === "object" && (n as PortableTextNode)._type === "block";
|
|
343
355
|
|
|
@@ -389,6 +401,12 @@ export const PT_TYPES = {
|
|
|
389
401
|
pullQuote: "pullQuote",
|
|
390
402
|
/** Key takeaways — see `PortableTextKeyTakeaways`. The V2 kit's `<aside data-rt="takeaways">`. */
|
|
391
403
|
keyTakeaways: "keyTakeaways",
|
|
404
|
+
/**
|
|
405
|
+
* A third-party embed (X, CodePen, Figma, Maps, Spotify, Podcasts, audio, a link card) — see
|
|
406
|
+
* `PortableTextEmbed` and `embed.ts`. The V2 kit's `<figure data-rt="embed">`. Gated by the
|
|
407
|
+
* `embed` feature; YouTube and Vimeo links still resolve here too, through the video resolver.
|
|
408
|
+
*/
|
|
409
|
+
embed: "embed",
|
|
392
410
|
/**
|
|
393
411
|
* A table. Spelled `table`, unprefixed, because that is what every other Portable Text
|
|
394
412
|
* producer calls one — the Sanity-shaped `{_type:"table", rows}` this platform used to refuse
|
|
@@ -446,6 +464,7 @@ export const STORABLE_BLOCK_TYPES: ReadonlySet<string> = new Set<string>([
|
|
|
446
464
|
PT_TYPES.file,
|
|
447
465
|
PT_TYPES.pullQuote,
|
|
448
466
|
PT_TYPES.keyTakeaways,
|
|
467
|
+
PT_TYPES.embed,
|
|
449
468
|
]);
|
|
450
469
|
|
|
451
470
|
/**
|
|
@@ -575,6 +594,14 @@ export function isStorableKeyTakeaways(node: unknown): boolean {
|
|
|
575
594
|
return Array.isArray((node as { points?: unknown }).points);
|
|
576
595
|
}
|
|
577
596
|
|
|
597
|
+
/** An embed holds a link, a title of at most 120 characters, and one of V2's four shapes. */
|
|
598
|
+
export function isStorableEmbed(node: unknown): boolean {
|
|
599
|
+
if (!node || typeof node !== "object" || Array.isArray(node)) return false;
|
|
600
|
+
const n = node as { url?: unknown; title?: unknown; aspect?: unknown };
|
|
601
|
+
return typeof n.url === "string" && typeof n.title === "string" && n.title.length <= 120
|
|
602
|
+
&& (n.aspect === "16:9" || n.aspect === "4:3" || n.aspect === "1:1" || n.aspect === "9:16");
|
|
603
|
+
}
|
|
604
|
+
|
|
578
605
|
/**
|
|
579
606
|
* @deprecated The marker a table-accepting field used to carry INSTEAD of a table.
|
|
580
607
|
*
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a rich-text WRITE may not hold, refused by name and never removed (RT0, properties R4, R8).
|
|
3
|
+
*
|
|
4
|
+
* Both read the value and answer a list. Neither changes it, so a caller that writes after an
|
|
5
|
+
* empty answer writes exactly what it was given: there is no strip step for content to vanish in.
|
|
6
|
+
* Callers that judge a stored value should judge only what the write introduces, as the field-key
|
|
7
|
+
* rules do: values written before a rule existed must keep saving.
|
|
8
|
+
*/
|
|
9
|
+
import { richTextRefusal, type UiRefusal } from "@bettercms-ai/types";
|
|
10
|
+
import { acceptFor } from "./accept";
|
|
11
|
+
import { ptSchemaFor } from "./schema";
|
|
12
|
+
import { isPortableTextBlock, type PortableText, type PortableTextNode } from "./types";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* R4: every node, style, list, mark or annotation a field's features do not accept, judged against
|
|
16
|
+
* `ptSchemaFor(acceptFor(features))`, the same schema the editor offers. One refusal per name:
|
|
17
|
+
* `richtext.block_not_accepted:<name>`, where name is a block object's or inline object's `_type`,
|
|
18
|
+
* a style (`h2`), a list (`check`), a decorator (`strong`) or an annotation's `_type` (`link`).
|
|
19
|
+
*/
|
|
20
|
+
export function notAcceptedProblems(value: PortableText, features: readonly string[]): UiRefusal[] {
|
|
21
|
+
const accepted = acceptFor(features);
|
|
22
|
+
const schema = ptSchemaFor(accepted);
|
|
23
|
+
// One `link` annotation carries both: a plain link needs a link element, a button the button one.
|
|
24
|
+
const linkOk = accepted.includes("link") || accepted.includes("internalLink");
|
|
25
|
+
const buttonOk = accepted.includes("button");
|
|
26
|
+
const names = (list: { name: string }[]) => new Set(list.map((x) => x.name));
|
|
27
|
+
const styles = names(schema.styles);
|
|
28
|
+
const lists = names(schema.lists);
|
|
29
|
+
const decorators = names(schema.decorators);
|
|
30
|
+
const annotations = names(schema.annotations);
|
|
31
|
+
const blockObjects = names(schema.blockObjects);
|
|
32
|
+
const inlineObjects = names(schema.inlineObjects);
|
|
33
|
+
|
|
34
|
+
const out: UiRefusal[] = [];
|
|
35
|
+
const refused = new Set<string>();
|
|
36
|
+
const refuse = (name: string) => {
|
|
37
|
+
if (refused.has(name)) return;
|
|
38
|
+
refused.add(name);
|
|
39
|
+
out.push(richTextRefusal("block_not_accepted", name));
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
for (const node of value) {
|
|
43
|
+
if (!isPortableTextBlock(node)) {
|
|
44
|
+
if (!blockObjects.has(node._type)) refuse(node._type);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (!styles.has(node.style ?? "normal")) refuse(node.style);
|
|
48
|
+
if (node.listItem !== undefined && !lists.has(node.listItem)) refuse(node.listItem);
|
|
49
|
+
const defs = new Set<string>();
|
|
50
|
+
for (const def of node.markDefs ?? []) {
|
|
51
|
+
defs.add(def._key);
|
|
52
|
+
if (def._type === "link") {
|
|
53
|
+
if (def.button === true ? !buttonOk : !linkOk) refuse(def.button === true ? "button" : "link");
|
|
54
|
+
} else if (!annotations.has(def._type)) refuse(def._type);
|
|
55
|
+
}
|
|
56
|
+
for (const child of node.children ?? []) {
|
|
57
|
+
if (child._type !== "span") {
|
|
58
|
+
if (!inlineObjects.has(child._type)) refuse(child._type);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
// A mark is a markDef key (its annotation was judged above) or a decorator name.
|
|
62
|
+
for (const mark of (child as { marks?: string[] }).marks ?? []) if (!defs.has(mark) && !decorators.has(mark)) refuse(mark);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* What each V2 block computes at render time and never stores (kit-richtext-blocks-gap.md, the
|
|
70
|
+
* "Derived" line of each block). `posts[].title` means that key on every entry of `posts`. Each RT
|
|
71
|
+
* step confirms its row when it builds the type; a key outside its row is not judged here.
|
|
72
|
+
*/
|
|
73
|
+
export const DERIVED_FIELDS: Readonly<Record<string, readonly string[]>> = {
|
|
74
|
+
footnote: ["number"],
|
|
75
|
+
embed: ["provider", "shape", "src"],
|
|
76
|
+
columns: ["cols"],
|
|
77
|
+
tableOfContents: ["outline"],
|
|
78
|
+
authorBio: ["name", "avatar", "bio"],
|
|
79
|
+
relatedPosts: ["posts[].title", "posts[].slug", "posts[].url", "posts[].cover", "posts[].category"],
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** Whether a node holds a derived path: `key`, or `list[].key` on any entry of the list. */
|
|
83
|
+
const holds = (node: Record<string, unknown>, path: string): boolean => {
|
|
84
|
+
const [list, key] = path.split("[].");
|
|
85
|
+
if (key === undefined) return Object.hasOwn(node, path);
|
|
86
|
+
const items = node[list];
|
|
87
|
+
return Array.isArray(items) && items.some((item) => item !== null && typeof item === "object" && Object.hasOwn(item, key));
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* R8: every derived key a write carries, top-level nodes and inline children alike, refused as
|
|
92
|
+
* `richtext.derived_field:<_type>.<path>` (`embed.provider`, `relatedPosts.posts[].title`).
|
|
93
|
+
*/
|
|
94
|
+
export function derivedFieldProblems(value: PortableText): UiRefusal[] {
|
|
95
|
+
const out: UiRefusal[] = [];
|
|
96
|
+
const judge = (node: PortableTextNode) => {
|
|
97
|
+
for (const path of DERIVED_FIELDS[node._type] ?? []) {
|
|
98
|
+
if (holds(node as unknown as Record<string, unknown>, path)) out.push(richTextRefusal("derived_field", `${node._type}.${path}`));
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
for (const node of value) {
|
|
102
|
+
judge(node);
|
|
103
|
+
if (isPortableTextBlock(node)) for (const child of node.children ?? []) judge(child);
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|