@bettercms-ai/richtext 0.11.0 → 0.12.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/from-lexical.ts +2 -1
- package/src/html.ts +40 -0
- package/src/ingest.ts +29 -1
- package/src/types.ts +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bettercms-ai/richtext",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.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/from-lexical.ts
CHANGED
|
@@ -95,7 +95,8 @@ function spansFrom(
|
|
|
95
95
|
if (!isRecord(value)) return [];
|
|
96
96
|
const node = value as LexNode;
|
|
97
97
|
|
|
98
|
-
|
|
98
|
+
// An `autolink` is Lexical's link for a URL typed as text: the same annotation, so the same mark.
|
|
99
|
+
if (node.type === "link" || node.type === "autolink") {
|
|
99
100
|
const href = safeHref(node.url ?? "");
|
|
100
101
|
// An unsafe href does not become an unsafe link and does not silently delete the label —
|
|
101
102
|
// the text survives, unlinked.
|
package/src/html.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { toHTML, type PortableTextHtmlComponents } from "@portabletext/to-html";
|
|
2
2
|
import {
|
|
3
|
+
BUILTIN_CALLOUT_KEY,
|
|
3
4
|
BUILTIN_EMBED_KEY,
|
|
4
5
|
BUILTIN_IMAGE_KEY,
|
|
6
|
+
BUILTIN_TOGGLE_KEY,
|
|
5
7
|
PT_TYPES,
|
|
6
8
|
pullQuoteHref,
|
|
7
9
|
type PortableText,
|
|
@@ -307,6 +309,42 @@ function embedHtml(value: unknown, _classes: HtmlClasses): string {
|
|
|
307
309
|
return `<iframe src="${esc(src)}"${title ? ` title="${esc(title)}"` : ""}${IFRAME_ATTRS}></iframe>`;
|
|
308
310
|
}
|
|
309
311
|
|
|
312
|
+
/** Plain multi-line text as paragraphs: a blank line starts a new one, a single newline is a `<br>`. */
|
|
313
|
+
const paragraphs = (text: string): string =>
|
|
314
|
+
text.split(/\n\s*\n/).map((p) => p.trim()).filter(Boolean)
|
|
315
|
+
.map((p) => `<p>${p.split("\n").map(esc).join("<br>")}</p>`).join("");
|
|
316
|
+
|
|
317
|
+
const CALLOUT_TONES = new Set(["info", "tip", "warning"]);
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* A callout: a titled note in its tone. Its payload is its own text, so it renders as content — a marker
|
|
321
|
+
* here was the whole note missing from every delivered page. An unknown tone renders as `info`.
|
|
322
|
+
*/
|
|
323
|
+
function calloutHtml(value: unknown): string {
|
|
324
|
+
const f = fieldsOf(value);
|
|
325
|
+
const tone = CALLOUT_TONES.has(str(f.tone)) ? str(f.tone) : "info";
|
|
326
|
+
const title = str(f.title);
|
|
327
|
+
const body = paragraphs(typeof f.message === "string" ? f.message : "");
|
|
328
|
+
if (!title && !body) return "";
|
|
329
|
+
return `<div class="bcms-callout bcms-callout--${tone}" data-rt="callout" data-callout="${tone}" role="note">`
|
|
330
|
+
+ (title ? `<p class="bcms-callout__title" data-title>${esc(title)}</p>` : "")
|
|
331
|
+
+ `<div class="bcms-callout__body" data-body>${body}</div></div>`;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** A toggle: `<details>`, so it opens and closes on a page with no script. */
|
|
335
|
+
function toggleHtml(value: unknown): string {
|
|
336
|
+
const f = fieldsOf(value);
|
|
337
|
+
const title = str(f.title);
|
|
338
|
+
const body = paragraphs(typeof f.body === "string" ? f.body : "");
|
|
339
|
+
if (!title && !body) return "";
|
|
340
|
+
return `<details class="bcms-toggle" data-rt="toggle"><summary class="bcms-toggle__title">${esc(title)}</summary>`
|
|
341
|
+
+ `<div class="bcms-toggle__body" data-body>${body}</div></details>`;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Built-in blocks this serialiser renders from their own fields (no project lookup): read-time html re-derives them. */
|
|
345
|
+
export const isSelfRenderedBlock = (node: unknown): boolean =>
|
|
346
|
+
isRecord(node) && (node.schemaKey === BUILTIN_CALLOUT_KEY || node.schemaKey === BUILTIN_TOGGLE_KEY);
|
|
347
|
+
|
|
310
348
|
/**
|
|
311
349
|
* A video: the file itself where the URL is one, the provider's player where it is a provider's.
|
|
312
350
|
*
|
|
@@ -486,6 +524,8 @@ const buildComponents = (
|
|
|
486
524
|
const v = value as { schemaKey?: unknown };
|
|
487
525
|
if (v.schemaKey === BUILTIN_IMAGE_KEY) return imageHtml(value, classes);
|
|
488
526
|
if (v.schemaKey === BUILTIN_EMBED_KEY) return embedHtml(value, classes);
|
|
527
|
+
if (v.schemaKey === BUILTIN_CALLOUT_KEY) return calloutHtml(value);
|
|
528
|
+
if (v.schemaKey === BUILTIN_TOGGLE_KEY) return toggleHtml(value);
|
|
489
529
|
/**
|
|
490
530
|
* A pre-P1c TABLE is the exception to the marker, and it has to be: it resolves against
|
|
491
531
|
* nothing. Its rows are text, held right here, so a marker would DELETE visible content.
|
package/src/ingest.ts
CHANGED
|
@@ -795,11 +795,39 @@ const allowsBlockObject = (schema: PtSchema | undefined, name: string): boolean
|
|
|
795
795
|
const isElement = (n: unknown): n is Element =>
|
|
796
796
|
!!n && typeof n === "object" && (n as Node).nodeType === 1;
|
|
797
797
|
|
|
798
|
+
const BLOCK_TAGS = new Set([
|
|
799
|
+
"ADDRESS", "ARTICLE", "ASIDE", "BLOCKQUOTE", "DD", "DETAILS", "DIV", "DL", "DT", "FIGCAPTION", "FIGURE", "FOOTER",
|
|
800
|
+
"H1", "H2", "H3", "H4", "H5", "H6", "HEADER", "HR", "LI", "MAIN", "NAV", "OL", "P", "PRE", "SECTION", "TABLE", "UL",
|
|
801
|
+
]);
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* The parser, minus the indentation between block elements. The converter skips that whitespace
|
|
805
|
+
* at the top level but turns it into an empty block inside a wrapper, so `<div><p>a</p>\n<p>b</p></div>`
|
|
806
|
+
* came back as three blocks. Only ASCII whitespace beside a block element goes: a space between two
|
|
807
|
+
* inline elements is a word break, and ` ` or a `<br>` is an author's blank line.
|
|
808
|
+
*/
|
|
809
|
+
const withoutBlockWhitespace =
|
|
810
|
+
(parse: (html: string) => Document) =>
|
|
811
|
+
(html: string): Document => {
|
|
812
|
+
const doc = parse(html);
|
|
813
|
+
const walker = doc.createTreeWalker(doc.body ?? doc, 4 /* NodeFilter.SHOW_TEXT */);
|
|
814
|
+
const blank: Node[] = [];
|
|
815
|
+
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
816
|
+
const beside = [n.previousSibling, n.nextSibling].some((s) => !!s && BLOCK_TAGS.has(s.nodeName));
|
|
817
|
+
if (beside && /^[ \t\n\r]*$/.test(n.nodeValue ?? "") && n.parentNode?.nodeName !== "PRE") blank.push(n);
|
|
818
|
+
}
|
|
819
|
+
for (const n of blank) n.parentNode?.removeChild(n);
|
|
820
|
+
return doc;
|
|
821
|
+
};
|
|
822
|
+
|
|
798
823
|
export function htmlToPortableText(html: string, opts: IngestOptions = {}): PortableText {
|
|
799
824
|
if (typeof html !== "string" || html.trim() === "") return [];
|
|
800
825
|
const key = opts.keyGenerator ?? randomKey;
|
|
826
|
+
const parse =
|
|
827
|
+
opts.parseHtml ?? (typeof DOMParser !== "undefined" ? (markup: string) => new DOMParser().parseFromString(markup, "text/html") : undefined);
|
|
801
828
|
const converted = ptHtmlToPortableText(html, {
|
|
802
|
-
|
|
829
|
+
// The browser's own parser when none is passed (Node must pass one), so both drop the same whitespace.
|
|
830
|
+
...(parse ? { parseHtml: withoutBlockWhitespace(parse) } : {}),
|
|
803
831
|
// `ptSchemaFor` returns a schema DEFINITION — the same object `EditorProvider` takes, so
|
|
804
832
|
// the editor and this filter are literally configured from one value. The deserialiser
|
|
805
833
|
// wants the COMPILED form, so the compile happens here rather than at every call site.
|
package/src/types.ts
CHANGED
|
@@ -462,6 +462,9 @@ export const STORABLE_BLOCK_TYPES: ReadonlySet<string> = new Set<string>([
|
|
|
462
462
|
*/
|
|
463
463
|
export const BUILTIN_IMAGE_KEY = "builtin:image" as const;
|
|
464
464
|
export const BUILTIN_EMBED_KEY = "builtin:video" as const;
|
|
465
|
+
/** A callout (`{ tone: info|tip|warning, title, message }`) and a toggle (`{ title, body }`): plain text, read straight. */
|
|
466
|
+
export const BUILTIN_CALLOUT_KEY = "builtin:callout" as const;
|
|
467
|
+
export const BUILTIN_TOGGLE_KEY = "builtin:toggle" as const;
|
|
465
468
|
|
|
466
469
|
/**
|
|
467
470
|
* The `schemaKey` of a component embedded in rich text. A wire value shared with the dashboard
|