@bettercms-ai/richtext 0.6.1 → 0.8.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bettercms-ai/richtext",
3
- "version": "0.6.1",
3
+ "version": "0.8.0",
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
  }
@@ -235,6 +243,11 @@ export interface RenderOptions {
235
243
  * sits on each `<li>`, because a wrapper per item would split the list into one-item lists.
236
244
  */
237
245
  bindingBase?: string;
246
+ /**
247
+ * Renders an embedded block this serialiser cannot resolve itself (a component, say). A
248
+ * string replaces the marker; `undefined` keeps it.
249
+ */
250
+ renderBlock?: (value: Record<string, unknown>) => string | undefined;
238
251
  }
239
252
 
240
253
  /**
@@ -269,6 +282,7 @@ const bindingAttrs = (base: string, key: unknown): string =>
269
282
  const buildComponents = (
270
283
  classes: HtmlClasses = {},
271
284
  binding?: { base: string; keys: ReadonlySet<unknown> },
285
+ renderBlock?: RenderOptions["renderBlock"],
272
286
  ): Partial<PortableTextHtmlComponents> => ({
273
287
  block: tagOverrides(classes, STYLE_TAGS),
274
288
  list: tagOverrides(classes, { bullet: "ul", number: "ol", check: "ul" }),
@@ -315,6 +329,8 @@ const buildComponents = (
315
329
  */
316
330
  const legacy = builtInTableToTable(value);
317
331
  if (legacy) return tableHtml(legacy);
332
+ const rendered = renderBlock?.(value as Record<string, unknown>);
333
+ if (rendered !== undefined) return rendered;
318
334
  const key = typeof v.schemaKey === "string" && TOKEN.test(v.schemaKey.replace(/^builtin:/, ""))
319
335
  ? v.schemaKey
320
336
  : "block";
@@ -392,11 +408,12 @@ export function portableTextToHtml(
392
408
  opts: RenderOptions = {},
393
409
  ): string {
394
410
  if (!Array.isArray(value) || value.length === 0) return "";
395
- const components = opts.classes ? buildComponents(opts.classes) : defaultComponents;
411
+ const components =
412
+ opts.classes || opts.renderBlock ? buildComponents(opts.classes, undefined, opts.renderBlock) : defaultComponents;
396
413
 
397
414
  if (opts.bindingBase) {
398
415
  const base = opts.bindingBase;
399
- const bound = buildComponents(opts.classes, { base, keys: new Set(value.map((n) => n._key)) });
416
+ const bound = buildComponents(opts.classes, { base, keys: new Set(value.map((n) => n._key)) }, opts.renderBlock);
400
417
  const out: string[] = [];
401
418
  for (let i = 0; i < value.length; ) {
402
419
  const node = value[i];
@@ -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/read.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { PORTABLE_TEXT_FORMAT, PT_TYPES, type PortableText } from "./types";
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
4
  import { randomKey, type KeyGenerator } from "./key";
@@ -231,3 +231,26 @@ export function readStructuredContent(props: unknown): PortableText | null {
231
231
  : null;
232
232
  return arr && arr.length > 0 ? (arr as PortableText) : null;
233
233
  }
234
+
235
+ /** The `component` block an embed in rich text stands for. */
236
+ export interface EmbeddedComponentBlock {
237
+ type: "component";
238
+ /** The node's `_key` — unique within its rich-text field only. */
239
+ id: string;
240
+ props: { componentId: string; overrides: Record<string, unknown> };
241
+ }
242
+
243
+ /**
244
+ * A component embedded in rich text, as the ordinary `component` block every renderer already
245
+ * draws — so an embed gets the same resolver, cycle guard and identity attributes as a placed
246
+ * instance. Null for any other node.
247
+ */
248
+ export function embeddedComponentBlock(value: unknown): EmbeddedComponentBlock | null {
249
+ if (!isRecord(value) || value.schemaKey !== EMBEDDED_COMPONENT_KEY || !isRecord(value.fields)) return null;
250
+ const { componentId, overrides } = value.fields;
251
+ return {
252
+ type: "component",
253
+ id: typeof value._key === "string" ? value._key : "",
254
+ props: { componentId: String(componentId ?? ""), overrides: isRecord(overrides) ? overrides : {} },
255
+ };
256
+ }
package/src/types.ts CHANGED
@@ -276,6 +276,12 @@ export const STORABLE_BLOCK_TYPES: ReadonlySet<string> = new Set<string>([
276
276
  export const BUILTIN_IMAGE_KEY = "builtin:image" as const;
277
277
  export const BUILTIN_EMBED_KEY = "builtin:video" as const;
278
278
 
279
+ /**
280
+ * The `schemaKey` of a component embedded in rich text. A wire value shared with the dashboard
281
+ * (`COMPONENT_SCHEMA_KEY`) and the backend (`RICHTEXT_COMPONENT_KEY`).
282
+ */
283
+ export const EMBEDDED_COMPONENT_KEY = "component" as const;
284
+
279
285
  /**
280
286
  * Top-level nodes whose `_type` this platform cannot store or render, in document order.
281
287
  *