@bettercms-ai/richtext 0.5.0 → 0.6.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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/html.ts +47 -12
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bettercms-ai/richtext",
3
- "version": "0.5.0",
3
+ "version": "0.6.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
@@ -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
- const buildComponents = (classes: HtmlClasses = {}): Partial<PortableTextHtmlComponents> => ({
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
- listItem: tagOverrides(classes, { bullet: "li", number: "li", check: "li" }),
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),
@@ -362,6 +382,9 @@ const buildComponents = (classes: HtmlClasses = {}): Partial<PortableTextHtmlCom
362
382
  /** The undressed components, built once: the overwhelming majority of calls pass no classes. */
363
383
  const defaultComponents = buildComponents();
364
384
 
385
+ const isListItem = (node: unknown): boolean =>
386
+ isRecord(node) && node._type === "block" && typeof node.listItem === "string" && !!node.listItem;
387
+
365
388
  export function portableTextToHtml(
366
389
  value: PortableText | null | undefined,
367
390
  opts: RenderOptions = {},
@@ -370,16 +393,28 @@ export function portableTextToHtml(
370
393
  const components = opts.classes ? buildComponents(opts.classes) : defaultComponents;
371
394
 
372
395
  if (opts.bindingBase) {
373
- const base = esc(opts.bindingBase);
374
- return value
375
- .map((node) => {
376
- const inner = toHTML([node] as never, { components });
377
- // A node with no key cannot be addressed. Render it rather than dropping it — an
378
- // unaddressable paragraph is a small loss, a missing one is content disappearing.
379
- if (!node._key) return inner;
380
- return `<div data-bcms-field="${base}#${esc(node._key)}" data-bcms-kind="richtext">${inner}</div>`;
381
- })
382
- .join("");
396
+ const base = opts.bindingBase;
397
+ const bound = buildComponents(opts.classes, { base, keys: new Set(value.map((n) => n._key)) });
398
+ const out: string[] = [];
399
+ for (let i = 0; i < value.length; ) {
400
+ const node = value[i];
401
+ // A RUN of list items renders as ONE list, each `<li>` carrying its own address. Wrapping
402
+ // items one by one gave every item its own `<ol>`, so numbering restarted at 1 on every line
403
+ // and bullets sat a list-margin apart — the canvas no longer looked like the site.
404
+ if (isListItem(node)) {
405
+ let end = i + 1;
406
+ while (end < value.length && isListItem(value[end])) end++;
407
+ out.push(toHTML(value.slice(i, end) as never, { components: bound }));
408
+ i = end;
409
+ continue;
410
+ }
411
+ const inner = toHTML([node] as never, { components });
412
+ // A node with no key cannot be addressed. Render it rather than dropping it — an
413
+ // unaddressable paragraph is a small loss, a missing one is content disappearing.
414
+ out.push(node._key ? `<div${bindingAttrs(base, node._key)}>${inner}</div>` : inner);
415
+ i++;
416
+ }
417
+ return out.join("");
383
418
  }
384
419
 
385
420
  const html = toHTML(value as never, { components });