@bettercms-ai/richtext 0.6.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 +4 -2
- 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.6.
|
|
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
|
@@ -332,7 +332,7 @@ const buildComponents = (
|
|
|
332
332
|
* renders the LABEL rather than deleting it — a blocked link must not lose the words.
|
|
333
333
|
*/
|
|
334
334
|
[PT_TYPES.link]: ({ value, children }) => {
|
|
335
|
-
const v = (value ?? {}) as { href?: unknown; target?: unknown; rel?: unknown };
|
|
335
|
+
const v = (value ?? {}) as { href?: unknown; target?: unknown; rel?: unknown; className?: unknown };
|
|
336
336
|
const href = typeof v.href === "string" ? safeHref(v.href) : null;
|
|
337
337
|
if (!href) return children;
|
|
338
338
|
// `rel` is author data too (the importer keeps `prev`/`next` on pagination links), so only
|
|
@@ -346,7 +346,9 @@ const buildComponents = (
|
|
|
346
346
|
rel.add("noreferrer");
|
|
347
347
|
}
|
|
348
348
|
const relAttr = rel.size ? ` rel="${esc([...rel].join(" "))}"` : "";
|
|
349
|
-
|
|
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>`;
|
|
350
352
|
},
|
|
351
353
|
/**
|
|
352
354
|
* The site's own inline markup, written back out as itself.
|
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
|
|