@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 +2 -1
- package/src/component-copy.ts +138 -0
- package/src/html.ts +140 -10
- package/src/index.ts +2 -0
- package/src/ingest.ts +333 -28
- package/src/key-takeaways.ts +93 -0
- package/src/plain.ts +55 -7
- package/src/read.ts +19 -1
- package/src/schema.ts +39 -1
- package/src/table.ts +291 -0
- package/src/types.ts +168 -2
package/src/ingest.ts
CHANGED
|
@@ -6,10 +6,15 @@ import {
|
|
|
6
6
|
PT_TYPES,
|
|
7
7
|
type PortableText,
|
|
8
8
|
type PortableTextBlock,
|
|
9
|
+
type PortableTextTable,
|
|
10
|
+
type PortableTextTableGroup,
|
|
11
|
+
type PortableTextTableRow,
|
|
9
12
|
} from "./types";
|
|
10
13
|
import { randomKey, isValidKey, type KeyGenerator } from "./key";
|
|
11
14
|
import { safeHref } from "./href";
|
|
12
15
|
import { BARE_IS_DESIGN, isStyledTag, styledAttrsOf, type StyledMarkDef } from "./styled";
|
|
16
|
+
import { designOf, jsonLdOf, PT_TABLE_LIMITS } from "./table";
|
|
17
|
+
import { PT_KEY_TAKEAWAYS_LIMITS } from "./key-takeaways";
|
|
13
18
|
import type { PtSchema } from "./schema";
|
|
14
19
|
|
|
15
20
|
/**
|
|
@@ -145,44 +150,220 @@ const cellsOf = (row: Element): Element[] =>
|
|
|
145
150
|
[...row.querySelectorAll("th,td")].filter((c) => c.closest("tr") === row);
|
|
146
151
|
|
|
147
152
|
/** A span attribute, recorded and not honoured — see `PortableTextTableCell`. */
|
|
148
|
-
const spanOf = (cell: Element, name: "colspan" | "rowspan"): number | undefined => {
|
|
153
|
+
const spanOf = (cell: Element, name: "colspan" | "rowspan" | "span"): number | undefined => {
|
|
149
154
|
const n = Number(cell.getAttribute(name));
|
|
150
155
|
return Number.isInteger(n) && n > 1 ? n : undefined;
|
|
151
156
|
};
|
|
152
157
|
|
|
158
|
+
/** An element's words, whitespace collapsed. */
|
|
159
|
+
const wordsOf = (el: Element | null | undefined): string => (el?.textContent ?? "").replace(/\s+/g, " ").trim();
|
|
160
|
+
|
|
161
|
+
const scopeOf = (cell: Element): string => (cell.getAttribute("scope") ?? "").toLowerCase();
|
|
162
|
+
const isTag = (el: Element | null | undefined, tag: string): boolean => (el?.nodeName ?? "").toLowerCase() === tag;
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The V2 table's column-group row: in `<thead>`, at least one `<th scope="colgroup">`, and every
|
|
166
|
+
* other cell an EMPTY `<td>` (the corner over ungrouped columns). Anything looser stays a row, so
|
|
167
|
+
* a site's own two-row header keeps round-tripping exactly as it always has.
|
|
168
|
+
*/
|
|
169
|
+
const isColumnGroupRow = (row: Element): boolean => {
|
|
170
|
+
const cells = cellsOf(row);
|
|
171
|
+
return isTag(row.parentElement, "thead")
|
|
172
|
+
&& cells.some((c) => isTag(c, "th") && scopeOf(c) === "colgroup")
|
|
173
|
+
&& cells.every((c) => (isTag(c, "th") && scopeOf(c) === "colgroup") || (isTag(c, "td") && wordsOf(c) === ""));
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The V2 table's row-group label row: one `<th scope="rowgroup">` and nothing else, OPENING its
|
|
178
|
+
* own `<tbody>` with at least one body row after it — the shape V2 and `portableTextToHtml` write.
|
|
179
|
+
* A site's own `scope="rowgroup"` header anywhere else (a lone last row, inside `<thead>`, a
|
|
180
|
+
* second label in the same `<tbody>`) stays an ordinary row, so that markup round-trips as before.
|
|
181
|
+
*/
|
|
182
|
+
const isRowGroupRow = (row: Element): boolean => {
|
|
183
|
+
const cells = cellsOf(row);
|
|
184
|
+
if (cells.length !== 1 || !isTag(cells[0], "th") || scopeOf(cells[0]!) !== "rowgroup") return false;
|
|
185
|
+
const body = row.parentElement;
|
|
186
|
+
if (!body || !isTag(body, "tbody")) return false;
|
|
187
|
+
const bodyRows = [...body.children].filter((r) => isTag(r, "tr"));
|
|
188
|
+
return bodyRows[0] === row && bodyRows.length > 1;
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
/** Ingested text clipped to its stored limit, so a long caption or note never fails the whole write. */
|
|
192
|
+
const clip = (text: string, max: number): string => (text.length > max ? text.slice(0, max).trimEnd() : text);
|
|
193
|
+
|
|
194
|
+
/** A group `_key` the markup carries (`data-group`, a `<tbody data-key>`), or a minted one. */
|
|
195
|
+
const groupKey = (value: string | null | undefined, key: KeyGenerator, taken: Set<string>): string => {
|
|
196
|
+
const own = (value ?? "").trim();
|
|
197
|
+
const k = own && own.length <= 64 && !taken.has(own) ? own : key();
|
|
198
|
+
taken.add(k);
|
|
199
|
+
return k;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* A `<table>` element → the table, or `undefined` when it holds no row with a cell.
|
|
204
|
+
*
|
|
205
|
+
* Caption and groups are read only where the markup says them in the V2 table's own terms
|
|
206
|
+
* (`<caption>`, `th[scope=colgroup]`, `th[scope=rowgroup]`), so a table without them reads exactly
|
|
207
|
+
* as it did before they existed.
|
|
208
|
+
*/
|
|
209
|
+
function readTableElement(el: Element, key: KeyGenerator, ingestCell: (html: string) => PortableText): PortableTextTable | undefined {
|
|
210
|
+
const all = rowsOf(el);
|
|
211
|
+
// The columns a `<col data-sortable="true">` marks: a `<colgroup>` without `<col>`s spans its `span` columns.
|
|
212
|
+
const sortableCols = new Set<number>();
|
|
213
|
+
let colAt = 0;
|
|
214
|
+
for (const group of [...el.children].filter((c) => isTag(c, "colgroup"))) {
|
|
215
|
+
const cols = [...group.children].filter((c) => isTag(c, "col"));
|
|
216
|
+
if (!cols.length) colAt += spanOf(group, "span") ?? 1;
|
|
217
|
+
for (const col of cols) {
|
|
218
|
+
const width = spanOf(col, "span") ?? 1;
|
|
219
|
+
if (col.getAttribute("data-sortable") === "true") for (let i = 0; i < width; i += 1) sortableCols.add(colAt + i);
|
|
220
|
+
colAt += width;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
const groupRow = all.find(isColumnGroupRow);
|
|
224
|
+
const taken = new Set<string>();
|
|
225
|
+
|
|
226
|
+
// Column groups: the column range each `scope="colgroup"` header spans.
|
|
227
|
+
const columnGroups: PortableTextTableGroup[] = [];
|
|
228
|
+
const ranges: { from: number; to: number; key: string }[] = [];
|
|
229
|
+
if (groupRow) {
|
|
230
|
+
let at = 0;
|
|
231
|
+
for (const cell of cellsOf(groupRow)) {
|
|
232
|
+
const width = spanOf(cell, "colspan") ?? 1;
|
|
233
|
+
if (isTag(cell, "th")) {
|
|
234
|
+
const own = cell.getAttribute("data-group");
|
|
235
|
+
// The same group split by an ungrouped column comes back as one group, not two.
|
|
236
|
+
const k = columnGroups.find((g) => own && g._key === own)?._key ?? groupKey(own, key, taken);
|
|
237
|
+
if (!columnGroups.some((g) => g._key === k)) columnGroups.push({ _key: k, label: clip(wordsOf(cell), PT_TABLE_LIMITS.labelChars) });
|
|
238
|
+
ranges.push({ from: at, to: at + width, key: k });
|
|
239
|
+
}
|
|
240
|
+
at += width;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const rowGroups: PortableTextTableGroup[] = [];
|
|
245
|
+
let current: { key: string; body: Element | null } | undefined;
|
|
246
|
+
const rows: PortableTextTableRow[] = [];
|
|
247
|
+
for (const row of all) {
|
|
248
|
+
if (row === groupRow) continue;
|
|
249
|
+
if (isRowGroupRow(row)) {
|
|
250
|
+
const own = row.parentElement?.getAttribute("data-key");
|
|
251
|
+
// The same group split by an ungrouped run comes back as one group, not two.
|
|
252
|
+
const k = rowGroups.find((g) => own && g._key === own)?._key ?? groupKey(own, key, taken);
|
|
253
|
+
if (!rowGroups.some((g) => g._key === k)) rowGroups.push({ _key: k, label: clip(wordsOf(cellsOf(row)[0]), PT_TABLE_LIMITS.labelChars) });
|
|
254
|
+
current = { key: k, body: row.parentElement };
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if (current && current.body !== row.parentElement) current = undefined;
|
|
258
|
+
const first = rows.length === 0;
|
|
259
|
+
// Minted BEFORE the cells, as it always was: a deterministic key sequence is a contract.
|
|
260
|
+
const rowKey = key();
|
|
261
|
+
let at = 0;
|
|
262
|
+
const cells = cellsOf(row).map((cell) => {
|
|
263
|
+
const start = at;
|
|
264
|
+
at += spanOf(cell, "colspan") ?? 1;
|
|
265
|
+
const group = first ? ranges.find((r) => start >= r.from && start < r.to)?.key : undefined;
|
|
266
|
+
return {
|
|
267
|
+
_key: key(),
|
|
268
|
+
content: ingestCell(cell.innerHTML),
|
|
269
|
+
// A header is what the markup SAYS it is, cell by cell. Deriving it from position
|
|
270
|
+
// would promote the first data row of a headerless table, and would lose the row
|
|
271
|
+
// headers that live in a table's first COLUMN.
|
|
272
|
+
...(isTag(cell, "th") ? { header: true } : {}),
|
|
273
|
+
...(spanOf(cell, "colspan") === undefined ? {} : { span: spanOf(cell, "colspan") }),
|
|
274
|
+
...(spanOf(cell, "rowspan") === undefined ? {} : { rowSpan: spanOf(cell, "rowspan") }),
|
|
275
|
+
...(group === undefined ? {} : { group }),
|
|
276
|
+
// "Highlight this column" is named on the column's first-row cell, as `group` is.
|
|
277
|
+
...(first && cell.getAttribute("data-highlight") === "true" ? { highlight: true } : {}),
|
|
278
|
+
// "Visitors can sort columns": the column's `<col data-sortable="true">` (V2 renderTableHtml)
|
|
279
|
+
...(first && sortableCols.has(start) ? { sortable: true } : {}),
|
|
280
|
+
};
|
|
281
|
+
});
|
|
282
|
+
if (cells.length === 0) continue;
|
|
283
|
+
const head = isTag(row.parentElement, "thead");
|
|
284
|
+
// `<tfoot>` is the V2 table's "Totals row", `data-highlight` its "Highlight this row".
|
|
285
|
+
const footer = !head && isTag(row.parentElement, "tfoot");
|
|
286
|
+
const highlight = row.getAttribute("data-highlight") === "true";
|
|
287
|
+
rows.push({
|
|
288
|
+
_key: rowKey,
|
|
289
|
+
cells,
|
|
290
|
+
// `<thead>` is recorded rather than inferred: a browser inserts `<tbody>` on its own
|
|
291
|
+
// and never a `<thead>`, so inventing one at render time would make every bare
|
|
292
|
+
// `<tr><th>` table diverge from its own stored markup.
|
|
293
|
+
...(head ? { head: true } : {}),
|
|
294
|
+
...(current && !head && !footer ? { group: current.key } : {}),
|
|
295
|
+
...(footer ? { footer: true } : {}),
|
|
296
|
+
...(highlight ? { highlight: true } : {}),
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
if (rows.length === 0) return undefined;
|
|
300
|
+
const caption = clip(wordsOf([...el.children].find((c) => isTag(c, "caption"))), PT_TABLE_LIMITS.captionChars);
|
|
301
|
+
// `class="bcms-table"` and nothing else: that class only ever appears in markup the
|
|
302
|
+
// pre-P1c renderer produced, so recognising it is what lets such markup round-trip
|
|
303
|
+
// instead of coming back a class short. Any other class is not representable and the
|
|
304
|
+
// audit reports it, which is the honest answer.
|
|
305
|
+
const legacy = el.getAttribute("class") === "bcms-table";
|
|
306
|
+
return {
|
|
307
|
+
_type: PT_TYPES.table,
|
|
308
|
+
_key: key(),
|
|
309
|
+
rows,
|
|
310
|
+
...(legacy ? { legacy } : {}),
|
|
311
|
+
...(caption ? { caption } : {}),
|
|
312
|
+
...(rowGroups.length ? { rowGroups } : {}),
|
|
313
|
+
...(columnGroups.length ? { columnGroups } : {}),
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
153
317
|
function tableRule(key: KeyGenerator, ingestCell: (html: string) => PortableText): DeserializerRule {
|
|
154
318
|
return {
|
|
155
319
|
deserialize(el: Node, _next, createBlock) {
|
|
156
320
|
if (!isElement(el) || el.nodeName.toLowerCase() !== "table") return undefined;
|
|
157
|
-
const
|
|
158
|
-
.map((row) => ({
|
|
159
|
-
_key: key(),
|
|
160
|
-
cells: cellsOf(row).map((cell) => ({
|
|
161
|
-
_key: key(),
|
|
162
|
-
content: ingestCell(cell.innerHTML),
|
|
163
|
-
// A header is what the markup SAYS it is, cell by cell. Deriving it from position
|
|
164
|
-
// would promote the first data row of a headerless table, and would lose the row
|
|
165
|
-
// headers that live in a table's first COLUMN.
|
|
166
|
-
...(cell.nodeName.toLowerCase() === "th" ? { header: true } : {}),
|
|
167
|
-
...(spanOf(cell, "colspan") === undefined ? {} : { span: spanOf(cell, "colspan") }),
|
|
168
|
-
...(spanOf(cell, "rowspan") === undefined ? {} : { rowSpan: spanOf(cell, "rowspan") }),
|
|
169
|
-
})),
|
|
170
|
-
// `<thead>` is recorded rather than inferred: a browser inserts `<tbody>` on its own
|
|
171
|
-
// and never a `<thead>`, so inventing one at render time would make every bare
|
|
172
|
-
// `<tr><th>` table diverge from its own stored markup.
|
|
173
|
-
...(row.parentElement?.nodeName.toLowerCase() === "thead" ? { head: true } : {}),
|
|
174
|
-
}))
|
|
175
|
-
.filter((r) => r.cells.length > 0);
|
|
176
|
-
if (rows.length === 0) return undefined;
|
|
321
|
+
const table = readTableElement(el, key, ingestCell);
|
|
177
322
|
// `createBlock` is what marks this BLOCK-level. Returning the object bare makes the
|
|
178
323
|
// deserialiser treat it as inline and wrap it in a paragraph — the table survives, nested
|
|
179
324
|
// one level down where no renderer looks for it.
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
325
|
+
return table ? createBlock(table) : undefined;
|
|
326
|
+
},
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** The figure's `data-jsonld` (+ axis, price row) as the table's `jsonLd`, or nothing. */
|
|
331
|
+
function jsonLdIn(el: Element, rowKeys: string[]): { jsonLd?: PortableTextTable["jsonLd"] } {
|
|
332
|
+
const price = el.getAttribute("data-jsonld-price");
|
|
333
|
+
const j = jsonLdOf({ type: el.getAttribute("data-jsonld"), axis: el.getAttribute("data-jsonld-axis") ?? undefined, ...(price && rowKeys.includes(price) ? { priceRow: price } : {}) });
|
|
334
|
+
return j ? { jsonLd: j } : {};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* `<figure data-rt="table">` → the table, with the note under it: the V2 kit's table island
|
|
339
|
+
* (src/schema/table.ts renderTableHtml) and what `portableTextToHtml` writes for a table with
|
|
340
|
+
* `notes`. The note is `[data-notes]`; the kit's footnotes have no place of their own here, so
|
|
341
|
+
* they FOLD INTO the note, numbered as the kit numbers them (`[1] …`), rather than vanish. The
|
|
342
|
+
* table's `_key` comes back from the figure's `data-id` or the note's `id` (`<key>-notes`).
|
|
343
|
+
*/
|
|
344
|
+
function tableFigureRule(key: KeyGenerator, ingestCell: (html: string) => PortableText): DeserializerRule {
|
|
345
|
+
return {
|
|
346
|
+
deserialize(el: Node, _next, createBlock) {
|
|
347
|
+
if (!isElement(el) || el.nodeName.toLowerCase() !== "figure" || el.getAttribute("data-rt") !== "table") return undefined;
|
|
348
|
+
const inner = el.querySelector("table");
|
|
349
|
+
const table = inner ? readTableElement(inner, key, ingestCell) : undefined;
|
|
350
|
+
if (!table) return undefined;
|
|
351
|
+
const noteEl = el.querySelector("[data-notes]");
|
|
352
|
+
const footnotes = [...el.querySelectorAll("[data-footnotes] li")].map((li, i) => `[${i + 1}] ${wordsOf(li)}`);
|
|
353
|
+
// Joined with a space, not a line break: the note renders as one `<p>`, so a break would
|
|
354
|
+
// read back as a space and the value would not survive its own round trip.
|
|
355
|
+
const notes = clip([wordsOf(noteEl), ...footnotes].filter(Boolean).join(" "), PT_TABLE_LIMITS.notesChars);
|
|
356
|
+
const own = [el.getAttribute("data-id"), noteEl?.getAttribute("id")?.replace(/-notes$/, "")].find((k) => isValidKey(k));
|
|
357
|
+
return createBlock({ ...table, ...(own ? { _key: own } : {}), ...(notes ? { notes } : {}), ...(el.getAttribute("data-filter") === "true" ? { filterable: true } : {}),
|
|
358
|
+
// "Help search engines read this as" (V2 parseTableElement: dataset.jsonld, jsonldAxis, jsonldPrice). The price row
|
|
359
|
+
// is kept only when it names a row read here: this renderer writes no row keys, so a round trip falls back to
|
|
360
|
+
// V2's own default (the first row of prices), never to a key that names nothing.
|
|
361
|
+
...jsonLdIn(el, table.rows.map((r) => r._key)),
|
|
362
|
+
// "On phones" / "Keep column names in view" (V2 parseTableElement: dataset.responsive stack|cards, dataset.sticky)
|
|
363
|
+
...(designOf({ responsive: el.getAttribute("data-responsive"), sticky: el.getAttribute("data-sticky") === "true" })
|
|
364
|
+
? { design: designOf({ responsive: el.getAttribute("data-responsive"), sticky: el.getAttribute("data-sticky") === "true" }) }
|
|
365
|
+
: {}),
|
|
366
|
+
});
|
|
186
367
|
},
|
|
187
368
|
};
|
|
188
369
|
}
|
|
@@ -409,6 +590,126 @@ function mediaRule(key: KeyGenerator, schema: PtSchema | undefined): Deserialize
|
|
|
409
590
|
};
|
|
410
591
|
}
|
|
411
592
|
|
|
593
|
+
/**
|
|
594
|
+
* `<figure data-rt="pullquote">` → the `pullQuote` block, the inverse of `pullQuoteHtml`.
|
|
595
|
+
*
|
|
596
|
+
* Read as the V2 kit reads it (`parseArticle("pullquote")`, article.ts): the quote is the
|
|
597
|
+
* blockquote's text (any `<cite>` removed), the name is the caption's `[data-name]` or else the
|
|
598
|
+
* `<cite>`, the role is the caption's `[data-role]`, and the link is the blockquote's `cite` or
|
|
599
|
+
* the name's `href`. With no figcaption at all, a `<cite>Name, role</cite>` splits on the first
|
|
600
|
+
* comma (the kit's legacy rule). One step past the kit, so nothing is dropped silently: a
|
|
601
|
+
* figcaption with neither hook nor `<cite>` keeps its whole text as the name (the kit reads
|
|
602
|
+
* `""` there). Only the marked figure takes this rule — a bare `<blockquote>` stays the
|
|
603
|
+
* blockquote STYLE it has always been.
|
|
604
|
+
*/
|
|
605
|
+
function pullQuoteRule(key: KeyGenerator): DeserializerRule {
|
|
606
|
+
const text = (el: Element | null | undefined): string => (el?.textContent ?? "").replace(/\s+/g, " ").trim();
|
|
607
|
+
return {
|
|
608
|
+
deserialize(el: Node, _next, createBlock) {
|
|
609
|
+
if (!isElement(el) || el.nodeName.toLowerCase() !== "figure" || el.getAttribute("data-rt") !== "pullquote") {
|
|
610
|
+
return undefined;
|
|
611
|
+
}
|
|
612
|
+
const bq = el.querySelector("blockquote");
|
|
613
|
+
const quoteEl = bq?.cloneNode(true) as Element | undefined;
|
|
614
|
+
quoteEl?.querySelectorAll("cite").forEach((c) => c.remove());
|
|
615
|
+
const cap = el.querySelector("figcaption");
|
|
616
|
+
const nameEl = cap?.querySelector("[data-name]") ?? el.querySelector("blockquote > cite, cite");
|
|
617
|
+
const roleEl = cap?.querySelector("[data-role]") ?? null;
|
|
618
|
+
let name = text(nameEl);
|
|
619
|
+
let role = text(roleEl);
|
|
620
|
+
if (!cap && name.includes(",")) {
|
|
621
|
+
const comma = name.indexOf(",");
|
|
622
|
+
[name, role] = [name.slice(0, comma).trim(), name.slice(comma + 1).trim()];
|
|
623
|
+
}
|
|
624
|
+
if (cap && !nameEl && !roleEl) name = text(cap);
|
|
625
|
+
const rawLink = (bq?.getAttribute("cite") ?? nameEl?.getAttribute("href") ?? "").trim();
|
|
626
|
+
const link = rawLink ? safeHref(rawLink) : null;
|
|
627
|
+
return createBlock({
|
|
628
|
+
_type: PT_TYPES.pullQuote,
|
|
629
|
+
_key: key(),
|
|
630
|
+
quote: text(quoteEl),
|
|
631
|
+
name,
|
|
632
|
+
...(role ? { role } : {}),
|
|
633
|
+
...(link ? { link } : {}),
|
|
634
|
+
});
|
|
635
|
+
},
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Key takeaways → the `keyTakeaways` block, the inverse of `keyTakeawaysHtml`. Two sources:
|
|
641
|
+
*
|
|
642
|
+
* · V2's own markup, `<aside data-rt="takeaways">`, read as V2 reads it (`parseArticle
|
|
643
|
+
* ("takeaways")`, article.ts): the title is `[data-title]`'s text, each point an `<li>`'s.
|
|
644
|
+
* · A site's own: an `<aside>`, `<div>` or `<section>` that SAYS it holds takeaways — a class
|
|
645
|
+
* or id ENDING in "takeaways" (`takeaways`, `key-takeaways`, `keyTakeaways`,
|
|
646
|
+
* `article__takeaways`; never a `takeaway-menu`), or an `aria-label` of "Takeaways" or "Key
|
|
647
|
+
* takeaways" — holding an optional title (a heading, `<p>`, `<strong>` or `<b>`) and then ONE
|
|
648
|
+
* bulleted list, and nothing else.
|
|
649
|
+
*
|
|
650
|
+
* 🔴 NO FALSE POSITIVES, AND NOTHING LOST. An ordinary list is never takeaways: only a marked
|
|
651
|
+
* container is read, and a `<ul>` alone is a list as it always was. And the block is taken only
|
|
652
|
+
* when it holds the whole container: a site's point that carries a link, an image, a nested list
|
|
653
|
+
* bold, italic or classed words, or other markup the block's plain strings cannot keep, a numbered list, text loose beside the
|
|
654
|
+
* list, more than V2's 8 points or a title over 40 characters — each leaves the container to the
|
|
655
|
+
* deserialiser, which reads it as the title paragraph and the list it was, word for word.
|
|
656
|
+
*/
|
|
657
|
+
const TAKEAWAYS_NAME = /^(?:[a-z0-9]+(?:-|_|__))*(?:key[-_]?)?takeaways$/i;
|
|
658
|
+
const TAKEAWAYS_LABEL = /^(?:key )?takeaways$/i;
|
|
659
|
+
const TITLE_TAGS = new Set(["h1", "h2", "h3", "h4", "h5", "h6", "p", "strong", "b"]);
|
|
660
|
+
/**
|
|
661
|
+
* Bare inline wrappers a title may sit in (the title renders as its own label, so its weight is the block's). A point
|
|
662
|
+
* is a plain string and keeps no formatting, so a point may sit only in a bare `<span>`: a bold or italic word, or any
|
|
663
|
+
* wrapper with a class or attribute, is design the list item keeps and the block would lose.
|
|
664
|
+
*/
|
|
665
|
+
const TITLE_INLINE = new Set(["span", "strong", "b", "em", "i"]);
|
|
666
|
+
const POINT_INLINE = new Set(["span"]);
|
|
667
|
+
|
|
668
|
+
function keyTakeawaysRule(key: KeyGenerator): DeserializerRule {
|
|
669
|
+
const words = (el: Element | null | undefined): string => (el?.textContent ?? "").replace(/\s+/g, " ").trim();
|
|
670
|
+
const tag = (el: Element): string => el.nodeName.toLowerCase();
|
|
671
|
+
/** Only text and bare wrappers from `allowed`, at any depth. */
|
|
672
|
+
const plain = (el: Element, allowed: ReadonlySet<string>): boolean =>
|
|
673
|
+
[...el.children].every((c) => allowed.has(tag(c)) && c.attributes.length === 0 && plain(c, allowed));
|
|
674
|
+
const fits = (title: string, points: string[]): boolean =>
|
|
675
|
+
title.length <= PT_KEY_TAKEAWAYS_LIMITS.titleChars && points.length <= PT_KEY_TAKEAWAYS_LIMITS.points;
|
|
676
|
+
const block = (title: string, points: string[]) => ({
|
|
677
|
+
_type: PT_TYPES.keyTakeaways,
|
|
678
|
+
_key: key(),
|
|
679
|
+
...(title ? { title } : {}),
|
|
680
|
+
points,
|
|
681
|
+
});
|
|
682
|
+
/** A site's container → its title and points, or null when it is anything but those. */
|
|
683
|
+
const siteTakeaways = (el: Element): { title: string; points: string[] } | null => {
|
|
684
|
+
const names = [...(el.getAttribute("class") ?? "").split(/\s+/), el.getAttribute("id") ?? ""];
|
|
685
|
+
const marked = names.some((n) => TAKEAWAYS_NAME.test(n)) || TAKEAWAYS_LABEL.test((el.getAttribute("aria-label") ?? "").trim());
|
|
686
|
+
if (!marked) return null;
|
|
687
|
+
if ([...el.childNodes].some((n) => n.nodeType === 3 && (n.textContent ?? "").trim() !== "")) return null;
|
|
688
|
+
const kids = [...el.children];
|
|
689
|
+
const list = kids[kids.length - 1];
|
|
690
|
+
if (!list || tag(list) !== "ul" || kids.length > 2) return null;
|
|
691
|
+
const head = kids.length === 2 ? kids[0]! : null;
|
|
692
|
+
if (head && (!TITLE_TAGS.has(tag(head)) || !plain(head, TITLE_INLINE))) return null;
|
|
693
|
+
const items = [...list.children];
|
|
694
|
+
if (items.length === 0 || items.some((li) => tag(li) !== "li" || !plain(li, POINT_INLINE))) return null;
|
|
695
|
+
return { title: words(head), points: items.map((li) => words(li)).filter(Boolean) };
|
|
696
|
+
};
|
|
697
|
+
return {
|
|
698
|
+
deserialize(el: Node, _next, createBlock) {
|
|
699
|
+
if (!isElement(el)) return undefined;
|
|
700
|
+
const t = tag(el);
|
|
701
|
+
if (t !== "aside" && t !== "div" && t !== "section") return undefined;
|
|
702
|
+
if (t === "aside" && el.getAttribute("data-rt") === "takeaways") {
|
|
703
|
+
const title = words(el.querySelector("[data-title]"));
|
|
704
|
+
const points = [...el.querySelectorAll("li")].map((li) => words(li)).filter(Boolean);
|
|
705
|
+
return fits(title, points) ? createBlock(block(title, points)) : undefined;
|
|
706
|
+
}
|
|
707
|
+
const site = siteTakeaways(el);
|
|
708
|
+
return site && fits(site.title, site.points) ? createBlock(block(site.title, site.points)) : undefined;
|
|
709
|
+
},
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
|
|
412
713
|
/**
|
|
413
714
|
* Does this field's schema carry the `styled` annotation?
|
|
414
715
|
*
|
|
@@ -449,8 +750,12 @@ export function htmlToPortableText(html: string, opts: IngestOptions = {}): Port
|
|
|
449
750
|
* accepts only images.
|
|
450
751
|
*/
|
|
451
752
|
rules: [
|
|
452
|
-
...(allowsBlockObject(opts.schema, PT_TYPES.table)
|
|
753
|
+
...(allowsBlockObject(opts.schema, PT_TYPES.table)
|
|
754
|
+
? [tableFigureRule(key, (cell) => htmlToPortableText(cell, opts)), tableRule(key, (cell) => htmlToPortableText(cell, opts))]
|
|
755
|
+
: []),
|
|
453
756
|
...(allowsBlockObject(opts.schema, PT_TYPES.code) ? [codeRule(key)] : []),
|
|
757
|
+
...(allowsBlockObject(opts.schema, PT_TYPES.pullQuote) ? [pullQuoteRule(key)] : []),
|
|
758
|
+
...(allowsBlockObject(opts.schema, PT_TYPES.keyTakeaways) ? [keyTakeawaysRule(key)] : []),
|
|
454
759
|
mediaRule(key, opts.schema),
|
|
455
760
|
...(allowsStyled(opts.schema) ? [styledRule(key)] : []),
|
|
456
761
|
...(allowsLinks(opts.schema) ? [classedLinkRule(key)] : []),
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { PT_TYPES } from "./types";
|
|
2
|
+
import { isPortableTextTable } from "./table";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Key takeaways (`PortableTextKeyTakeaways`, types.ts): the limits, the write check, and the
|
|
6
|
+
* read every renderer shares.
|
|
7
|
+
*
|
|
8
|
+
* The limits are the V2 kit's (`ARTICLE_SCHEMAS.takeaways` in `controls/rich-text/article.ts`):
|
|
9
|
+
* a title of at most 40 characters and at most 8 points. V2's `min: 1` on points is the
|
|
10
|
+
* editor's, not the write's: its editor always holds a line to type in, and the value it renders
|
|
11
|
+
* while that line is still empty has no point in it. Refusing that would refuse an autosave
|
|
12
|
+
* mid-typing, so an empty list stores, and renders as V2 renders it.
|
|
13
|
+
*/
|
|
14
|
+
export const PT_KEY_TAKEAWAYS_LIMITS = { titleChars: 40, points: 8 } as const;
|
|
15
|
+
|
|
16
|
+
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
|
17
|
+
!!v && typeof v === "object" && !Array.isArray(v);
|
|
18
|
+
|
|
19
|
+
/** One reason a stored key-takeaways block is refused, at its own path inside the block. */
|
|
20
|
+
export type PortableTextKeyTakeawaysProblem = { path: Array<string | number>; code: string; message: string };
|
|
21
|
+
|
|
22
|
+
const KEYS = new Set(["_type", "_key", "title", "points"]);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Every reason `node` cannot be stored, each named with a code: `key_takeaways_invalid` (a
|
|
26
|
+
* shape problem) or `key_takeaways_limit_exceeded` (a V2 limit).
|
|
27
|
+
*
|
|
28
|
+
* Only a `keyTakeaways` block is judged. The type is new, so no value stored before it existed
|
|
29
|
+
* can hold one: there is nothing to grandfather.
|
|
30
|
+
*/
|
|
31
|
+
export function keyTakeawaysProblems(node: unknown): PortableTextKeyTakeawaysProblem[] {
|
|
32
|
+
const out: PortableTextKeyTakeawaysProblem[] = [];
|
|
33
|
+
if (!isRecord(node) || node._type !== PT_TYPES.keyTakeaways) return out;
|
|
34
|
+
const bad = (path: Array<string | number>, message: string, code = "key_takeaways_invalid") =>
|
|
35
|
+
out.push({ path, code, message: `${code}: ${message}` });
|
|
36
|
+
for (const k of Object.keys(node)) if (!KEYS.has(k)) bad([k], `unknown key "${k}" (title, points)`);
|
|
37
|
+
if (node.title !== undefined) {
|
|
38
|
+
if (typeof node.title !== "string") bad(["title"], "title must be a string");
|
|
39
|
+
else if (node.title.length > PT_KEY_TAKEAWAYS_LIMITS.titleChars) {
|
|
40
|
+
bad(["title"], `title is ${node.title.length} characters; at most ${PT_KEY_TAKEAWAYS_LIMITS.titleChars}`, "key_takeaways_limit_exceeded");
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (!Array.isArray(node.points)) {
|
|
44
|
+
bad(["points"], "points must be an array of strings");
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
node.points.forEach((p, i) => {
|
|
48
|
+
if (typeof p !== "string") bad(["points", i], `points[${i}] must be a string`);
|
|
49
|
+
});
|
|
50
|
+
if (node.points.length > PT_KEY_TAKEAWAYS_LIMITS.points) {
|
|
51
|
+
bad(["points"], `${node.points.length} points; at most ${PT_KEY_TAKEAWAYS_LIMITS.points}`, "key_takeaways_limit_exceeded");
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* `keyTakeawaysProblems` for every key-takeaways block in a rich-text value — top level, and
|
|
58
|
+
* inside table cells — with the block's `_key` so a refusal names the block an author has to find.
|
|
59
|
+
*/
|
|
60
|
+
export function portableTextKeyTakeawaysProblems(value: unknown): Array<PortableTextKeyTakeawaysProblem & { block: string }> {
|
|
61
|
+
const out: Array<PortableTextKeyTakeawaysProblem & { block: string }> = [];
|
|
62
|
+
const walk = (blocks: unknown) => {
|
|
63
|
+
if (!Array.isArray(blocks)) return;
|
|
64
|
+
for (const node of blocks) {
|
|
65
|
+
if (isPortableTextTable(node)) {
|
|
66
|
+
for (const row of node.rows as unknown[]) {
|
|
67
|
+
if (!isRecord(row) || !Array.isArray(row.cells)) continue;
|
|
68
|
+
for (const cell of row.cells) if (isRecord(cell)) walk(cell.content);
|
|
69
|
+
}
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (!isRecord(node) || node._type !== PT_TYPES.keyTakeaways) continue;
|
|
73
|
+
const block = typeof node._key === "string" ? node._key : "";
|
|
74
|
+
for (const p of keyTakeawaysProblems(node)) out.push({ ...p, block });
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
walk(value);
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* What a reader sees of a key-takeaways block: the title, trimmed ("" for none), and the points,
|
|
83
|
+
* each trimmed, the empty ones left out (V2 `renderArticle("takeaways")`). Read defensively: a
|
|
84
|
+
* value that never passed the write check renders what it can.
|
|
85
|
+
*/
|
|
86
|
+
export function keyTakeawaysView(value: unknown): { title: string; points: string[] } {
|
|
87
|
+
const v = isRecord(value) ? value : {};
|
|
88
|
+
const title = typeof v.title === "string" ? v.title.trim() : "";
|
|
89
|
+
const points = (Array.isArray(v.points) ? v.points : [])
|
|
90
|
+
.map((p) => (typeof p === "string" ? p.trim() : ""))
|
|
91
|
+
.filter(Boolean);
|
|
92
|
+
return { title, points };
|
|
93
|
+
}
|
package/src/plain.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { isPortableTextBlock, isPortableTextSpan, PT_TYPES, type PortableText, type PortableTextTable } from "./types";
|
|
2
|
-
import { migrateTable } from "./table";
|
|
2
|
+
import { migrateTable, tableGroupLayout } from "./table";
|
|
3
|
+
import { keyTakeawaysView } from "./key-takeaways";
|
|
4
|
+
import { componentCopyLines, detachedComponentCopy } from "./component-copy";
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
7
|
* Portable Text → plain text.
|
|
@@ -30,14 +32,34 @@ export function portableTextToPlainText(value: PortableText | null | undefined):
|
|
|
30
32
|
* over values that never passed through the reader, the corpus audit's above all.
|
|
31
33
|
* `migrateTable` answers null for everything else, so the cost of asking is one comparison.
|
|
32
34
|
*
|
|
33
|
-
* Every other embedded block — an image, an embed, a component — resolves against a
|
|
34
|
-
* this function does not have, so it still contributes nothing.
|
|
35
|
+
* Every other embedded block — an image, an embed, a LINKED component — resolves against a
|
|
36
|
+
* project this function does not have, so it still contributes nothing.
|
|
35
37
|
*/
|
|
38
|
+
/**
|
|
39
|
+
* A pull quote is words a reader sees — the quote, then who said it — so search and the
|
|
40
|
+
* audit count them. The attribution line is the one the Markdown export writes.
|
|
41
|
+
*/
|
|
42
|
+
if (node._type === PT_TYPES.pullQuote) {
|
|
43
|
+
lines.push(...pullQuoteLines(node as Record<string, unknown>));
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
/** Key takeaways are words a reader sees too: the title, then one line per point. */
|
|
47
|
+
if (node._type === PT_TYPES.keyTakeaways) {
|
|
48
|
+
const { title, points } = keyTakeawaysView(node);
|
|
49
|
+
lines.push(...(title ? [title] : []), ...points);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
36
52
|
const table = migrateTable(node);
|
|
37
53
|
if (table) {
|
|
38
54
|
lines.push(...tableLines(table));
|
|
39
55
|
continue;
|
|
40
56
|
}
|
|
57
|
+
// A separate copy of a component holds its own words (a linked one's live in the component).
|
|
58
|
+
const copy = detachedComponentCopy(node);
|
|
59
|
+
if (copy) {
|
|
60
|
+
lines.push(...componentCopyLines(copy));
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
41
63
|
// Dividers and other embedded blocks contribute no text, which is what a reader wants.
|
|
42
64
|
}
|
|
43
65
|
return lines.join("\n");
|
|
@@ -51,10 +73,36 @@ export function portableTextToPlainText(value: PortableText | null | undefined):
|
|
|
51
73
|
* because a row is a LINE and a cell that broke it would make the audit's before/after
|
|
52
74
|
* comparison depend on where a paragraph happened to fall.
|
|
53
75
|
*/
|
|
54
|
-
const tableLines = (table: PortableTextTable): string[] =>
|
|
55
|
-
|
|
76
|
+
const tableLines = (table: PortableTextTable): string[] => {
|
|
77
|
+
const rows = Array.isArray(table.rows) ? table.rows : [];
|
|
78
|
+
const rowLine = (row: (typeof rows)[number]): string =>
|
|
56
79
|
(Array.isArray(row?.cells) ? row.cells : [])
|
|
57
80
|
.map((cell) => portableTextToPlainText(cell?.content).replace(/\s+/g, " ").trim())
|
|
58
81
|
.join(" ")
|
|
59
|
-
.trim()
|
|
60
|
-
|
|
82
|
+
.trim();
|
|
83
|
+
/**
|
|
84
|
+
* Caption, column-group labels, then the rows with each row group's label before its run, then
|
|
85
|
+
* the note: the order the html draws them in. A table without them is one line per row, as
|
|
86
|
+
* before they existed.
|
|
87
|
+
*/
|
|
88
|
+
const layout = tableGroupLayout(table);
|
|
89
|
+
const one = (s: unknown): string => (typeof s === "string" ? s.replace(/\s+/g, " ").trim() : "");
|
|
90
|
+
const head = layout.rows.length ? rows.slice(0, rows.length - layout.rows.reduce((n, r) => n + r.rows.length, 0)) : rows;
|
|
91
|
+
const lines = [
|
|
92
|
+
one(table.caption),
|
|
93
|
+
layout.columns.map((r) => one(r.label)).filter(Boolean).join(" "),
|
|
94
|
+
...head.map(rowLine),
|
|
95
|
+
...layout.rows.flatMap((run) => [...(run.label === undefined ? [] : [one(run.label)]), ...(run.rows as typeof rows).map(rowLine)]),
|
|
96
|
+
one(table.notes),
|
|
97
|
+
];
|
|
98
|
+
// Only the NEW lines are dropped when empty: an empty row has always contributed its "" line.
|
|
99
|
+
const extras = new Set([0, 1, lines.length - 1]);
|
|
100
|
+
return lines.filter((l, i) => l !== "" || !extras.has(i));
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/** The quote, then `— name, role` when either is set. */
|
|
104
|
+
const pullQuoteLines = (v: Record<string, unknown>): string[] => {
|
|
105
|
+
const text = (x: unknown): string => (typeof x === "string" ? x.trim() : "");
|
|
106
|
+
const by = [text(v.name), text(v.role)].filter(Boolean).join(", ");
|
|
107
|
+
return [text(v.quote), ...(by ? [`— ${by}`] : [])].filter(Boolean);
|
|
108
|
+
};
|
package/src/read.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { EMBEDDED_COMPONENT_KEY, PORTABLE_TEXT_FORMAT, PT_TYPES, type PortableText } from "./types";
|
|
2
2
|
import { lexicalToPortableText } from "./from-lexical";
|
|
3
3
|
import { migrateTable } from "./table";
|
|
4
|
+
import { detachedComponentCopy, type DetachedComponentCopy } from "./component-copy";
|
|
4
5
|
import { randomKey, type KeyGenerator } from "./key";
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -243,10 +244,13 @@ export interface EmbeddedComponentBlock {
|
|
|
243
244
|
/**
|
|
244
245
|
* A component embedded in rich text, as the ordinary `component` block every renderer already
|
|
245
246
|
* draws — so an embed gets the same resolver, cycle guard and identity attributes as a placed
|
|
246
|
-
* instance. Null for any other node.
|
|
247
|
+
* instance. Null for any other node, and for a separate copy (`fields.detached`): a copy is not
|
|
248
|
+
* a reference, and resolving its `componentId` would draw the shared component it left.
|
|
249
|
+
* `embeddedComponentRender` answers for both.
|
|
247
250
|
*/
|
|
248
251
|
export function embeddedComponentBlock(value: unknown): EmbeddedComponentBlock | null {
|
|
249
252
|
if (!isRecord(value) || value.schemaKey !== EMBEDDED_COMPONENT_KEY || !isRecord(value.fields)) return null;
|
|
253
|
+
if (value.fields.detached !== undefined) return null;
|
|
250
254
|
const { componentId, overrides } = value.fields;
|
|
251
255
|
return {
|
|
252
256
|
type: "component",
|
|
@@ -254,3 +258,17 @@ export function embeddedComponentBlock(value: unknown): EmbeddedComponentBlock |
|
|
|
254
258
|
props: { componentId: String(componentId ?? ""), overrides: isRecord(overrides) ? overrides : {} },
|
|
255
259
|
};
|
|
256
260
|
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* What an embedded component renders as: a linked one, its `component` block (resolved by the
|
|
264
|
+
* host); a separate copy, its own frozen blocks, which need no lookup. Null for any other node,
|
|
265
|
+
* and for a copy whose blocks are missing (the write refuses one; a reader draws nothing for it).
|
|
266
|
+
*/
|
|
267
|
+
export function embeddedComponentRender(
|
|
268
|
+
value: unknown,
|
|
269
|
+
): { kind: "linked"; block: EmbeddedComponentBlock } | { kind: "copy"; copy: DetachedComponentCopy } | null {
|
|
270
|
+
const block = embeddedComponentBlock(value);
|
|
271
|
+
if (block) return { kind: "linked", block };
|
|
272
|
+
const copy = detachedComponentCopy(value);
|
|
273
|
+
return copy ? { kind: "copy", copy } : null;
|
|
274
|
+
}
|