@bettercms-ai/richtext 0.5.0 → 0.6.1
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 +1 -1
- package/src/html.ts +51 -14
- package/src/ingest.ts +40 -6
- package/src/schema.ts +2 -0
- package/src/styled.ts +16 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bettercms-ai/richtext",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
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.",
|
package/src/html.ts
CHANGED
|
@@ -230,6 +230,9 @@ export interface RenderOptions {
|
|
|
230
230
|
* Wrapping at the top level rather than inside the block components is deliberate: a block
|
|
231
231
|
* component only ever sees TEXT blocks, so a divider or an embedded image would get no
|
|
232
232
|
* address — and those are exactly the nodes an editor most wants to click.
|
|
233
|
+
*
|
|
234
|
+
* List items are the one exception: a run of them renders as ONE `<ul>`/`<ol>` and the address
|
|
235
|
+
* sits on each `<li>`, because a wrapper per item would split the list into one-item lists.
|
|
233
236
|
*/
|
|
234
237
|
bindingBase?: string;
|
|
235
238
|
}
|
|
@@ -253,10 +256,27 @@ const tagOverrides = <T extends string>(
|
|
|
253
256
|
return out;
|
|
254
257
|
};
|
|
255
258
|
|
|
256
|
-
|
|
259
|
+
/** The per-node address `portableTextToHtml({ bindingBase })` stamps. "" for a node with no key. */
|
|
260
|
+
const bindingAttrs = (base: string, key: unknown): string =>
|
|
261
|
+
typeof key === "string" && key
|
|
262
|
+
? ` data-bcms-field="${esc(base)}#${esc(key)}" data-bcms-kind="richtext"`
|
|
263
|
+
: "";
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* `keys` is the set of `_key`s the value actually carries: to-html invents one (`li-0-1`) for an
|
|
267
|
+
* item that has none, and an invented key is an address that resolves to nothing.
|
|
268
|
+
*/
|
|
269
|
+
const buildComponents = (
|
|
270
|
+
classes: HtmlClasses = {},
|
|
271
|
+
binding?: { base: string; keys: ReadonlySet<unknown> },
|
|
272
|
+
): Partial<PortableTextHtmlComponents> => ({
|
|
257
273
|
block: tagOverrides(classes, STYLE_TAGS),
|
|
258
274
|
list: tagOverrides(classes, { bullet: "ul", number: "ol", check: "ul" }),
|
|
259
|
-
|
|
275
|
+
// With a base, every item carries its own address ON the `<li>` — see `bindingBase`.
|
|
276
|
+
listItem: binding
|
|
277
|
+
? ({ value, children }: { value: { _key?: unknown }; children?: string }) =>
|
|
278
|
+
`<li${cls(classes, "li")}${binding.keys.has(value._key) ? bindingAttrs(binding.base, value._key) : ""}>${children ?? ""}</li>`
|
|
279
|
+
: tagOverrides(classes, { bullet: "li", number: "li", check: "li" }),
|
|
260
280
|
types: {
|
|
261
281
|
[PT_TYPES.divider]: () => "<hr>",
|
|
262
282
|
[PT_TYPES.table]: ({ value }) => tableHtml(value as PortableTextTable),
|
|
@@ -312,7 +332,7 @@ const buildComponents = (classes: HtmlClasses = {}): Partial<PortableTextHtmlCom
|
|
|
312
332
|
* renders the LABEL rather than deleting it — a blocked link must not lose the words.
|
|
313
333
|
*/
|
|
314
334
|
[PT_TYPES.link]: ({ value, children }) => {
|
|
315
|
-
const v = (value ?? {}) as { href?: unknown; target?: unknown; rel?: unknown };
|
|
335
|
+
const v = (value ?? {}) as { href?: unknown; target?: unknown; rel?: unknown; className?: unknown };
|
|
316
336
|
const href = typeof v.href === "string" ? safeHref(v.href) : null;
|
|
317
337
|
if (!href) return children;
|
|
318
338
|
// `rel` is author data too (the importer keeps `prev`/`next` on pagination links), so only
|
|
@@ -326,7 +346,9 @@ const buildComponents = (classes: HtmlClasses = {}): Partial<PortableTextHtmlCom
|
|
|
326
346
|
rel.add("noreferrer");
|
|
327
347
|
}
|
|
328
348
|
const relAttr = rel.size ? ` rel="${esc([...rel].join(" "))}"` : "";
|
|
329
|
-
|
|
349
|
+
// The link's own class (kept on ingest) wins over the caller's default: it IS the site.
|
|
350
|
+
const own = typeof v.className === "string" && v.className ? ` class="${esc(v.className)}"` : "";
|
|
351
|
+
return `<a${own || cls(classes, "a")} href="${esc(href)}"${target}${relAttr}>${children}</a>`;
|
|
330
352
|
},
|
|
331
353
|
/**
|
|
332
354
|
* The site's own inline markup, written back out as itself.
|
|
@@ -362,6 +384,9 @@ const buildComponents = (classes: HtmlClasses = {}): Partial<PortableTextHtmlCom
|
|
|
362
384
|
/** The undressed components, built once: the overwhelming majority of calls pass no classes. */
|
|
363
385
|
const defaultComponents = buildComponents();
|
|
364
386
|
|
|
387
|
+
const isListItem = (node: unknown): boolean =>
|
|
388
|
+
isRecord(node) && node._type === "block" && typeof node.listItem === "string" && !!node.listItem;
|
|
389
|
+
|
|
365
390
|
export function portableTextToHtml(
|
|
366
391
|
value: PortableText | null | undefined,
|
|
367
392
|
opts: RenderOptions = {},
|
|
@@ -370,16 +395,28 @@ export function portableTextToHtml(
|
|
|
370
395
|
const components = opts.classes ? buildComponents(opts.classes) : defaultComponents;
|
|
371
396
|
|
|
372
397
|
if (opts.bindingBase) {
|
|
373
|
-
const base =
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
398
|
+
const base = opts.bindingBase;
|
|
399
|
+
const bound = buildComponents(opts.classes, { base, keys: new Set(value.map((n) => n._key)) });
|
|
400
|
+
const out: string[] = [];
|
|
401
|
+
for (let i = 0; i < value.length; ) {
|
|
402
|
+
const node = value[i];
|
|
403
|
+
// A RUN of list items renders as ONE list, each `<li>` carrying its own address. Wrapping
|
|
404
|
+
// items one by one gave every item its own `<ol>`, so numbering restarted at 1 on every line
|
|
405
|
+
// and bullets sat a list-margin apart — the canvas no longer looked like the site.
|
|
406
|
+
if (isListItem(node)) {
|
|
407
|
+
let end = i + 1;
|
|
408
|
+
while (end < value.length && isListItem(value[end])) end++;
|
|
409
|
+
out.push(toHTML(value.slice(i, end) as never, { components: bound }));
|
|
410
|
+
i = end;
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
const inner = toHTML([node] as never, { components });
|
|
414
|
+
// A node with no key cannot be addressed. Render it rather than dropping it — an
|
|
415
|
+
// unaddressable paragraph is a small loss, a missing one is content disappearing.
|
|
416
|
+
out.push(node._key ? `<div${bindingAttrs(base, node._key)}>${inner}</div>` : inner);
|
|
417
|
+
i++;
|
|
418
|
+
}
|
|
419
|
+
return out.join("");
|
|
383
420
|
}
|
|
384
421
|
|
|
385
422
|
const html = toHTML(value as never, { components });
|
package/src/ingest.ts
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
} from "./types";
|
|
10
10
|
import { randomKey, isValidKey, type KeyGenerator } from "./key";
|
|
11
11
|
import { safeHref } from "./href";
|
|
12
|
-
import { isStyledTag, styledAttrsOf, type StyledMarkDef } from "./styled";
|
|
12
|
+
import { BARE_IS_DESIGN, isStyledTag, styledAttrsOf, type StyledMarkDef } from "./styled";
|
|
13
13
|
import type { PtSchema } from "./schema";
|
|
14
14
|
|
|
15
15
|
/**
|
|
@@ -197,11 +197,12 @@ function tableRule(key: KeyGenerator, ingestCell: (html: string) => PortableText
|
|
|
197
197
|
* for exactly that reason.
|
|
198
198
|
*
|
|
199
199
|
* A `<span>` with nothing on it is NOT annotated: it carries no design, so recording it would
|
|
200
|
-
* mint a markDef per stray wrapper and bloat every value for nothing.
|
|
201
|
-
* annotated bare, because there the TAG is the design
|
|
200
|
+
* mint a markDef per stray wrapper and bloat every value for nothing. `<small>`, `<sup>`,
|
|
201
|
+
* `<kbd>` … are annotated bare, because there the TAG is the design.
|
|
202
202
|
*
|
|
203
|
-
* Decorator tags (`b/strong`, `i/em`, `u`, `s`, `mark`, `code
|
|
204
|
-
*
|
|
203
|
+
* Decorator tags (`b/strong`, `i/em`, `u`, `s`, `mark`, `code`, …) take this rule ONLY when
|
|
204
|
+
* they carry a class or an admitted attribute — the one thing their decorator cannot hold.
|
|
205
|
+
* Bare, they fall through to the standard rules and become the decorator as before.
|
|
205
206
|
*
|
|
206
207
|
* `__annotation` is the deserialiser's own internal placeholder — the same one its built-in
|
|
207
208
|
* link rule returns — so nesting composes for free: `<strong><span class>` comes back as one
|
|
@@ -215,7 +216,7 @@ function styledRule(key: KeyGenerator): DeserializerRule {
|
|
|
215
216
|
if (!isStyledTag(tag)) return undefined;
|
|
216
217
|
const className = el.getAttribute("class")?.trim() || undefined;
|
|
217
218
|
const attrs = styledAttrsOf(el);
|
|
218
|
-
if (
|
|
219
|
+
if (!className && !attrs && !BARE_IS_DESIGN.has(tag)) return undefined;
|
|
219
220
|
const markDef: StyledMarkDef = {
|
|
220
221
|
_type: PT_TYPES.styled,
|
|
221
222
|
_key: key(),
|
|
@@ -228,6 +229,38 @@ function styledRule(key: KeyGenerator): DeserializerRule {
|
|
|
228
229
|
};
|
|
229
230
|
}
|
|
230
231
|
|
|
232
|
+
/**
|
|
233
|
+
* `<a class>` → a `link` annotation that keeps the site's class as `className`.
|
|
234
|
+
*
|
|
235
|
+
* The built-in link rule records `href` and nothing else, so a styled link (`<a class="underline
|
|
236
|
+
* text-brand">`) came back bare and the design died on ingest. Only a link WITH a class takes
|
|
237
|
+
* this rule; a plain link still goes to the built-in one. `href` is checked by `normalise`.
|
|
238
|
+
*/
|
|
239
|
+
function classedLinkRule(key: KeyGenerator): DeserializerRule {
|
|
240
|
+
return {
|
|
241
|
+
deserialize(el: Node, next) {
|
|
242
|
+
if (!isElement(el) || el.nodeName.toLowerCase() !== "a") return undefined;
|
|
243
|
+
const className = el.getAttribute("class")?.trim();
|
|
244
|
+
const href = el.getAttribute("href")?.trim();
|
|
245
|
+
if (!className || !href) return undefined;
|
|
246
|
+
const rel = el.getAttribute("rel")?.trim();
|
|
247
|
+
const markDef = {
|
|
248
|
+
_type: PT_TYPES.link,
|
|
249
|
+
_key: key(),
|
|
250
|
+
href,
|
|
251
|
+
className,
|
|
252
|
+
...(el.getAttribute("target") === "_blank" ? { target: "_blank" } : {}),
|
|
253
|
+
...(rel ? { rel } : {}),
|
|
254
|
+
};
|
|
255
|
+
return { _type: "__annotation", markDef, children: next(el.childNodes) } as never;
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Does this field's schema carry the link annotation? */
|
|
261
|
+
const allowsLinks = (schema: PtSchema | undefined): boolean =>
|
|
262
|
+
!schema || schema.annotations.some((a) => a.name === PT_TYPES.link);
|
|
263
|
+
|
|
231
264
|
/**
|
|
232
265
|
* ── MEDIA, WHICH USED TO PRODUCE NO NODE AT ALL ──────────────────────────────────────────
|
|
233
266
|
*
|
|
@@ -383,6 +416,7 @@ export function htmlToPortableText(html: string, opts: IngestOptions = {}): Port
|
|
|
383
416
|
...(allowsTables(opts.schema) ? [tableRule(key, (cell) => htmlToPortableText(cell, opts))] : []),
|
|
384
417
|
mediaRule(key, opts.schema),
|
|
385
418
|
...(allowsStyled(opts.schema) ? [styledRule(key)] : []),
|
|
419
|
+
...(allowsLinks(opts.schema) ? [classedLinkRule(key)] : []),
|
|
386
420
|
],
|
|
387
421
|
keyGenerator: key,
|
|
388
422
|
});
|
package/src/schema.ts
CHANGED
|
@@ -111,6 +111,8 @@ export function ptSchemaFor(accept: readonly AcceptElement[]): PtSchema {
|
|
|
111
111
|
fields: [
|
|
112
112
|
{ name: "href", type: "string" },
|
|
113
113
|
{ name: "target", type: "string" },
|
|
114
|
+
// The site's own class on the `<a>`, kept so a styled link round-trips.
|
|
115
|
+
{ name: "className", type: "string" },
|
|
114
116
|
],
|
|
115
117
|
});
|
|
116
118
|
}
|
package/src/styled.ts
CHANGED
|
@@ -22,8 +22,22 @@
|
|
|
22
22
|
* NOT passed through — its value is a language of its own, bounded by `safeStyle` below.
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
-
/**
|
|
26
|
-
|
|
25
|
+
/**
|
|
26
|
+
* Inline tags a `styled` annotation may wear. Nothing outside this list is ever rendered.
|
|
27
|
+
*
|
|
28
|
+
* The decorator tags (`strong` … `code`) are here for the one case their decorator cannot
|
|
29
|
+
* hold: the site put a CLASS on them (`<strong class="text-yellow-500">`). A decorator is a
|
|
30
|
+
* bit with nowhere to keep a class, so without this the design died on ingest — which is what
|
|
31
|
+
* made agents rewrite a site's markup to dodge the loss. A bare `<strong>` still becomes the
|
|
32
|
+
* decorator (see `BARE_IS_DESIGN`).
|
|
33
|
+
*/
|
|
34
|
+
export const STYLED_TAGS = [
|
|
35
|
+
"span", "small", "sup", "sub", "kbd", "abbr", "time",
|
|
36
|
+
"strong", "b", "em", "i", "u", "s", "mark", "code", "q", "cite", "dfn", "var", "samp", "ins", "del",
|
|
37
|
+
] as const;
|
|
38
|
+
|
|
39
|
+
/** Tags whose bare form IS the design, so they are annotated even with no class. */
|
|
40
|
+
export const BARE_IS_DESIGN: ReadonlySet<string> = new Set(["small", "sup", "sub", "kbd", "abbr", "time"]);
|
|
27
41
|
|
|
28
42
|
export type StyledTag = (typeof STYLED_TAGS)[number];
|
|
29
43
|
|