@bettercms-ai/richtext 0.12.1 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bettercms-ai/richtext",
3
- "version": "0.12.1",
3
+ "version": "0.14.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.55.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
- export const ALL_ELEMENTS = [...CORE_ELEMENTS, ...ADVANCED_ELEMENTS] as const
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,18 @@ 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
+ // RT4: the gallery's figure, its list and each image's own figure.
97
+ gallery: ['figure', 'ul', 'li', 'img', 'figcaption'],
98
+ // RT5: the contents' nav, its title and its (nested) list of links.
99
+ tableOfContents: ['nav', 'p', 'ol', 'li', 'a'],
100
+ relatedPosts: [], authorBio: [], footnotes: [],
101
+ // RT2: a button is an `<a data-button>`; the ingest reads it back (buttonLinkRule).
102
+ button: ['a'],
79
103
  }
80
104
 
81
105
  /**
@@ -92,7 +116,7 @@ export const HTML_BACKED_UNAVAILABLE_ACCEPT_REASONS: Partial<Record<AcceptElemen
92
116
  columns: 'Embedded blocks are available in entry rich text; component rich text stores HTML.',
93
117
  }
94
118
 
95
- const NEW_AUTHORING_EXCLUSIONS = new Set<AcceptElement>(['internalLink', 'rtl'])
119
+ const NEW_AUTHORING_EXCLUSIONS = new Set<AcceptElement>(['internalLink', 'rtl', ...BLOCK_ELEMENTS])
96
120
 
97
121
  /** New defaults contain every capability whose complete consumer pipeline is currently live. */
