@bettercms-ai/richtext 0.7.0 → 0.8.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bettercms-ai/richtext",
3
- "version": "0.7.0",
3
+ "version": "0.8.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
@@ -10,6 +10,7 @@ import { resolveVideoSource } from "./video";
10
10
  import { builtInTableToTable } from "./table";
11
11
  import { safeHref } from "./href";
12
12
  import { isStyledTag, styledAttrEntries } from "./styled";
13
+ import { richTextImageLayout } from "./image-layout";
13
14
 
14
15
  /**
15
16
  * Portable Text → HTML.
@@ -151,8 +152,15 @@ function imageHtml(value: unknown, classes: HtmlClasses): string {
151
152
  const src = mediaSrc(str(f.src));
152
153
  if (!src) return "";
153
154
  const caption = str(f.caption);
154
- const img = `<img${cls(classes, "img")} src="${esc(src)}" alt="${esc(str(f.alt))}">`;
155
- return `<figure${cls(classes, "figure")}>${img}${
155
+ const layout = richTextImageLayout(f);
156
+ // A placed image fills its figure; the figure carries the width and position (image-layout.ts).
157
+ const imgStyle = layout ? ' style="display:block;width:100%;height:auto"' : "";
158
+ const img = `<img${cls(classes, "img")} src="${esc(src)}" alt="${esc(str(f.alt))}"${imgStyle}>`;
159
+ const figureClass = [classes.figure, layout?.className].filter(Boolean).join(" ");
160
+ const figureAttrs = `${figureClass ? ` class="${esc(figureClass)}"` : ""}${
161
+ layout ? ` style="${esc(layout.figureStyle)}"` : ""
162
+ }`;
163
+ return `<figure${figureAttrs}>${img}${
156
164
  caption ? `<figcaption>${esc(caption)}</figcaption>` : ""
157
165
  }</figure>`;
158
166
  }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Where a rich-text image sits and how wide it is — `align` and `size` on a `builtin:image`
3
+ * block's fields.
4
+ *
5
+ * WHY ONE TABLE. The editor draws the image with `align` (RichTextImageBlock's ALIGN_CLASS) and
6
+ * every delivered rendition is written by `portableTextToHtml` — which used to ignore `align`
7
+ * entirely, so an author centred an image and the published article showed it full width. The
8
+ * numbers here are the editor's, so the two cannot drift again: a centred image is 78% of the
9
+ * column, a left/right one 62%, full is the whole column. `size` overrides that width when set.
10
+ *
11
+ * The rendition carries classes (`bcms-rt-img--{align}`, `bcms-rt-img--{size}`) for a site that
12
+ * wants to restyle it, AND inline max-width/margins so it looks right on a site with no CSS for
13
+ * them at all — which is every site today.
14
+ */
15
+
16
+ export const RICHTEXT_IMAGE_ALIGNS = ["full", "center", "left", "right"] as const;
17
+ export type RichTextImageAlign = (typeof RICHTEXT_IMAGE_ALIGNS)[number];
18
+
19
+ export const RICHTEXT_IMAGE_SIZES = ["small", "medium", "large", "full"] as const;
20
+ export type RichTextImageSize = (typeof RICHTEXT_IMAGE_SIZES)[number];
21
+
22
+ /** Column share when `size` is unset — the editor's ALIGN_CLASS max-widths. */
23
+ export const RICHTEXT_IMAGE_ALIGN_MAX_WIDTH: Record<RichTextImageAlign, string> = {
24
+ full: "100%",
25
+ center: "78%",
26
+ left: "62%",
27
+ right: "62%",
28
+ };
29
+
30
+ /** Column share for an explicit `size`. Wins over the align default. */
31
+ export const RICHTEXT_IMAGE_SIZE_MAX_WIDTH: Record<RichTextImageSize, string> = {
32
+ small: "33%",
33
+ medium: "50%",
34
+ large: "75%",
35
+ full: "100%",
36
+ };
37
+
38
+ export interface RichTextImageLayout {
39
+ align: RichTextImageAlign;
40
+ size: RichTextImageSize | null;
41
+ /** `bcms-rt-img bcms-rt-img--{align}[ bcms-rt-img--{size}]` */
42
+ className: string;
43
+ /** Inline CSS for the `<figure>`: max-width plus the margins that place it. */
44
+ figureStyle: string;
45
+ }
46
+
47
+ const oneOf = <T extends string>(list: readonly T[], v: unknown): T | null =>
48
+ typeof v === "string" && (list as readonly string[]).includes(v) ? (v as T) : null;
49
+
50
+ /**
51
+ * The layout a block's fields ask for, or `null` when they ask for none — an image with neither
52
+ * a known `align` nor a known `size` renders exactly as it always did. Unknown values are
53
+ * ignored, never echoed: both come from stored data and end up in an attribute.
54
+ */
55
+ export function richTextImageLayout(fields: Record<string, unknown>): RichTextImageLayout | null {
56
+ const alignValue = oneOf(RICHTEXT_IMAGE_ALIGNS, fields.align);
57
+ const size = oneOf(RICHTEXT_IMAGE_SIZES, fields.size);
58
+ if (!alignValue && !size) return null;
59
+ const align = alignValue ?? "full";
60
+ const maxWidth = size ? RICHTEXT_IMAGE_SIZE_MAX_WIDTH[size] : RICHTEXT_IMAGE_ALIGN_MAX_WIDTH[align];
61
+ // left hugs the start, right the end; centre and full centre (a sized full image is centred).
62
+ const [ml, mr] = align === "left" ? ["0", "auto"] : align === "right" ? ["auto", "0"] : ["auto", "auto"];
63
+ return {
64
+ align,
65
+ size,
66
+ className: `bcms-rt-img bcms-rt-img--${align}${size ? ` bcms-rt-img--${size}` : ""}`,
67
+ figureStyle: `max-width:${maxWidth};margin-left:${ml};margin-right:${mr}`,
68
+ };
69
+ }
package/src/index.ts CHANGED
@@ -10,3 +10,4 @@ export * from "./read";
10
10
  export * from "./accept";
