@bettercms-ai/richtext 0.8.3 → 0.10.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.8.3",
3
+ "version": "0.10.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.",
@@ -9,6 +9,7 @@
9
9
  "files": [
10
10
  "src",
11
11
  "!src/__tests__",
12
+ "!src/__fixtures__",
12
13
  "package.json",
13
14
  "README.md"
14
15
  ],
@@ -0,0 +1,138 @@
1
+ import { EMBEDDED_COMPONENT_KEY } from "./types";
2
+
3
+ /**
4
+ * A component embedded in rich text, kept as a SEPARATE COPY (V2 "Keep as a separate copy" /
5
+ * "Make a separate copy", bettercms-v2-demo features/blocks/instances.ts `instanceOps.detach`).
6
+ *
7
+ * Linked (every value stored before this existed):
8
+ * { _type: "bcmsBlock", schemaKey: "component", fields: { componentId, overrides?, variant? } }
9
+ * → renders the shared component with this place's overrides; edits to the component reach it.
10
+ *
11
+ * Detached:
12
+ * { _type: "bcmsBlock", schemaKey: "component", fields: {
13
+ * componentId, the component it was copied from (kept for "Use shared version again")
14
+ * detached: true,
15
+ * props, the values readers saw when it was copied (the component's ⊕ this place's)
16
+ * blocks, the block tree readers see, resolved and frozen: no `component` block inside
17
+ * sourcePublishedAt, the source's `lastPublishedAt` when it was copied (0454; older copies hold its first `publishedAt`): the version it was taken from
18
+ * variant?
19
+ * } }
20
+ * → renders `blocks` and never looks the component up again.
21
+ *
22
+ * V2 keeps only `props` because its renderer draws a catalog type from props. Here a component's
23
+ * markup is its own block tree, so a copy that kept only props would still follow the shared
24
+ * component's structure; `blocks` is what makes the copy separate.
25
+ */
26
+ export interface DetachedComponentCopy {
27
+ /** The node's `_key`. */
28
+ id: string;
29
+ componentId: string;
30
+ props: Record<string, unknown>;
31
+ blocks: unknown[];
32
+ sourcePublishedAt?: string;
33
+ variant?: string;
34
+ }
35
+
36
+ const isRecord = (v: unknown): v is Record<string, unknown> => !!v && typeof v === "object" && !Array.isArray(v);
37
+
38
+ const fieldsOf = (value: unknown): Record<string, unknown> | null =>
39
+ isRecord(value) && value.schemaKey === EMBEDDED_COMPONENT_KEY && isRecord(value.fields) ? value.fields : null;
40
+
41
+ /** True when the node is an embedded component stored as a separate copy (whatever else it holds). */
42
+ export function isDetachedComponent(value: unknown): boolean {
43
+ return fieldsOf(value)?.detached === true;
44
+ }
45
+
46
+ /** The separate copy a node stands for, or null (a linked embed, any other node, a malformed copy). */
47
+ export function detachedComponentCopy(value: unknown): DetachedComponentCopy | null {
48
+ const f = fieldsOf(value);
49
+ if (!f || f.detached !== true || !Array.isArray(f.blocks)) return null;
50
+ return {
51
+ id: isRecord(value) && typeof value._key === "string" ? value._key : "",
52
+ componentId: typeof f.componentId === "string" ? f.componentId : "",
53
+ props: isRecord(f.props) ? f.props : {},
54
+ blocks: f.blocks,
55
+ ...(typeof f.sourcePublishedAt === "string" ? { sourcePublishedAt: f.sourcePublishedAt } : {}),
56
+ ...(typeof f.variant === "string" ? { variant: f.variant } : {}),
57
+ };
58
+ }
59
+
60
+ /** One reason a stored copy is refused, at its path inside the node's `fields`. */
61
+ export type ComponentCopyProblem = { path: Array<string | number>; code: string; message: string };
62
+
63
+ /**
64
+ * Every reason a separate copy cannot be stored, named `component_copy_invalid` (the shape) or
65
+ * `component_copy_not_frozen` (its blocks still reference a component, so it would keep
66
+ * following one).
67
+ *
68
+ * 🔴 ONLY A NODE THAT CARRIES `detached` IS JUDGED. A linked embed answers `[]`, so every value
69
+ * stored before copies existed saves exactly as it did.
70
+ */
71
+ export function componentCopyProblems(node: unknown): ComponentCopyProblem[] {
72
+ const f = fieldsOf(node);
73
+ if (!f || f.detached === undefined) return [];
74
+ const out: ComponentCopyProblem[] = [];
75
+ const bad = (path: Array<string | number>, message: string, code = "component_copy_invalid") =>
76
+ out.push({ path, code, message: `${code}: ${message}` });
77
+ if (f.detached !== true) bad(["detached"], "detached must be true (leave it out for a linked component)");
78
+ if (typeof f.componentId !== "string" || !f.componentId) bad(["componentId"], "needs the componentId it was copied from");
79
+ if (!isRecord(f.props)) bad(["props"], "props must be an object of the values readers see");
80
+ if (!Array.isArray(f.blocks)) {
81
+ bad(["blocks"], "needs blocks, the copy readers see; get_component { detachedCopy } returns them");
82
+ } else {
83
+ const live = componentRefs(f.blocks);
84
+ if (live.length) {
85
+ bad(["blocks"], `still holds ${live.length} component block(s) (${live.slice(0, 3).join(", ")}), so it would keep following the shared one`, "component_copy_not_frozen");
86
+ }
87
+ }
88
+ if (f.sourcePublishedAt !== undefined && (typeof f.sourcePublishedAt !== "string" || Number.isNaN(Date.parse(f.sourcePublishedAt)))) {
89
+ bad(["sourcePublishedAt"], "sourcePublishedAt must be an ISO date");
90
+ }
91
+ if (isRecord(f.overrides) && Object.keys(f.overrides).length) {
92
+ bad(["overrides"], "a separate copy keeps its values in props; overrides belong to a linked component");
93
+ }
94
+ return out;
95
+ }
96
+
97
+ /** `componentCopyProblems` for every embedded component at the top of a rich-text value, with its `_key`. */
98
+ export function portableTextComponentCopyProblems(value: unknown): Array<ComponentCopyProblem & { block: string }> {
99
+ if (!Array.isArray(value)) return [];
100
+ return value.flatMap((node) => {
101
+ const block = isRecord(node) && typeof node._key === "string" ? node._key : "";
102
+ return componentCopyProblems(node).map((p) => ({ ...p, block }));
103
+ });
104
+ }
105
+
106
+ /** The component ids a block tree still references, at any depth. */
107
+ function componentRefs(blocks: unknown, out: string[] = [], depth = 0): string[] {
108
+ if (depth > 32) return out;
109
+ if (Array.isArray(blocks)) {
110
+ for (const b of blocks) componentRefs(b, out, depth + 1);
111
+ return out;
112
+ }
113
+ if (!isRecord(blocks)) return out;
114
+ if (blocks.type === "component") {
115
+ const p = isRecord(blocks.props) ? blocks.props : {};
116
+ out.push(typeof p.componentId === "string" && p.componentId ? p.componentId : "(no id)");
117
+ }
118
+ for (const v of Object.values(blocks)) if (v && typeof v === "object") componentRefs(v, out, depth + 1);
119
+ return out;
120
+ }
121
+
122
+ /**
123
+ * What a separate copy says, one line per value, for search and plain text: its own copy is
124
+ * content this page owns, unlike a linked embed, whose words live in the component.
125
+ * Strings (markup stripped), numbers, and a link's or button's label; nothing else.
126
+ */
127
+ export function componentCopyLines(copy: Pick<DetachedComponentCopy, "props">): string[] {
128
+ const line = (v: unknown): string | null => {
129
+ if (typeof v === "number" && Number.isFinite(v)) return String(v);
130
+ if (typeof v === "string") {
131
+ const t = v.replace(/<[^>]+>/g, " ").replace(/&nbsp;/g, " ").replace(/\s+/g, " ").trim();
132
+ return t || null;
133
+ }
134
+ if (isRecord(v)) return line(v.label ?? v.text ?? v.title);
135
+ return null;
136
+ };
137
+ return Object.values(copy.props).map(line).filter((s): s is string => !!s);
138
+ }
package/src/html.ts CHANGED
@@ -3,14 +3,17 @@ import {
3
3
  BUILTIN_EMBED_KEY,
4
4
  BUILTIN_IMAGE_KEY,
5
5
  PT_TYPES,
6
+ pullQuoteHref,
6
7
  type PortableText,
7
8
  type PortableTextTable,
8
9
  } from "./types";