98
122
  export const DEFAULT_ACCEPT: AcceptElement[] = ALL_ELEMENTS.filter(
@@ -122,6 +146,35 @@ export const DEFAULT_RICHTEXT_CONFIG: RichTextConfig = {
122
146
  allowMultipleParagraphs: true,
123
147
  }
124
148
 
149
+ /**
150
+ * Each feature → the elements it grants. Total over the vocabulary (`satisfies` fails the build on
151
+ * a feature with no row); an alias (`headings`) has none, it is expanded before the lookup. Quote
152
+ * and divider grant nothing: `p` carries them, as `featuresFor` has always given both to any field
153
+ * with paragraphs.
154
+ */
155
+ const FEATURE_ELEMENTS = {
156
+ bold: ['b'], italic: ['i'], underline: ['u'], strikethrough: ['s'], code: ['code'], highlight: ['mark'],
157
+ blockquote: [], horizontalRule: [],
158
+ orderedList: ['ol'], unorderedList: ['ul'], checklist: ['checklist'],
159
+ link: ['link'], internalLink: ['internalLink'], inlineImage: ['image'], videoEmbed: ['embed'],
160
+ codeBlock: ['pre'], table: ['table'], columns: ['columns'],
161
+ h1: ['h1'], h2: ['h2'], h3: ['h3'], h4: ['h4'], h5: ['h5'], h6: ['h6'],
162
+ gallery: ['gallery'], embed: ['providerEmbed'], tableOfContents: ['tableOfContents'], relatedPosts: ['relatedPosts'],
163
+ authorBio: ['authorBio'], footnotes: ['footnotes'], button: ['button'],
164
+ } as const satisfies Record<FeatureName, readonly AcceptElement[]>
165
+
166
+ /**
167
+ * A field's features → the elements it accepts: the ONE projection the sanitiser, the PT schema
168
+ * (`ptSchemaFor`), the dashboard codec and the kit consume, so `features` is the only stored gate.
169
+ * Aliases are expanded first (`headings` → the six levels) and unknown names grant nothing. `p` is
170
+ * the floor and never a feature, so `[]` is a field of paragraphs.
171
+ */
172
+ export function acceptFor(features: readonly string[]): AcceptElement[] {
173
+ const granted = new Set<AcceptElement>(['p'])
174
+ for (const f of normaliseRichTextFeatures(features).features) for (const e of FEATURE_ELEMENTS[f]) granted.add(e)
175
+ return ALL_ELEMENTS.filter((e) => granted.has(e))
176
+ }
177
+
125
178
  /** The tag allowlist the sanitiser enforces for a given Accept set. */
126
179
  export function allowedTags(accept: AcceptElement[]): string[] {
127
180
  // `span` carries marks and is never the reason content is stripped; the rest of `STYLED_TAGS`
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
 
package/src/gallery.ts ADDED
@@ -0,0 +1,147 @@
1
+ import { PT_TYPES } from "./types";
2
+ import { isPortableTextTable } from "./table";
3
+ import { safeHref } from "./href";
4
+
5
+ /**
6
+ * RT4: the gallery (`PortableTextGallery`, types.ts): the limits, the write check, and the read
7
+ * both renderers share.
8
+ *
9
+ * The limits are the V2 kit's (`ARTICLE_SCHEMAS.gallery` in `controls/rich-text/article.ts`): 2 to
10
+ * 24 images, a caption of at most 160 characters. As with key takeaways, V2's `min: 2` and its
11
+ * required image are the editor's, not the write's: an author adds images one at a time, and
12
+ * refusing the autosave in between would refuse the gallery they are building. So a gallery of
13
+ * one, or an image not yet chosen, stores; the view leaves an unchosen image out, as V2 does.
14
+ *
15
+ * The image is the rich-text image's own `{ src, alt }` (+ an optional media id), so whatever
16
+ * reads a rich-text image's src and alt reads a gallery image's the same way.
17
+ */
18
+ export const PT_GALLERY_LIMITS = { minImages: 2, images: 24, captionChars: 160 } as const;
19
+ export const GALLERY_LAYOUTS = ["grid", "carousel"] as const;
20
+ export type GalleryLayout = (typeof GALLERY_LAYOUTS)[number];
21
+
22
+ const isRecord = (v: unknown): v is Record<string, unknown> =>
23
+ !!v && typeof v === "object" && !Array.isArray(v);
24
+
25
+ /** One reason a stored gallery is refused, at its own path inside the block. */
26
+ export type PortableTextGalleryProblem = { path: Array<string | number>; code: string; message: string };
27
+
28
+ const KEYS = new Set(["_type", "_key", "layout", "images"]);
29
+ const ITEM_KEYS = new Set(["_key", "image", "caption"]);
30
+ const IMAGE_KEYS = new Set(["src", "alt", "mediaId"]);
31
+
32
+ /**
33
+ * Every reason `node` cannot be stored, each named with a code: `gallery_invalid` (a shape
34
+ * problem) or `gallery_limit_exceeded` (a V2 limit). Only a `gallery` block is judged; the type is
35
+ * new, so there is nothing stored before it to grandfather.
36
+ */
37
+ export function galleryProblems(node: unknown): PortableTextGalleryProblem[] {
38
+ const out: PortableTextGalleryProblem[] = [];
39
+ if (!isRecord(node) || node._type !== PT_TYPES.gallery) return out;
40
+ const bad = (path: Array<string | number>, message: string, code = "gallery_invalid") =>
41
+ out.push({ path, code, message: `${code}: ${message}` });
42
+ for (const k of Object.keys(node)) if (!KEYS.has(k)) bad([k], `unknown key "${k}" (layout, images)`);
43
+ if (!(GALLERY_LAYOUTS as readonly unknown[]).includes(node.layout)) bad(["layout"], 'layout must be "grid" or "carousel"');
44
+ if (!Array.isArray(node.images)) {
45
+ bad(["images"], "images must be an array");
46
+ return out;
47
+ }
48
+ node.images.forEach((item, i) => {
49
+ const at = ["images", i];
50
+ if (!isRecord(item)) return bad(at, `images[${i}] must be an object`);
51
+ for (const k of Object.keys(item)) if (!ITEM_KEYS.has(k)) bad([...at, k], `unknown key "${k}" (image, caption)`);
52
+ if (typeof item._key !== "string" || !item._key) bad([...at, "_key"], `images[${i}] needs a _key`);
53
+ if (item.caption !== undefined) {
54
+ if (typeof item.caption !== "string") bad([...at, "caption"], "caption must be a string");
55
+ else if (item.caption.length > PT_GALLERY_LIMITS.captionChars) {
56
+ bad([...at, "caption"], `caption is ${item.caption.length} characters; at most ${PT_GALLERY_LIMITS.captionChars}`, "gallery_limit_exceeded");
57
+ }
58
+ }
59
+ const image = item.image;
60
+ if (image === undefined || image === null) return;
61
+ if (!isRecord(image)) return bad([...at, "image"], "image must be { src, alt }");
62
+ for (const k of Object.keys(image)) if (!IMAGE_KEYS.has(k)) bad([...at, "image", k], `unknown key "${k}" (src, alt, mediaId)`);
63
+ if (typeof image.src !== "string") bad([...at, "image", "src"], "src must be a string");
64
+ if (typeof image.alt !== "string") bad([...at, "image", "alt"], "alt must be a string");
65
+ if (image.mediaId !== undefined && typeof image.mediaId !== "string") bad([...at, "image", "mediaId"], "mediaId must be a string");
66
+ });
67
+ if (node.images.length > PT_GALLERY_LIMITS.images) {
68
+ bad(["images"], `${node.images.length} images; at most ${PT_GALLERY_LIMITS.images}`, "gallery_limit_exceeded");
69
+ }
70
+ return out;
71
+ }
72
+
73
+ /**
74
+ * `galleryProblems` for every gallery in a rich-text value (top level, and inside table cells),
75
+ * with the block's `_key` so a refusal names the block an author has to find.
76
+ */
77
+ export function portableTextGalleryProblems(value: unknown): Array<PortableTextGalleryProblem & { block: string }> {
78
+ const out: Array<PortableTextGalleryProblem & { block: string }> = [];
79
+ const walk = (blocks: unknown) => {
80
+ if (!Array.isArray(blocks)) return;
81
+ for (const node of blocks) {
82
+ if (isPortableTextTable(node)) {
83
+ for (const row of node.rows as unknown[]) {
84
+ if (!isRecord(row) || !Array.isArray(row.cells)) continue;
85
+ for (const cell of row.cells) if (isRecord(cell)) walk(cell.content);
86
+ }
87
+ continue;
88
+ }
89
+ if (!isRecord(node) || node._type !== PT_TYPES.gallery) continue;
90
+ const block = typeof node._key === "string" ? node._key : "";
91
+ for (const p of galleryProblems(node)) out.push({ ...p, block });
92
+ }
93
+ };
94
+ walk(value);
95
+ return out;
96
+ }
97
+
98
+ /**
99
+ * The same widened check the rich-text image's `<img>` gets (html.ts `mediaSrc`): http(s), or a
100
+ * site-relative path with no scheme, whitespace or protocol-relative `//`.
101
+ */
102
+ export const galleryMediaSrc = (raw: string): string | null =>
103
+ safeHref(raw) ?? (raw !== "" && !raw.startsWith("//") && !/[:\s]/.test(raw) ? raw : null);
104
+
105
+ /**
106
+ * The layout, as inline CSS both renderers write (ui turns each into a React style object). A grid
107
+ * of equal cells; a carousel is one image per view in a scroll-snap row, its no-JS fallback being
108
+ * itself.
109
+ */
110
+ export const GALLERY_STYLES: Record<GalleryLayout, { ul: string; li: string; figure: string; img: string }> = {
111
+ grid: {
112
+ ul: "display:grid;grid-template-columns:repeat(auto-fill,minmax(12rem,1fr));gap:1rem;list-style:none;margin:0;padding:0",
113
+ li: "margin:0",
114
+ figure: "margin:0",
115
+ img: "display:block;width:100%;height:auto",
116
+ },
117
+ carousel: {
118
+ ul: "display:flex;gap:1rem;overflow-x:auto;scroll-snap-type:x mandatory;list-style:none;margin:0;padding:0",
119
+ li: "flex:0 0 100%;scroll-snap-align:start;margin:0",
120
+ figure: "margin:0",
121
+ img: "display:block;width:100%;height:auto",
122
+ },
123
+ };
124
+
125
+ /**
126
+ * What a reader sees of a gallery: its layout (grid unless it says carousel) and each image that
127
+ * has a safe src, with its alt and trimmed caption. An unchosen image is left out, as V2's
128
+ * `renderArticle("gallery")` leaves it out. Read defensively: a value that never passed the write
129
+ * check renders what it can.
130
+ */
131
+ export function galleryView(value: unknown): { layout: GalleryLayout; images: { key: string; src: string; alt: string; caption: string }[] } {
132
+ const v = isRecord(value) ? value : {};
133
+ const layout: GalleryLayout = v.layout === "carousel" ? "carousel" : "grid";
134
+ const images: { key: string; src: string; alt: string; caption: string }[] = [];
135
+ (Array.isArray(v.images) ? v.images : []).forEach((item, i) => {
136
+ if (!isRecord(item) || !isRecord(item.image)) return;
137
+ const src = galleryMediaSrc(typeof item.image.src === "string" ? item.image.src.trim() : "");
138
+ if (!src) return;
139
+ images.push({
140
+ key: typeof item._key === "string" && item._key ? item._key : `i${i}`,
141
+ src,
142
+ alt: typeof item.image.alt === "string" ? item.image.alt : "",
143
+ caption: typeof item.caption === "string" ? item.caption.trim() : "",
144
+ });
145
+ });
146
+ return { layout, images };
147
+ }