11
11
  export * from "./features";
12
12
  export * from "./video";
13
+ export * from "./image-layout";
package/src/ingest.ts CHANGED
@@ -187,6 +187,35 @@ function tableRule(key: KeyGenerator, ingestCell: (html: string) => PortableText
187
187
  };
188
188
  }
189
189
 
190
+ /** A code block's text, line breaks included: Lexical writes them as `<br>`, not newlines. */
191
+ const codeText = (node: Node): string =>
192
+ node.nodeName.toLowerCase() === "br"
193
+ ? "\n"
194
+ : node.nodeType === 3
195
+ ? (node.textContent ?? "")
196
+ : [...node.childNodes].map(codeText).join("");
197
+
198
+ /** `language-x` on the `<code>` (Markdown, our renderer) or `data-language` on the `<pre>` (Lexical, Shiki). */
199
+ const codeLanguage = (pre: Element): string | undefined =>
200
+ /(?:^|\s)language-([\w+#-]+)/.exec(pre.querySelector("code")?.getAttribute("class") ?? "")?.[1] ??
201
+ (pre.getAttribute("data-language") || undefined);
202
+
203
+ /**
204
+ * `<pre>` → the `bcmsCode` block the renderer turns back into `<pre><code>`. Without it the
205
+ * deserialiser's default flattens a code block to a paragraph with a `code` mark and its
206
+ * newlines collapsed: every imported code fence became one line of inline code.
207
+ */
208
+ function codeRule(key: KeyGenerator): DeserializerRule {
209
+ return {
210
+ deserialize(el: Node, _next, createBlock) {
211
+ if (!isElement(el) || el.nodeName.toLowerCase() !== "pre") return undefined;
212
+ const language = codeLanguage(el);
213
+ const code = codeText(el).replace(/\n$/, "");
214
+ return createBlock({ _type: PT_TYPES.code, _key: key(), code, ...(language ? { language } : {}) });
215
+ },
216
+ };
217
+ }
218
+
190
219
  /**
191
220
  * An inline element Portable Text does not model → the `styled` annotation.
192
221
  *
@@ -384,9 +413,9 @@ function mediaRule(key: KeyGenerator, schema: PtSchema | undefined): Deserialize
384
413
  const allowsStyled = (schema: PtSchema | undefined): boolean =>
385
414
  !schema || schema.annotations.some((a) => a.name === PT_TYPES.styled);
386
415
 
387
- /** Does this field's schema carry the table block object? Asked by name, not by capability. */
388
- const allowsTables = (schema: PtSchema | undefined): boolean =>
389
- !schema || schema.blockObjects.some((o) => o.name === PT_TYPES.table);
416
+ /** Does this field's schema carry the named block object? Asked by name, not by capability. */
417
+ const allowsBlockObject = (schema: PtSchema | undefined, name: string): boolean =>
418
+ !schema || schema.blockObjects.some((o) => o.name === name);
390
419
 
391
420
  const isElement = (n: unknown): n is Element =>
392
421
  !!n && typeof n === "object" && (n as Node).nodeType === 1;
@@ -413,7 +442,8 @@ export function htmlToPortableText(html: string, opts: IngestOptions = {}): Port
413
442
  * accepts only images.
414
443
  */
415
444
  rules: [
416
- ...(allowsTables(opts.schema) ? [tableRule(key, (cell) => htmlToPortableText(cell, opts))] : []),
445
+ ...(allowsBlockObject(opts.schema, PT_TYPES.table) ? [tableRule(key, (cell) => htmlToPortableText(cell, opts))] : []),
446
+ ...(allowsBlockObject(opts.schema, PT_TYPES.code) ? [codeRule(key)] : []),
417
447
  mediaRule(key, opts.schema),
418
448
  ...(allowsStyled(opts.schema) ? [styledRule(key)] : []),
419
449
  ...(allowsLinks(opts.schema) ? [classedLinkRule(key)] : []),