@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bettercms-ai/richtext",
3
- "version": "0.12.1",
3
+ "version": "0.16.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,14 @@
26
26
  "test:watch": "vitest"
27
27
  },
28
28
  "dependencies": {
29
+ "@bettercms-ai/image-url": "^0.4.1",
30
+ "@bettercms-ai/types": "^1.57.0",
29
31
  "@portabletext/to-html": "^6.0.0",
30
32
  "@portabletext/html": "^2.0.0",
31
33
  "@portabletext/schema": "^3.0.0"
32
34
  },
33
35
  "devDependencies": {
36
+ "fast-check": "^4.10.2",
34
37
  "tsup": "^8.5.1",
35
38
  "vitest": "^4.1.4"
36
39
  },
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
- export const ALL_ELEMENTS = [...CORE_ELEMENTS, ...ADVANCED_ELEMENTS] as const
32
+ /**
33
+ * The V2 blocks' elements (RT2–RT8). Each one has its Portable Text type, both renderers, the
34
+ * ingest and the write check, so each is in the panel, the defaults and the sanitiser like any
35
+ * other capability.
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
  /**
@@ -49,6 +61,8 @@ export const ELEMENT_GROUPS: { id: string; label: string; elements: AcceptElemen
49
61
  // `internalLink` stays in ALL_ELEMENTS for stored-config compatibility, but is intentionally
50
62
  // absent here: new authoring uses the ordinary URL Link until a real internal-entry contract exists.
51
63
  { id: 'insert', label: 'Insert', elements: ['link', 'image', 'table', 'columns', 'embed', 'checklist'] },
64
+ // The V2 article blocks (RT2–RT8), each with its type, renderers, ingest and write check.
65
+ { id: 'blocks', label: 'Article blocks', elements: ['button', 'providerEmbed', 'gallery', 'tableOfContents', 'footnotes', 'relatedPosts', 'authorBio'] },
52
66
  // `rtl` stays in ALL_ELEMENTS for stored-config compatibility. There is no editor command
53
67
  // for it yet, so new schema authoring must not advertise an inert toggle.
54
68
  ]
@@ -76,6 +90,22 @@ const ELEMENT_TAGS: Record<AcceptElement, string[]> = {
76
90
  checklist: ['ul', 'li', 'input'], table: ['table', 'thead', 'tbody', 'tr', 'th', 'td'],
77
91
  columns: ['section', 'div', 'p', 'br'],
78
92
  embed: ['figure', 'iframe', 'video', 'source', 'figcaption'],
93
+ // Each V2 block's tags are exactly what its ingest reads back: a tag the sanitiser lets through
94
+ // and the ingest cannot read is content an author loses on save.
95
+ // RT3: the embed's figure and what it draws. Never `script`: X's widget script is written by the
96
+ // renderer for a site that opted in, and no submitted markup may bring one.
97
+ providerEmbed: ['figure', 'iframe', 'audio', 'a', 'blockquote', 'p', 'footer'],
98
+ // RT4: the gallery's figure, its list and each image's own figure.
99
+ gallery: ['figure', 'ul', 'li', 'img', 'figcaption'],
100
+ // RT5: the contents' nav, its title and its (nested) list of links.
101
+ tableOfContents: ['nav', 'p', 'ol', 'li', 'a'],
102
+ // RT6: a footnote's marker and link, and the end list its notes make.
103
+ footnotes: ['sup', 'a', 'ol', 'li'],
104
+ // RT8: related posts' aside, title and list of links; an author bio's aside, avatar and lines.
105
+ relatedPosts: ['aside', 'p', 'ul', 'li', 'a'],
106
+ authorBio: ['aside', 'img', 'p'],
107
+ // RT2: a button is an `<a data-button>`; the ingest reads it back (buttonLinkRule).
108
+ button: ['a'],
79
109
  }
80
110
 
81
111
  /**
@@ -122,6 +152,35 @@ export const DEFAULT_RICHTEXT_CONFIG: RichTextConfig = {
122
152
  allowMultipleParagraphs: true,
123
153
  }
124
154
 
155
+ /**
156
+ * Each feature → the elements it grants. Total over the vocabulary (`satisfies` fails the build on
157
+ * a feature with no row); an alias (`headings`) has none, it is expanded before the lookup. Quote
158
+ * and divider grant nothing: `p` carries them, as `featuresFor` has always given both to any field
159
+ * with paragraphs.
160
+ */
161
+ const FEATURE_ELEMENTS = {
162
+ bold: ['b'], italic: ['i'], underline: ['u'], strikethrough: ['s'], code: ['code'], highlight: ['mark'],
163
+ blockquote: [], horizontalRule: [],
164
+ orderedList: ['ol'], unorderedList: ['ul'], checklist: ['checklist'],
165
+ link: ['link'], internalLink: ['internalLink'], inlineImage: ['image'], videoEmbed: ['embed'],
166
+ codeBlock: ['pre'], table: ['table'], columns: ['columns'],
167
+ h1: ['h1'], h2: ['h2'], h3: ['h3'], h4: ['h4'], h5: ['h5'], h6: ['h6'],
168
+ gallery: ['gallery'], embed: ['providerEmbed'], tableOfContents: ['tableOfContents'], relatedPosts: ['relatedPosts'],
169
+ authorBio: ['authorBio'], footnotes: ['footnotes'], button: ['button'],
170
+ } as const satisfies Record<FeatureName, readonly AcceptElement[]>
171
+
172
+ /**
173
+ * A field's features → the elements it accepts: the ONE projection the sanitiser, the PT schema
174
+ * (`ptSchemaFor`), the dashboard codec and the kit consume, so `features` is the only stored gate.
175
+ * Aliases are expanded first (`headings` → the six levels) and unknown names grant nothing. `p` is
176
+ * the floor and never a feature, so `[]` is a field of paragraphs.
177
+ */
178
+ export function acceptFor(features: readonly string[]): AcceptElement[] {
179
+ const granted = new Set<AcceptElement>(['p'])
180
+ for (const f of normaliseRichTextFeatures(features).features) for (const e of FEATURE_ELEMENTS[f]) granted.add(e)
181
+ return ALL_ELEMENTS.filter((e) => granted.has(e))
182
+ }
183
+
125
184
  /** The tag allowlist the sanitiser enforces for a given Accept set. */