9
10
  import { resolveVideoSource } from "./video";
10
- import { builtInTableToTable } from "./table";
11
+ import { builtInTableToTable, designOf, highlightedColumns, jsonLdOf, sortableColumns, tableGroupLayout, tableSections } from "./table";
12
+ import { portableTextToPlainText } from "./plain";
11
13
  import { safeHref } from "./href";
12
14
  import { isStyledTag, styledAttrEntries } from "./styled";
13
15
  import { richTextImageLayout } from "./image-layout";
16
+ import { keyTakeawaysView } from "./key-takeaways";
14
17
 
15
18
  /**
16
19
  * Portable Text → HTML.
@@ -62,34 +65,116 @@ const isRecord = (v: unknown): v is Record<string, unknown> =>
62
65
  const attr = (name: string, value: unknown): string =>
63
66
  typeof value === "number" && Number.isInteger(value) && value > 1 ? ` ${name}="${value}"` : "";
64
67
 
68
+ /** V2 schema/table.ts withTableRoles, verbatim: a stacked table's explicit ARIA roles. */
69
+ function withTableRoles(html: string): string {
70
+ return html
71
+ .replace(/<table(?=[\s>])/, '<table role="table"')
72
+ .replace(/<(thead|tbody|tfoot)(?=[\s>])/g, '<$1 role="rowgroup"')
73
+ .replace(/<tr(?=[\s>])/g, '<tr role="row"')
74
+ .replace(/<th scope="(col|colgroup)"/g, '<th role="columnheader" scope="$1"')
75
+ .replace(/<th scope="(row|rowgroup)"/g, '<th role="rowheader" scope="$1"')
76
+ .replace(/<td(?=[\s>])/g, '<td role="cell"');
77
+ }
78
+
65
79
  function tableHtml(table: PortableTextTable): string {
66
80
  const rows: unknown[] = Array.isArray(table.rows) ? table.rows : [];
67
81
  if (rows.length === 0) return "";
68
82
 
69
- const cellHtml = (raw: unknown): string => {
83
+ // "Highlight this column": every cell starting in a marked column (V2 stamps each th/td of it).
84
+ const marked = highlightedColumns(table);
85
+ // "On phones" (stack / cards): each body cell names its column (`data-label`, V2 renderTableHtml), from the last head row
86
+ const design = designOf(table.design);
87
+ const stacked = design?.responsive === "stack" || design?.responsive === "cards";
88
+ const labels: string[] = [];
89
+ if (stacked) {
90
+ const head = tableSections(table).head;
91
+ const last = head[head.length - 1];
92
+ let at = 0;
93
+ for (const c of isRecord(last) && Array.isArray(last.cells) ? last.cells : []) {
94
+ const text = isRecord(c) ? portableTextToPlainText(c.content as PortableText).replace(/\s+/g, " ").trim() : "";
95
+ const w = isRecord(c) && typeof c.span === "number" && Number.isInteger(c.span) && c.span > 1 ? c.span : 1;
96
+ for (let i = 0; i < w; i += 1) labels[at + i] = text;
97
+ at += w;
98
+ }
99
+ }
100
+ const cellHtml = (raw: unknown, col: number): string => {
70
101
  const cell = isRecord(raw) ? raw : {};
71
102
  const tag = cell.header === true ? "th" : "td";
72
103
  // `colspan` before `rowspan`, the order every table in the corpus writes them in: the
73
104
  // comparison is on serialised markup, and a parser preserves the order it was given.
74
- const attrs = `${attr("colspan", cell.span)}${attr("rowspan", cell.rowSpan)}`;
105
+ const label = stacked && tag === "td" ? ` data-label="${esc(labels[col] || `Column ${col + 1}`)}"` : "";
106
+ const attrs = `${attr("colspan", cell.span)}${attr("rowspan", cell.rowSpan)}${label}${marked.has(col) ? ' data-highlight="true"' : ""}`;
75
107
  return `<${tag}${attrs}>${portableTextToHtml(cell.content as PortableText, { inline: true })}</${tag}>`;
76
108
  };
77
109
  const rowHtml = (raw: unknown): string => {
78
110
  const cells = isRecord(raw) && Array.isArray(raw.cells) ? raw.cells : [];
79
- return `<tr>${cells.map(cellHtml).join("")}</tr>`;
111
+ // "Highlight this row": the V2 table's own attribute (src/schema/table.ts rowHtml).
112
+ const highlight = isRecord(raw) && raw.highlight === true ? ' data-highlight="true"' : "";
113
+ let col = 0;
114
+ const html = cells.map((c) => {
115
+ const out = cellHtml(c, col);
116
+ col += isRecord(c) && typeof c.span === "number" && Number.isInteger(c.span) && c.span > 1 ? c.span : 1;
117
+ return out;
118
+ });
119
+ return `<tr${highlight}>${html.join("")}</tr>`;
80
120
  };
81
121
 
82
122
  // The LEADING RUN of head rows, not every row flagged one: `<thead>` is a section, and a
83
123
  // header row appearing after body rows cannot be lifted out of document order without moving
84
124
  // content the author placed.
85
- const headCount = rows.findIndex((r) => !isRecord(r) || r.head !== true);
86
- const head = rows.slice(0, headCount === -1 ? rows.length : headCount);
87
- const body = rows.slice(head.length);
125
+ // Likewise the TRAILING run of footer (totals) rows is `<tfoot>`, after every `<tbody>` (V2).
126
+ const { head, body, foot } = tableSections(table);
127
+ const layout = tableGroupLayout(table);
88
128
  // The platform's own class, on migrated values only — see `PortableTextTable["legacy"]`.
89
129
  const cls = table.legacy === true ? ' class="bcms-table"' : "";
90
- return `<table${cls}>${head.length ? `<thead>${head.map(rowHtml).join("")}</thead>` : ""}${
91
- body.length ? `<tbody>${body.map(rowHtml).join("")}</tbody>` : ""
92
- }</table>`;
130
+ const notes = typeof table.notes === "string" ? table.notes.trim() : "";
131
+ const notesId = notes && typeof table._key === "string" && table._key ? `${table._key}-notes` : "";
132
+ const caption = typeof table.caption === "string" && table.caption.trim() ? `<caption>${esc(table.caption.trim())}</caption>` : "";
133
+
134
+ // Column groups: one `<colgroup>` per run of columns, and a `scope="colgroup"` header over each
135
+ // grouped run, as the first row of `<thead>` (the V2 table's layout).
136
+ // "Visitors can sort columns": one `<col data-sortable="true">` per sortable column, as the V2 table writes it
137
+ // (src/schema/table.ts renderTableHtml); with `<col>`s a column group's `<colgroup>` holds them instead of a `span`.
138
+ const sortable = sortableColumns(table);
139
+ let colAt = 0;
140
+ const cols = (width: number): string => {
141
+ const out = Array.from({ length: width }, (_, i) => (sortable.has(colAt + i) ? '<col data-sortable="true">' : "<col>")).join("");
142
+ colAt += width;
143
+ return out;
144
+ };
145
+ const colgroups = sortable.size
146
+ ? (layout.columns.length ? layout.columns.map((run) => run.width) : [layout.width]).map((w) => `<colgroup>${cols(w)}</colgroup>`).join("")
147
+ : layout.columns.map((run) => `<colgroup${attr("span", run.width)}></colgroup>`).join("");
148
+ const groupRow = layout.columns.length
149
+ ? `<tr>${layout.columns
150
+ .map((run) => (run.label === undefined ? "<td></td>".repeat(run.width) : `<th scope="colgroup"${attr("colspan", run.width)} data-group="${esc(run.key ?? "")}">${esc(run.label)}</th>`))
151
+ .join("")}</tr>`
152
+ : "";
153
+ const thead = groupRow || head.length ? `<thead>${groupRow}${head.map(rowHtml).join("")}</thead>` : "";
154
+
155
+ // Row groups: each run of body rows sharing a group is its own `<tbody>`, under a
156
+ // `scope="rowgroup"` label row. With no grouped row, ONE `<tbody>`, exactly as before.
157
+ const tbody = layout.rows.length
158
+ ? layout.rows
159
+ .map((run) => `<tbody${run.key === undefined ? "" : ` data-key="${esc(run.key)}"`}>${run.label === undefined ? "" : `<tr><th scope="rowgroup"${attr("colspan", layout.width)}>${esc(run.label)}</th></tr>`}${run.rows.map(rowHtml).join("")}</tbody>`)
160
+ .join("")
161
+ : body.length ? `<tbody>${body.map(rowHtml).join("")}</tbody>` : "";
162
+ const tfoot = foot.length ? `<tfoot>${foot.map(rowHtml).join("")}</tfoot>` : "";
163
+
164
+ const plainHtml = `<table${cls}${notesId ? ` aria-describedby="${esc(notesId)}"` : ""}>${caption}${colgroups}${thead}${tbody}${tfoot}</table>`;
165
+ // A note sits UNDER the table, so it needs a container to stay with it: the V2 kit's
166
+ // `<figure data-rt="table">`, which the ingest reads back.
167
+ // "Visitors can search rows" is the figure's `data-filter="true"` (V2 renderTableHtml), so it takes the figure too.
168
+ const filter = table.filterable === true ? ' data-filter="true"' : "";
169
+ // "Help search engines read this as": V2 renderTableHtml's data-jsonld (+ axis, price row) on the figure, after data-filter
170
+ const j = jsonLdOf(table.jsonLd);
171
+ const jsonLd = j ? ` data-jsonld="${j.type}"${j.axis ? ` data-jsonld-axis="${j.axis}"` : ""}${j.priceRow ? ` data-jsonld-price="${esc(j.priceRow)}"` : ""}` : "";
172
+ // "On phones" / "Keep column names in view": V2 renderTableHtml's data-responsive / data-sticky, before data-filter; a
173
+ // stacked table takes V2's withTableRoles (explicit ARIA roles, since display:block drops the implicit ones)
174
+ const designAttrs = design ? `${design.responsive ? ` data-responsive="${design.responsive}"` : ""}${design.sticky ? ' data-sticky="true"' : ""}` : "";
175
+ const html = stacked ? withTableRoles(plainHtml) : plainHtml;
176
+ if (!notes && !filter && !jsonLd && !designAttrs) return html;
177
+ return `<figure data-rt="table"${designAttrs}${filter}${jsonLd}>${html}${notes ? `<p data-notes${notesId ? ` id="${esc(notesId)}"` : ""}>${esc(notes)}</p>` : ""}</figure>`;
93
178
  }
94
179
 
95
180
  /**
@@ -215,6 +300,49 @@ function fileHtml(value: unknown, classes: HtmlClasses): string {
215
300
  return `<a${cls(classes, "a")} href="${esc(src)}" download>${esc(name)}</a>`;
216
301
  }
217
302
 
303
+ /**
304
+ * A pull quote, as the V2 kit renders it (`renderArticle("pullquote")`, article.ts): the same
305
+ * markup when no classes are passed, with one byte-level difference — a `"` in text comes out
306
+ * as `&quot;` here (as everywhere in this serialiser, and as React writes it) where the kit
307
+ * leaves it bare. Both parse to the same DOM, and the kit reads the markup through the DOM, so
308
+ * only a raw string comparison could tell them apart. The `data-rt`, `data-name` and
309
+ * `data-role` hooks are the contract, not decoration.
310
+ *
311
+ * The name links to `link` (and the blockquote cites it) only through `safeHref`; a refused
312
+ * link keeps the name, unlinked.
313
+ */
314
+ function pullQuoteHtml(value: unknown, classes: HtmlClasses): string {
315
+ const v = isRecord(value) ? value : {};
316
+ const quote = typeof v.quote === "string" ? v.quote : "";
317
+ const name = str(v.name);
318
+ const role = str(v.role);
319
+ const href = safeHref(pullQuoteHref(v.link));
320
+ const nameHtml = name
321
+ ? href
322
+ ? `<a href="${esc(href)}" data-name>${esc(name)}</a>`
323
+ : `<span data-name>${esc(name)}</span>`
324
+ : "";
325
+ const roleHtml = role ? `<span data-role>${esc(role)}</span>` : "";
326
+ const caption = nameHtml || roleHtml
327
+ ? `<figcaption>${nameHtml}${nameHtml && roleHtml ? ", " : ""}${roleHtml}</figcaption>`
328
+ : "";
329
+ const cite = href ? ` cite="${esc(href)}"` : "";
330
+ return `<figure${cls(classes, "figure")} data-rt="pullquote"><blockquote${cls(classes, "blockquote")}${cite}><p>${esc(quote)}</p></blockquote>${caption}</figure>`;
331
+ }
332
+
333
+ /**
334
+ * Key takeaways, as the V2 kit renders them (`renderArticle("takeaways")`, article.ts):
335
+ * `<aside data-rt="takeaways"><p data-title>…</p><ul><li>…</li></ul></aside>`, the title only
336
+ * when there is one, each point trimmed and the empty ones left out. The same bytes when no
337
+ * classes are passed, save the pull quote's one difference: a `"` in text is `&quot;` here.
338
+ * A site's own list classes dress the `<ul>` and `<li>`, as they dress its other lists.
339
+ */
340
+ function keyTakeawaysHtml(value: unknown, classes: HtmlClasses): string {
341
+ const { title, points } = keyTakeawaysView(value);
342
+ const items = points.map((p) => `<li${cls(classes, "li")}>${esc(p)}</li>`).join("");
343
+ return `<aside data-rt="takeaways">${title ? `<p data-title>${esc(title)}</p>` : ""}<ul${cls(classes, "ul")}>${items}</ul></aside>`;
344
+ }
345
+
218
346
  export interface RenderOptions {
219
347
  /**
220
348
  * Render a single value with no wrapping `<p>`, for a field whose consumer supplies its own
@@ -303,6 +431,8 @@ const buildComponents = (
303
431
  },
304
432
  [PT_TYPES.video]: ({ value }) => videoHtml(value, classes),
305
433
  [PT_TYPES.file]: ({ value }) => fileHtml(value, classes),
434
+ [PT_TYPES.pullQuote]: ({ value }) => pullQuoteHtml(value, classes),
435
+ [PT_TYPES.keyTakeaways]: ({ value }) => keyTakeawaysHtml(value, classes),
306
436
  /**
307
437
  * A generic embedded block. A block whose payload this serialiser can READ renders as
308
438
  * content; one carrying a reference it would have to resolve against a project renders as a
package/src/index.ts CHANGED
@@ -5,8 +5,10 @@ export * from "./key";
5
5
  export * from "./from-lexical";
6
6
  export * from "./plain";
7
7
  export * from "./table";
8
+ export * from "./key-takeaways";
8
9
  export * from "./styled";
9
10
  export * from "./read";
11
+ export * from "./component-copy";
10
12
  export * from "./accept";
11
13
  export * from "./features";
12
14
  export * from "./video";