126
185
  export function allowedTags(accept: AcceptElement[]): string[] {
127
186
  // `span` carries marks and is never the reason content is stripped; the rest of `STYLED_TAGS`
package/src/columns.ts ADDED
@@ -0,0 +1,117 @@
1
+ import { PT_TYPES, type PortableText } from "./types";
2
+
3
+ /**
4
+ * RT7: columns (`PortableTextColumns`, types.ts), the first block that holds Portable Text of its
5
+ * own: `{ gap, columns: [{ _key, content }] }`. A V2 column is an editable run of text, so a
6
+ * column holds text blocks only, and never another block (R5: depth 1). The column count is
7
+ * derived (`data-cols`), never stored.
8
+ *
9
+ * A stored `bcmsBlock` under `builtin:columns` (plain text per column, `gap: small|medium|large`)
10
+ * is read the same way, so it renders instead of the empty marker it used to; nothing writes it.
11
+ * A legacy block in any other shape stays a marker.
12
+ */
13
+ export const PT_COLUMNS_LIMITS = { min: 2, max: 4 } as const;
14
+ export const COLUMNS_GAPS = ["s", "m", "l"] as const;
15
+ export type ColumnsGap = (typeof COLUMNS_GAPS)[number];
16
+ export const BUILTIN_COLUMNS_KEY = "builtin:columns" as const;
17
+
18
+ const isRecord = (v: unknown): v is Record<string, unknown> =>
19
+ !!v && typeof v === "object" && !Array.isArray(v);
20
+
21
+ /** The inline layout both renderers write (a hosted page has no stylesheet for it): legacy columns' flex row. */
22
+ export const COLUMNS_STYLES: Record<ColumnsGap, { row: string; col: string }> = {
23
+ s: { row: "display:flex;flex-wrap:wrap;gap:0.75rem", col: "flex:1 1 14rem;min-width:0" },
24
+ m: { row: "display:flex;flex-wrap:wrap;gap:1.25rem", col: "flex:1 1 14rem;min-width:0" },
25
+ l: { row: "display:flex;flex-wrap:wrap;gap:2rem", col: "flex:1 1 14rem;min-width:0" },
26
+ };
27
+
28
+ /** A legacy column's plain text as text blocks: a paragraph per blank-line-separated run. */
29
+ const legacyContent = (text: string, key: string): PortableText =>
30
+ text
31
+ .split(/\n{2,}/)
32
+ .filter((p) => p.trim() !== "")
33
+ .map((p, i) => ({ _type: "block", _key: `${key}p${i}`, style: "normal", markDefs: [], children: [{ _type: "span", _key: `${key}s${i}`, text: p, marks: [] }] }) as never);
34
+
35
+ /**
36
+ * What a reader sees of a columns block, ours or a legacy `builtin:columns`: its gap and each
37
+ * column's content. Null for anything else. Read defensively: a column without content renders empty.
38
+ */
39
+ export function columnsView(node: unknown): { gap: ColumnsGap; columns: { key: string; content: PortableText }[] } | null {
40
+ if (!isRecord(node)) return null;
41
+ if (node._type === PT_TYPES.columns) {
42
+ const gap: ColumnsGap = node.gap === "s" || node.gap === "l" ? node.gap : "m";
43
+ const columns = (Array.isArray(node.columns) ? node.columns : []).map((c, i) => ({
44
+ key: isRecord(c) && typeof c._key === "string" && c._key ? c._key : `c${i}`,
45
+ content: (isRecord(c) && Array.isArray(c.content) ? c.content : []) as PortableText,
46
+ }));
47
+ return { gap, columns };
48
+ }
49
+ if (node._type === PT_TYPES.block && node.schemaKey === BUILTIN_COLUMNS_KEY) {
50
+ const f = isRecord(node.fields) ? node.fields : {};
51
+ // Only the shape the legacy renderer wrote (plain text per column) reads as columns; any other
52
+ // stays the marker, which keeps "something is here" visible rather than drawing it empty.
53
+ if (!Array.isArray(f.columns) || !f.columns.every((c) => typeof c === "string")) return null;
54
+ const gap: ColumnsGap = f.gap === "small" ? "s" : f.gap === "large" ? "l" : "m";
55
+ const columns = (Array.isArray(f.columns) ? f.columns : []).slice(0, PT_COLUMNS_LIMITS.max).map((c, i) => ({
56
+ key: `c${i}`,
57
+ content: legacyContent(typeof c === "string" ? c : "", `c${i}`),
58
+ }));
59
+ return { gap, columns };
60
+ }
61
+ return null;
62
+ }
63
+
64
+ /** One reason a stored columns block is refused, at its own path inside the block. */
65
+ export type PortableTextColumnsProblem = { path: Array<string | number>; code: string; message: string };
66
+
67
+ const KEYS = new Set(["_type", "_key", "gap", "columns"]);
68
+
69
+ /**
70
+ * Every reason `node` cannot be stored: `columns_invalid` (a shape problem, a column holding
71
+ * anything but text blocks, which includes another columns block (R5) and a footnote) or
72
+ * `columns_limit_exceeded` (outside V2's 2 to 4 columns). A derived `cols` is refused by R8, by name.
73
+ */
74
+ export function columnsProblems(node: unknown): PortableTextColumnsProblem[] {
75
+ const out: PortableTextColumnsProblem[] = [];
76
+ if (!isRecord(node) || node._type !== PT_TYPES.columns) return out;
77
+ const bad = (path: Array<string | number>, message: string, code = "columns_invalid") =>
78
+ out.push({ path, code, message: `${code}: ${message}` });
79
+ for (const k of Object.keys(node)) if (!KEYS.has(k) && k !== "cols") bad([k], `unknown key "${k}" (gap, columns)`);
80
+ if (!(COLUMNS_GAPS as readonly unknown[]).includes(node.gap)) bad(["gap"], 'gap must be "s", "m" or "l"');
81
+ if (!Array.isArray(node.columns)) {
82
+ bad(["columns"], "columns must be an array");
83
+ return out;
84
+ }
85
+ const n = node.columns.length;
86
+ if (n < PT_COLUMNS_LIMITS.min || n > PT_COLUMNS_LIMITS.max) {
87
+ bad(["columns"], `${n} columns; ${PT_COLUMNS_LIMITS.min} to ${PT_COLUMNS_LIMITS.max}`, "columns_limit_exceeded");
88
+ }
89
+ node.columns.forEach((col, i) => {
90
+ const at = ["columns", i];
91
+ if (!isRecord(col)) return bad(at, `columns[${i}] must be an object`);
92
+ if (typeof col._key !== "string" || !col._key) bad([...at, "_key"], `columns[${i}] needs a _key`);
93
+ for (const k of Object.keys(col)) if (k !== "_key" && k !== "content") bad([...at, k], `unknown key "${k}" (content)`);
94
+ if (!Array.isArray(col.content)) return bad([...at, "content"], "content must be an array of text blocks");
95
+ col.content.forEach((b, j) => {
96
+ const bp = [...at, "content", j];
97
+ if (!isRecord(b) || b._type !== "block") {
98
+ return bad(bp, `a column holds text blocks only, not ${isRecord(b) && typeof b._type === "string" ? `"${b._type}"` : "this"}`);
99
+ }
100
+ (Array.isArray(b.children) ? b.children : []).forEach((c, k) => {
101
+ if (isRecord(c) && c._type === PT_TYPES.footnote) bad([...bp, "children", k], "a footnote belongs in the body text, not a column");
102
+ });
103
+ });
104
+ });
105
+ return out;
106
+ }
107
+
108
+ /** `columnsProblems` for every columns block at the top level of a value, with its block key. */
109
+ export function portableTextColumnsProblems(value: unknown): Array<PortableTextColumnsProblem & { block: string }> {
110
+ const out: Array<PortableTextColumnsProblem & { block: string }> = [];
111
+ for (const node of Array.isArray(value) ? value : []) {
112
+ if (!isRecord(node) || node._type !== PT_TYPES.columns) continue;
113
+ const block = typeof node._key === "string" ? node._key : "";
114
+ for (const p of columnsProblems(node)) out.push({ ...p, block });
115
+ }
116
+ return out;
117
+ }
package/src/embed.ts ADDED
@@ -0,0 +1,242 @@
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. The allowlist grows
8
+ * from evidence (an import's `embed.legacy_iframe:<host>` lines), one pure parser per provider.
9
+ *
10
+ * An X post is a card: its words come from an oEmbed fetch the server makes when it builds the
11
+ * html cache (never stored in the value). Without that data, it is a link card.
12
+ */
13
+ import type { EmbedCard, RichTextEmbeds } from "@bettercms-ai/types";
14
+ import { resolveVideoSource } from "./video";
15
+ import { safeHref } from "./href";
16
+
17
+ export type { EmbedCard, RichTextEmbeds };
18
+
19
+ export const EMBED_ASPECTS = ["16:9", "4:3", "1:1", "9:16"] as const;
20
+ export type EmbedAspect = (typeof EMBED_ASPECTS)[number];
21
+ export const isEmbedAspect = (v: unknown): v is EmbedAspect => (EMBED_ASPECTS as readonly unknown[]).includes(v);
22
+ /** V2's title cap: screen-reader only, never shown. */
23
+ export const EMBED_TITLE_MAX = 120;
24
+
25
+ export type EmbedProvider =
26
+ | "youtube" | "vimeo" | "x" | "codepen" | "figma" | "maps" | "spotify" | "podcast" | "audio"
27
+ | "calendly" | "typeform" | "loom" | "airtable" | "google"
28
+ | "link";
29
+
30
+ /** V2's detectEmbed (kit rich-text article.ts), verbatim: which provider a link is, and its label. */
31
+ export function detectEmbed(url: string): { provider: EmbedProvider; label: string; host: string } {
32
+ const u = url.trim();
33
+ let host = "";
34
+ try {
35
+ host = new URL(u).hostname.replace(/^www\./, "");
36
+ } catch {
37
+ host = "";
38
+ }
39
+ const is = (re: RegExp) => re.test(u);
40
+ if (is(/(?:youtube\.com\/(?:watch\?|embed\/|shorts\/|live\/)|youtu\.be\/)/i)) return { provider: "youtube", label: "YouTube", host };
41
+ if (is(/vimeo\.com\/(?:video\/)?\d+/i)) return { provider: "vimeo", label: "Vimeo", host };
42
+ if (is(/(?:twitter|x)\.com\/[^/]+\/status\/\d+/i)) return { provider: "x", label: "X post", host };
43
+ if (is(/codepen\.io\/[^/]+\/(?:pen|embed)\//i)) return { provider: "codepen", label: "CodePen", host };
44
+ if (is(/figma\.com\/(?:file|design|proto|board)\//i)) return { provider: "figma", label: "Figma", host };
45
+ if (is(/(?:google\.[a-z.]+\/maps|maps\.app\.goo\.gl|goo\.gl\/maps)/i)) return { provider: "maps", label: "Google Maps", host };
46
+ if (is(/open\.spotify\.com\/(?:episode|track|show|playlist|album)\//i)) return { provider: "spotify", label: "Spotify", host };
47
+ if (is(/podcasts\.apple\.com\//i)) return { provider: "podcast", label: "Apple Podcasts", host };
48
+ if (is(/\.(?:mp3|m4a|wav|ogg|aac)(?:\?|$)/i)) return { provider: "audio", label: "Audio", host };
49
+ return { provider: "link", label: host || "Link", host };
50
+ }
51
+
52
+ /**
53
+ * Past V2's list: the providers the import evidence asked for, and the PLAYER urls an imported
54
+ * `<iframe>` carries (a YouTube nocookie frame, a Spotify /embed/ path) that V2's page-url patterns miss.
55
+ */
56
+ function detectMore(url: URL): { provider: EmbedProvider; label: string } | null {
57
+ const host = url.hostname.toLowerCase().replace(/^www\./, "");
58
+ const path = url.pathname;
59
+ if (host === "youtube-nocookie.com" && path.startsWith("/embed/")) return { provider: "youtube", label: "YouTube" };
60
+ if (host === "open.spotify.com" && /^\/embed\/(?:episode|track|show|playlist|album)\//.test(path)) return { provider: "spotify", label: "Spotify" };
61
+ if (host === "calendly.com") return { provider: "calendly", label: "Calendly" };
62
+ if (host.endsWith(".typeform.com") && /^\/to\//.test(path)) return { provider: "typeform", label: "Typeform" };
63
+ if (host === "loom.com" && /^\/(?:share|embed)\//.test(path)) return { provider: "loom", label: "Loom" };
64
+ if (host === "airtable.com") return { provider: "airtable", label: "Airtable" };
65
+ if (host === "docs.google.com") {
66
+ const kind = path.split("/")[1];
67
+ const label = { forms: "Google Forms", document: "Google Docs", spreadsheets: "Google Sheets", presentation: "Google Slides" }[kind ?? ""];
68
+ if (label) return { provider: "google", label };
69
+ }
70
+ return null;
71
+ }
72
+
73
+ /** How an embed renders, all of it derived from the stored url. */
74
+ export type ResolvedEmbed =
75
+ | { kind: "frame"; provider: EmbedProvider; label: string; src: string; width: string; height: string }
76
+ | { kind: "audio"; provider: "audio"; label: string; src: string }
77
+ | { kind: "card"; provider: "x"; label: string; url: string }
78
+ | { kind: "link"; provider: EmbedProvider; label: string; url: string | null };
79
+
80
+ /** A video frame's default box per aspect: the size it has with no stylesheet at all. */
81
+ const FRAME_SIZE: Record<EmbedAspect, [string, string]> = {
82
+ "16:9": ["640", "360"],
83
+ "4:3": ["640", "480"],
84
+ "1:1": ["640", "640"],
85
+ "9:16": ["360", "640"],
86
+ };
87
+
88
+ const ID = /^[A-Za-z0-9_-]{1,64}$/;
89
+ /** A form, a calendar, a table or a document fills the column and is as tall as it needs. */
90
+ const FULL_WIDTH_HEIGHT: Partial<Record<EmbedProvider, string>> = { calendly: "700", typeform: "500", airtable: "533" };
91
+
92
+ /** The provider's player url (and, for a full-width player, its height), or null when we cannot frame the link. */
93
+ function frameOf(provider: EmbedProvider, url: URL): { src: string; height?: string } | null {
94
+ const parts = url.pathname.split("/").filter(Boolean);
95
+ switch (provider) {
96
+ case "youtube":
97
+ case "vimeo": {
98
+ // The video block's resolver: one place knows YouTube's and Vimeo's player urls.
99
+ const video = resolveVideoSource(url.href);
100
+ return video?.kind === "iframe" ? { src: video.src } : null;
101
+ }
102
+ case "codepen": {
103
+ const [user, mode, id] = parts;
104
+ return user && ID.test(user) && (mode === "pen" || mode === "embed") && id && ID.test(id)
105
+ ? { src: `https://codepen.io/${user}/embed/${id}?default-tab=result` }
106
+ : null;
107
+ }
108
+ case "figma": {
109
+ const [mode, key] = parts;
110
+ if (!mode || !["file", "design", "proto", "board"].includes(mode) || !key || !ID.test(key)) return null;
111
+ const frame = new URL(`https://embed.figma.com/${mode}/${key}`);
112
+ const node = url.searchParams.get("node-id");
113
+ if (node && /^[0-9A-Za-z:_-]{1,64}$/.test(node)) frame.searchParams.set("node-id", node);
114
+ frame.searchParams.set("embed-host", "bettercms");
115
+ return { src: frame.href };
116
+ }
117
+ case "maps": {
118
+ if (!/^(?:www\.)?google\.[a-z.]+$/i.test(url.hostname)) return null; // a short link needs a fetch to resolve
119
+ const pb = url.searchParams.get("pb");
120
+ if (parts[0] === "maps" && parts[1] === "embed" && pb && /^[!A-Za-z0-9._:%+-]{1,2000}$/.test(pb)) {
121
+ return { src: `https://www.google.com/maps/embed?pb=${encodeURIComponent(pb)}` };
122
+ }
123
+ const at = /@(-?\d{1,3}(?:\.\d+)?),(-?\d{1,3}(?:\.\d+)?)/.exec(url.pathname);
124
+ const place = parts[0] === "maps" && parts[1] === "place" && parts[2] ? decodeURIComponent(parts[2]).replace(/\+/g, " ") : null;
125
+ const q = at ? `${at[1]},${at[2]}` : (url.searchParams.get("q") ?? place);
126
+ if (!q || q.length > 200) return null;
127
+ return { src: `https://maps.google.com/maps?q=${encodeURIComponent(q)}&output=embed` };
128
+ }
129
+ case "spotify": {
130
+ const [type, id] = parts[0] === "embed" ? parts.slice(1) : parts;
131
+ if (!type || !["episode", "track", "show", "playlist", "album"].includes(type) || !id || !ID.test(id)) return null;
132
+ return { src: `https://open.spotify.com/embed/${type}/${id}`, height: type === "track" || type === "episode" ? "152" : "352" };
133
+ }
134
+ case "podcast":
135
+ return (url.hostname === "podcasts.apple.com" || url.hostname === "embed.podcasts.apple.com") && parts.length >= 2
136
+ ? { src: `https://embed.podcasts.apple.com${url.pathname}${url.search}`, height: url.searchParams.has("i") ? "175" : "450" }
137
+ : null;
138
+ case "calendly":
139
+ return parts.length >= 1 && parts.length <= 2 && parts.every((p) => ID.test(p))
140
+ ? { src: `https://calendly.com/${parts.join("/")}?embed_type=Inline&hide_gdpr_banner=1` }
141
+ : null;
142
+ case "typeform": {
143
+ const id = parts[0] === "to" ? parts[1] : undefined;
144
+ return id && /^[A-Za-z0-9]{4,32}$/.test(id) ? { src: `https://form.typeform.com/to/${id}` } : null;
145
+ }
146
+ case "loom": {
147
+ const id = parts[1];
148
+ return (parts[0] === "share" || parts[0] === "embed") && id && /^[0-9a-f]{32}$/.test(id) ? { src: `https://www.loom.com/embed/${id}` } : null;
149
+ }
150
+ case "airtable": {
151
+ const ids = parts[0] === "embed" ? parts.slice(1) : parts;
152
+ return ids.length >= 1 && ids.length <= 3 && ids.every((p) => /^(?:app|shr|tbl|viw)[A-Za-z0-9]{14}$/.test(p)) && ids.some((p) => p.startsWith("shr"))
153
+ ? { src: `https://airtable.com/embed/${ids.join("/")}` }
154
+ : null;
155
+ }
156
+ case "google": {
157
+ // /<kind>/d/e/<id>/… is a published file, /<kind>/d/<id>/… a shared one.
158
+ const [kind, d, maybeE, maybeId] = parts;
159
+ if (d !== "d") return null;
160
+ const published = maybeE === "e";
161
+ const id = published ? maybeId : maybeE;
162
+ if (!id || !/^[A-Za-z0-9_-]{10,128}$/.test(id)) return null;
163
+ const base = `https://docs.google.com/${kind}/d/${published ? `e/${id}` : id}`;
164
+ if (kind === "forms") return { src: `${base}/viewform?embedded=true`, height: "800" };
165
+ if (kind === "document") return { src: published ? `${base}/pub?embedded=true` : `${base}/preview`, height: "600" };
166
+ if (kind === "spreadsheets") return { src: published ? `${base}/pubhtml?widget=true&headers=false` : `${base}/preview`, height: "600" };
167
+ if (kind === "presentation") return { src: published ? `${base}/embed` : `${base}/preview` };
168
+ return null;
169
+ }
170
+ default:
171
+ return null;
172
+ }
173
+ }
174
+
175
+ /** The X post id and handle in a status link, any of its spellings. */
176
+ const X_STATUS = /^\/([A-Za-z0-9_]{1,15})\/status\/(\d{1,25})(?:\/|$)/;
177
+ const X_HOSTS = /^(?:www\.|mobile\.)?(?:twitter|x)\.com$/i;
178
+
179
+ /**
180
+ * The url an embed's derived data is keyed by. An X post in any spelling (twitter.com or x.com,
181
+ * www. or mobile., a tracking query, a /photo/1 tail, any handle case) is one
182
+ * `https://x.com/<handle>/status/<id>`; any other link is itself, normalised and without its
183
+ * fragment. Null for a link no renderer would follow.
184
+ */
185
+ export function canonicalEmbedUrl(raw: unknown): string | null {
186
+ const url = typeof raw === "string" ? raw.trim() : "";
187
+ const safe = url ? safeHref(url) : null;
188
+ if (!safe || !/^https?:\/\//i.test(safe)) return null;
189
+ let parsed: URL;
190
+ try {
191
+ parsed = new URL(safe);
192
+ } catch {
193
+ return null;
194
+ }
195
+ const status = X_HOSTS.test(parsed.hostname) ? X_STATUS.exec(parsed.pathname) : null;
196
+ if (status) return `https://x.com/${status[1].toLowerCase()}/status/${status[2]}`;
197
+ parsed.hash = "";
198
+ return parsed.href;
199
+ }
200
+
201
+ /**
202
+ * The embed's rendering, derived from its stored url and aspect. Pure. An unsafe url is a link
203
+ * card with no href; one no rule recognises (or a short link) is a link card to itself.
204
+ */
205
+ export function resolveEmbed(rawUrl: unknown, aspect: unknown = "16:9"): ResolvedEmbed {
206
+ const url = typeof rawUrl === "string" ? rawUrl.trim() : "";
207
+ const v2 = detectEmbed(url);
208
+ const safe = url ? safeHref(url) : null;
209
+ let parsed: URL | null = null;
210
+ try {
211
+ parsed = safe && /^https?:\/\//i.test(safe) ? new URL(safe) : null;
212
+ } catch {
213
+ parsed = null;
214
+ }
215
+ const { provider, label } = v2.provider === "link" && parsed ? (detectMore(parsed) ?? v2) : v2;
216
+ const link: ResolvedEmbed = { kind: "link", provider, label, url: safe };
217
+ if (!parsed) return link;
218
+ if (provider === "x") return { kind: "card", provider: "x", label, url: canonicalEmbedUrl(parsed.href) ?? parsed.href };
219
+ if (provider === "audio") return parsed.protocol === "https:" ? { kind: "audio", provider: "audio", label, src: parsed.href } : link;
220
+ const frame = frameOf(provider, parsed);
221
+ if (!frame) return link;
222
+ const height = frame.height ?? FULL_WIDTH_HEIGHT[provider];
223
+ if (height) return { kind: "frame", provider, label, src: frame.src, width: "100%", height };
224
+ const [width, h] = FRAME_SIZE[isEmbedAspect(aspect) ? aspect : "16:9"];
225
+ return { kind: "frame", provider, label, src: frame.src, width, height: h };
226
+ }
227
+
228
+ /** What a render needs that the value does not hold: the cards fetched for it. */
229
+ export type EmbedRender = Partial<RichTextEmbeds>;
230
+
231
+ /** X's widget script, loaded once per page on a site that opted in (next `BcmsEmbedScripts`), never by a value's html. */
232
+ export const X_WIDGET_SCRIPT = "https://platform.twitter.com/widgets.js";
233
+
234
+ /** The aspect nearest an imported frame's width and height (16:9 when it gives none). */
235
+ export function nearestEmbedAspect(width: unknown, height: unknown): EmbedAspect {
236
+ const w = Number(width);
237
+ const h = Number(height);
238
+ if (!(w > 0) || !(h > 0)) return "16:9";
239
+ const ratio = w / h;
240
+ const ratios: [EmbedAspect, number][] = [["16:9", 16 / 9], ["4:3", 4 / 3], ["1:1", 1], ["9:16", 9 / 16]];
241
+ return ratios.reduce((best, cur) => (Math.abs(cur[1] - ratio) < Math.abs(best[1] - ratio) ? cur : best))[0];
242
+ }
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
 
@@ -0,0 +1,70 @@
1
+ import { PT_TYPES } from "./types";
2
+ import { isPortableTextTable } from "./table";
3
+
4
+ /**
5
+ * RT6: the footnote (`PortableTextFootnote`, types.ts), an inline object in body text that stores
6
+ * only its `note` (plain text, as V2 keeps it on the marker). Everything else is derived on every
7
+ * render: its number (its place in text order across the value's top-level text blocks), the
8
+ * `fnref-N` / `fn-N` ids, the end list and its back links.
9
+ *
10
+ * Not the table's footnote (`PortableTextTableFootnote`), which belongs to a table and stays apart.
11
+ */
12
+ const isRecord = (v: unknown): v is Record<string, unknown> =>
13
+ !!v && typeof v === "object" && !Array.isArray(v);
14
+
15
+ /**
16
+ * Each footnote in the value's top-level text blocks, numbered in text order (V2
17
+ * `renumberFootnotes`), and the notes in that order. `nodes` maps each marker to its number, which
18
+ * is how a renderer finds the number of the marker it is drawing.
19
+ */
20
+ export function footnoteOrder(value: unknown): { notes: string[]; nodes: WeakMap<object, number> } {
21
+ const notes: string[] = [];
22
+ const nodes = new WeakMap<object, number>();
23
+ for (const node of Array.isArray(value) ? value : []) {
24
+ if (!isRecord(node) || node._type !== "block" || !Array.isArray(node.children)) continue;
25
+ for (const child of node.children) {
26
+ if (!isRecord(child) || child._type !== PT_TYPES.footnote) continue;
27
+ nodes.set(child, notes.push(typeof child.note === "string" ? child.note : ""));
28
+ }
29
+ }
30
+ return { notes, nodes };
31
+ }
32
+
33
+ /** One reason a stored footnote is refused, with the block it sits in. */
34
+ export type PortableTextFootnoteProblem = { block: string; path: Array<string | number>; code: string; message: string };
35
+
36
+ const KEYS = new Set(["_type", "_key", "note"]);
37
+
38
+ /**
39
+ * Every reason a value's footnotes cannot be stored, by block and path: `footnote_invalid` for a
40
+ * shape problem, and for a footnote in a table cell (V2 refuses one inside a block: a cell has no
41
+ * place in the body's numbering). A derived `number` is refused by the R8 check, by name.
42
+ */
43
+ export function portableTextFootnoteProblems(value: unknown): PortableTextFootnoteProblem[] {
44
+ const out: PortableTextFootnoteProblem[] = [];
45
+ const bad = (block: string, path: Array<string | number>, message: string) =>
46
+ out.push({ block, path, code: "footnote_invalid", message: `footnote_invalid: ${message}` });
47
+ const blockKey = (n: Record<string, unknown>) => (typeof n._key === "string" ? n._key : "");
48
+ const each = (blocks: unknown, inCell: string | null) => {
49
+ for (const node of Array.isArray(blocks) ? blocks : []) {
50
+ if (isPortableTextTable(node)) {
51
+ for (const row of node.rows as unknown[]) {
52
+ if (!isRecord(row) || !Array.isArray(row.cells)) continue;
53
+ for (const cell of row.cells) if (isRecord(cell)) each(cell.content, blockKey(node as Record<string, unknown>));
54
+ }
55
+ continue;
56
+ }
57
+ if (!isRecord(node) || node._type !== "block" || !Array.isArray(node.children)) continue;
58
+ node.children.forEach((child, i) => {
59
+ if (!isRecord(child) || child._type !== PT_TYPES.footnote) return;
60
+ const block = inCell ?? blockKey(node);
61
+ const path = inCell === null ? ["children", i] : [blockKey(node), "children", i];
62
+ if (inCell !== null) bad(block, path, "a footnote belongs in the body text, not a table cell");
63
+ for (const k of Object.keys(child)) if (!KEYS.has(k) && k !== "number") bad(block, [...path, k], `unknown key "${k}" (note)`);
64
+ if (typeof child.note !== "string") bad(block, [...path, "note"], "note must be a string");
65
+ });
66
+ }
67
+ };
68
+ each(value, null);
69
+ return out;
70
+ }