@bettercms-ai/richtext 0.14.0 → 0.17.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/src/html.ts CHANGED
@@ -3,6 +3,7 @@ import {
3
3
  BUILTIN_CALLOUT_KEY,
4
4
  BUILTIN_EMBED_KEY,
5
5
  BUILTIN_IMAGE_KEY,
6
+ BUILTIN_TABLE_KEY,
6
7
  BUILTIN_TOGGLE_KEY,
7
8
  PT_TYPES,
8
9
  pullQuoteHref,
@@ -12,12 +13,16 @@ import {
12
13
  import { resolveVideoSource } from "./video";
13
14
  import { builtInTableToTable, designOf, footnoteNumbers, footnotesOf, highlightedColumns, isTypedTable, jsonLdOf, sortableColumns, tableColumns, tableGroupLayout, tableSections, typedCellView } from "./table";
14
15
  import { portableTextToPlainText } from "./plain";
15
- import { safeHref } from "./href";
16
+ import { safeHref, safeMediaSrc, writeLinkRel } from "./href";
16
17
  import { isStyledTag, styledAttrEntries } from "./styled";
17
- import { richTextImageLayout } from "./image-layout";
18
+ import { richTextImageFrame, richTextImageLayout } from "./image-layout";
18
19
  import { keyTakeawaysView } from "./key-takeaways";
19
20
  import { GALLERY_STYLES, galleryView } from "./gallery";
20
21
  import { outlineOf, tocTree, tocView, type OutlineItem } from "./toc";
22
+ import { footnoteOrder } from "./footnote";
23
+ import { BUILTIN_COLUMNS_KEY, COLUMNS_STYLES, columnsView } from "./columns";
24
+ import { authorBioView, relatedPostsView } from "./references";
25
+ import type { RichTextResolved } from "@bettercms-ai/types";
21
26
  import { isEmbedAspect, resolveEmbed, type EmbedCard, type EmbedRender } from "./embed";
22
27
 
23
28
  /**
@@ -249,19 +254,8 @@ const STYLE_TAGS: Record<string, ClassTag> = {
249
254
 
250
255
  const str = (v: unknown): string => (typeof v === "string" ? v.trim() : "");
251
256
 
252
- /**
253
- * A MEDIA url: `safeHref`, plus a plain relative path.
254
- *
255
- * An imported page is full of `images/hero.png` and `../media/clip.mp4`, and `safeHref` refuses
256
- * a path with no leading slash — so every one of those images would render as nothing, which is
257
- * the loss this whole pass exists to stop. Widened only for `<img>`, `<video>` and a poster:
258
- * they load a picture, while an `<iframe>` frames a whole page and keeps the strict check.
259
- * A colon or whitespace is refused, so no scheme (`javascript:`) can ride this path, and a
260
- * PROTOCOL-RELATIVE `//host/clip.mp4` is refused with it — it is off-origin, not relative, and
261
- * `safeHref` rejects the same shape for the same reason (href.ts:20).
262
- */
263
- const mediaSrc = (raw: string): string | null =>
264
- safeHref(raw) ?? (raw !== "" && !raw.startsWith("//") && !/[:\s]/.test(raw) ? raw : null);
257
+ /** A media url (`<img>`, `<video>`, a poster): href.ts `safeMediaSrc`, shared with ui's renderer. */
258
+ const mediaSrc = safeMediaSrc;
265
259
 
266
260
  const fieldsOf = (value: unknown): Record<string, unknown> => {
267
261
  const f = isRecord(value) ? value.fields : undefined;
@@ -282,9 +276,13 @@ function imageHtml(value: unknown, classes: HtmlClasses): string {
282
276
  if (!src) return "";
283
277
  const caption = str(f.caption);
284
278
  const layout = richTextImageLayout(f);
279
+ // RT9: the crop rides the url, the focal point is object-position; what is stored rides data-*.
280
+ const frame = richTextImageFrame(src, f.crop, f.focal);
285
281
  // A placed image fills its figure; the figure carries the width and position (image-layout.ts).
286
- const imgStyle = layout ? ' style="display:block;width:100%;height:auto"' : "";
287
- const img = `<img${cls(classes, "img")} src="${esc(src)}" alt="${esc(str(f.alt))}"${imgStyle}>`;
282
+ const styles = [layout ? "display:block;width:100%;height:auto" : "", frame.position ? `object-position:${frame.position}` : ""].filter(Boolean);
283
+ const imgStyle = styles.length ? ` style="${styles.join(";")}"` : "";
284
+ const data = frame.data.map(([k, v]) => ` ${k}="${esc(v)}"`).join("");
285
+ const img = `<img${cls(classes, "img")} src="${esc(frame.src)}" alt="${esc(str(f.alt))}"${imgStyle}${data}>`;
288
286
  const figureClass = [classes.figure, layout?.className].filter(Boolean).join(" ");
289
287
  const figureAttrs = `${figureClass ? ` class="${esc(figureClass)}"` : ""}${
290
288
  layout ? ` style="${esc(layout.figureStyle)}"` : ""
@@ -434,7 +432,9 @@ function galleryHtml(value: unknown): string {
434
432
  const { layout, images } = galleryView(value);
435
433
  const s = GALLERY_STYLES[layout];
436
434
  const items = images
437
- .map((x) => `<li style="${s.li}"><figure style="${s.figure}"><img alt="${esc(x.alt)}" style="${s.img}" loading="lazy" src="${esc(x.src)}">${
435
+ .map((x) => `<li style="${s.li}"><figure style="${s.figure}"><img alt="${esc(x.alt)}" style="${s.img}${x.position ? `;object-position:${x.position}` : ""}" loading="lazy"${x.data
436
+ .map(([k, v]) => ` ${k}="${esc(v)}"`)
437
+ .join("")} src="${esc(x.src)}">${
438
438
  x.caption ? `<figcaption>${esc(x.caption)}</figcaption>` : ""
439
439
  }</figure></li>`)
440
440
  .join("");
@@ -448,6 +448,75 @@ function galleryHtml(value: unknown): string {
448
448
  * one); an inline render (a cell, a heading field) keeps the value's outline it sits in.
449
449
  */
450
450
  let outline: { items: OutlineItem[]; nodes: WeakMap<object, string> } = { items: [], nodes: new WeakMap() };
451
+ /** RT6: the footnotes of the value being rendered, numbered in text order; set and restored with `outline`. */
452
+ let footnotes: { notes: string[]; nodes: WeakMap<object, number> } = { notes: [], nodes: new WeakMap() };
453
+
454
+ /**
455
+ * RT6: a footnote's marker, V2's `<sup data-rt="fn" id="fnref-N" data-note><a href="#fn-N">N</a></sup>`.
456
+ * `data-note` carries the stored note, so the ingest reads it back; the number is its place in the
457
+ * value. A marker outside the value's body text (a table cell, which the write refuses) has none.
458
+ */
459
+ function footnoteMarkHtml(value: object): string {
460
+ const note = esc(typeof (value as { note?: unknown }).note === "string" ? (value as { note: string }).note : "");
461
+ const n = footnotes.nodes.get(value);
462
+ return n
463
+ ? `<sup data-rt="fn" id="fnref-${n}" data-note="${note}"><a href="#fn-${n}" aria-label="Footnote ${n}">${n}</a></sup>`
464
+ : `<sup data-rt="fn" data-note="${note}"></sup>`;
465
+ }
466
+
467
+ /**
468
+ * RT7: columns, V2's `<div data-rt="columns" data-gap data-cols><div data-col="true">…</div></div>`,
469
+ * each column's text rendered by the same components as the body. The flex row is inline (no
470
+ * stylesheet on a hosted page): side by side, wrapping one under another on a narrow screen. A
471
+ * legacy `builtin:columns` renders the same way.
472
+ */
473
+ function columnsHtml(value: unknown, components: () => Partial<PortableTextHtmlComponents>): string {
474
+ const view = columnsView(value);
475
+ if (!view) return "";
476
+ const s = COLUMNS_STYLES[view.gap];
477
+ const cols = view.columns
478
+ .map((c) => `<div data-col="true" style="${s.col}">${c.content.length ? toHTML(c.content as never, { components: components() }) : ""}</div>`)
479
+ .join("");
480
+ return `<div data-rt="columns" data-gap="${view.gap}" data-cols="${view.columns.length}" style="${s.row}">${cols}</div>`;
481
+ }
482
+
483
+ /**
484
+ * RT8: related posts, V2's `<aside data-rt="related" data-mode data-count data-posts><p data-title>…</p>
485
+ * <ul><li><a href>…</a></li></ul></aside>`, listing what the server resolved (relatedPostsView).
486
+ */
487
+ function relatedPostsHtml(value: unknown, resolved: RichTextResolved | undefined): string {
488
+ const { key, title, mode, count, picked, items } = relatedPostsView(value, resolved);
489
+ if (!resolved) {
490
+ return `<aside data-rt="related" data-key="${esc(key)}" data-mode="${mode}" data-count="${count}" data-posts="${esc(picked.join(","))}"${
491
+ title ? ` data-title="${esc(title)}"` : ""
492
+ }></aside>`;
493
+ }
494
+ if (!items.length) return "";
495
+ const list = items.map((p) => `<li><a href="${esc(p.url)}">${esc(p.title)}</a></li>`).join("");
496
+ return `<aside data-rt="related" data-mode="${mode}" data-count="${count}" data-posts="${esc(picked.join(","))}">${
497
+ title ? `<p data-title>${esc(title)}</p>` : ""
498
+ }${list ? `<ul>${list}</ul>` : ""}</aside>`;
499
+ }
500
+
501
+ /**
502
+ * RT8: an author bio, V2's `<aside data-rt="author" data-author><img><p data-name>…</p><p data-bio>…</p></aside>`.
503
+ * The `<img>` writes `src` last, as React does, so both renderers give the same bytes (R6).
504
+ */
505
+ function authorBioHtml(value: unknown, resolved: RichTextResolved | undefined): string {
506
+ const { key, id, author } = authorBioView(value, resolved);
507
+ if (!resolved) return `<aside data-rt="author" data-key="${esc(key)}" data-author="${esc(id)}"></aside>`;
508
+ if (!author) return "";
509
+ return `<aside data-rt="author" data-author="${esc(id)}">${author?.avatar ? `<img alt="" src="${esc(author.avatar)}">` : ""}${
510
+ author ? `<p data-name>${esc(author.name)}</p>` : ""
511
+ }${author?.bio ? `<p data-bio>${esc(author.bio)}</p>` : ""}</aside>`;
512
+ }
513
+
514
+ /** RT6: the end list, V2 `renderFootnotes` byte for byte: each note, then a link back to its marker. */
515
+ function footnoteListHtml(notes: readonly string[]): string {
516
+ if (!notes.length) return "";
517
+ const items = notes.map((n, i) => `<li id="fn-${i + 1}">${esc(n)} <a href="#fnref-${i + 1}" aria-label="Back to reference ${i + 1}">↩</a></li>`).join("");
518
+ return `<ol class="cs-footnotes" data-rt="footnotes" aria-label="Footnotes">${items}</ol>`;
519
+ }
451
520
 
452
521
  /** An h2 or h3 with its outline id, so a table of contents (or any link) can point at it. */
453
522
  const headingWithId = (tag: "h2" | "h3", classes: HtmlClasses) =>
@@ -511,9 +580,15 @@ export interface RenderOptions {
511
580
  inline?: boolean;
512
581
  /**
513
582
  * RT3: what an embed needs that the value does not hold: the X cards the server fetched for this
514
- * render (by url), and the site's opt-in to X's script. Absent: every X post is a link card.
583
+ * render (by canonical url). Absent: every X post is a link card.
515
584
  */
516
585
  embeds?: EmbedRender;
586
+ /**
587
+ * RT8: the entries this value's author bios and related posts point at, resolved by the caller
588
+ * whenever a page is produced; never stored. Absent: each block is a placeholder aside carrying
589
+ * only what is stored (the stored html cache). Present: a block with nothing to show draws nothing.
590
+ */
591
+ resolved?: RichTextResolved;
517
592
  /**
518
593
  * The site's own class per tag, for a value derived FROM that site.
519
594
  *
@@ -579,6 +654,35 @@ const checkItemHtml = (classes: HtmlClasses, value: { checked?: unknown }, child
579
654
  return `<li${cls(classes, "li")}${address} data-checked="${checked}"><input disabled type="checkbox"${checked ? " checked" : ""}>${children ?? ""}</li>`;
580
655
  };
581
656
 
657
+ /**
658
+ * The built-in blocks drawn from their OWN fields, by `schemaKey`: null falls through to the
659
+ * caller's `renderBlock`, then the marker. A map, so ui's twin can be checked key for key
660
+ * (portable-text-coverage.test.tsx): a built-in drawn here and not there is content one site loses.
661
+ *
662
+ * 🔴 AN IMAGE AND AN EMBED ARE READABLE, and rendering them as markers was silent loss: an
663
+ * imported article's illustrations and its YouTube embed disappeared from every delivered
664
+ * rendition while the stored value still held them. Their payload is a URL — there is nothing to
665
+ * resolve. A legacy columns block reads as columns only in the shape it was written in.
666
+ *
667
+ * A pre-P1c TABLE resolves against nothing either: its rows are text, held right here, so a marker
668
+ * would DELETE visible content. Migrating it rather than rendering the matrix directly is what
669
+ * keeps ONE table renderer: a value that came through the reader and one that did not produce the
670
+ * same page, which is the property four surfaces used to get wrong independently.
671
+ */
672
+ const SELF_RENDERED_BUILTINS = {
673
+ [BUILTIN_IMAGE_KEY]: (value: unknown, classes: HtmlClasses) => imageHtml(value, classes),
674
+ [BUILTIN_EMBED_KEY]: (value: unknown, classes: HtmlClasses) => embedHtml(value, classes),
675
+ [BUILTIN_CALLOUT_KEY]: (value: unknown) => calloutHtml(value),
676
+ [BUILTIN_TOGGLE_KEY]: (value: unknown) => toggleHtml(value),
677
+ [BUILTIN_COLUMNS_KEY]: (value: unknown, _classes: HtmlClasses, components: Parameters<typeof columnsHtml>[1]) =>
678
+ columnsView(value) ? columnsHtml(value, components) : null,
679
+ [BUILTIN_TABLE_KEY]: (value: unknown) => {
680
+ const legacy = builtInTableToTable(value);
681
+ return legacy ? tableHtml(legacy) : null;
682
+ },
683
+ } satisfies Record<string, (value: unknown, classes: HtmlClasses, components: Parameters<typeof columnsHtml>[1]) => string | null>;
684
+ type SelfRenderedBuiltin = keyof typeof SELF_RENDERED_BUILTINS;
685
+
582
686
  /**
583
687
  * `keys` is the set of `_key`s the value actually carries: to-html invents one (`li-0-1`) for an
584
688
  * item that has none, and an invented key is an address that resolves to nothing.
@@ -588,6 +692,7 @@ const buildComponents = (
588
692
  binding?: { base: string; keys: ReadonlySet<unknown> },
589
693
  renderBlock?: RenderOptions["renderBlock"],
590
694
  embeds: EmbedRender = {},
695
+ resolved?: RichTextResolved,
591
696
  ): Partial<PortableTextHtmlComponents> => ({
592
697
  block: { ...tagOverrides(classes, STYLE_TAGS), h2: headingWithId("h2", classes), h3: headingWithId("h3", classes) },
593
698
  list: {
@@ -624,6 +729,9 @@ const buildComponents = (
624
729
  [PT_TYPES.embed]: ({ value }) => embedFigureHtml(value, embeds),
625
730
  [PT_TYPES.gallery]: ({ value }) => galleryHtml(value),
626
731
  [PT_TYPES.tableOfContents]: ({ value }) => tocHtml(value),
732
+ [PT_TYPES.relatedPosts]: ({ value }) => relatedPostsHtml(value, resolved),
733
+ [PT_TYPES.authorBio]: ({ value }) => authorBioHtml(value, resolved),
734
+ [PT_TYPES.columns]: ({ value }) => columnsHtml(value, () => buildComponents(classes, undefined, renderBlock, embeds, resolved)),
627
735
  /**
628
736
  * A generic embedded block. A block whose payload this serialiser can READ renders as
629
737
  * content; one carrying a reference it would have to resolve against a project renders as a
@@ -636,22 +744,10 @@ const buildComponents = (
636
744
  */
637
745
  [PT_TYPES.block]: ({ value }) => {
638
746
  const v = value as { schemaKey?: unknown };
639
- if (v.schemaKey === BUILTIN_IMAGE_KEY) return imageHtml(value, classes);
640
- if (v.schemaKey === BUILTIN_EMBED_KEY) return embedHtml(value, classes);
641
- if (v.schemaKey === BUILTIN_CALLOUT_KEY) return calloutHtml(value);
642
- if (v.schemaKey === BUILTIN_TOGGLE_KEY) return toggleHtml(value);
643
- /**
644
- * A pre-P1c TABLE is the exception to the marker, and it has to be: it resolves against
645
- * nothing. Its rows are text, held right here, so a marker would DELETE visible content.
646
- * Every other built-in block (an image, a video, a component) carries a reference this
647
- * serialiser cannot resolve, so those stay markers.
648
- *
649
- * Migrating it here rather than rendering the matrix directly is what keeps ONE table
650
- * renderer: a value that came through the reader and one that did not produce the same
651
- * page, which is the property four surfaces used to get wrong independently.
652
- */
653
- const legacy = builtInTableToTable(value);
654
- if (legacy) return tableHtml(legacy);
747
+ const own = typeof v.schemaKey === "string" && Object.hasOwn(SELF_RENDERED_BUILTINS, v.schemaKey)
748
+ ? SELF_RENDERED_BUILTINS[v.schemaKey as SelfRenderedBuiltin](value, classes, () => buildComponents(classes, undefined, renderBlock, embeds, resolved))
749
+ : null;
750
+ if (own !== null) return own;
655
751
  const rendered = renderBlock?.(value as Record<string, unknown>);
656
752
  if (rendered !== undefined) return rendered;
657
753
  const key = typeof v.schemaKey === "string" && TOKEN.test(v.schemaKey.replace(/^builtin:/, ""))
@@ -659,6 +755,7 @@ const buildComponents = (
659
755
  : "block";
660
756
  return `<div class="bcms-block" data-bcms-schema-key="${esc(key)}"></div>`;
661
757
  },
758
+ [PT_TYPES.footnote]: ({ value }) => footnoteMarkHtml(value),
662
759
  [PT_TYPES.mention]: ({ value }) => {
663
760
  const v = value as { label?: unknown };
664
761
  return `<span class="bcms-mention">${esc(typeof v.label === "string" ? v.label : "")}</span>`;
@@ -676,17 +773,10 @@ const buildComponents = (
676
773
  if (!href) return children;
677
774
  // RT2: a button is a link the author marked as one; V2's `data-button` is the site's style hook.
678
775
  const button = v.button === true ? ' data-button="true"' : "";
679
- // `rel` is author data too (the importer keeps `prev`/`next` on pagination links), so only
680
- // plain link-type tokens pass; a new tab always adds noopener + noreferrer.
681
- const rel = new Set(
682
- typeof v.rel === "string" ? v.rel.toLowerCase().split(/\s+/).filter((t) => /^[a-z][a-z-]*$/.test(t)) : [],
683
- );
776
+ // `rel` is author data too: only plain link-type tokens pass; a new tab always adds noopener + noreferrer.
684
777
  const target = v.target === "_blank" ? ' target="_blank"' : "";
685
- if (target) {
686
- rel.add("noopener");
687
- rel.add("noreferrer");
688
- }
689
- const relAttr = rel.size ? ` rel="${esc([...rel].join(" "))}"` : "";
778
+ const rel = writeLinkRel(v.rel, !!target);
779
+ const relAttr = rel ? ` rel="${esc(rel)}"` : "";
690
780
  // The link's own class (kept on ingest) wins over the caller's default: it IS the site.
691
781
  const own = typeof v.className === "string" && v.className ? ` class="${esc(v.className)}"` : "";
692
782
  return `<a${own || cls(classes, "a")}${button} href="${esc(href)}"${target}${relAttr}>${children}</a>`;
@@ -725,6 +815,16 @@ const buildComponents = (
725
815
  /** The undressed components, built once: the overwhelming majority of calls pass no classes. */
726
816
  const defaultComponents = buildComponents();
727
817
 
818
+ /**
819
+ * What this serialiser draws: its node types, its marks, and the built-in blocks it draws from
820
+ * their own fields. ui's React renderer must draw every one of them (portable-text-coverage.test.tsx).
821
+ */
822
+ export const HTML_RENDERS = {
823
+ types: Object.keys(defaultComponents.types ?? {}),
824
+ marks: Object.keys(defaultComponents.marks ?? {}),
825
+ builtins: Object.keys(SELF_RENDERED_BUILTINS),
826
+ } as const;
827
+
728
828
  const isListItem = (node: unknown): boolean =>
729
829
  isRecord(node) && node._type === "block" && typeof node.listItem === "string" && !!node.listItem;
730
830
 
@@ -734,24 +834,25 @@ export function portableTextToHtml(
734
834
  ): string {
735
835
  if (!Array.isArray(value) || value.length === 0) return "";
736
836
  if (opts.inline) return render(value, opts);
737
- const outer = outline;
837
+ const outer = { outline, footnotes };
738
838
  outline = outlineOf(value);
839
+ footnotes = footnoteOrder(value);
739
840
  try {
740
- return render(value, opts);
841
+ return render(value, opts) + footnoteListHtml(footnotes.notes);
741
842
  } finally {
742
- outline = outer;
843
+ ({ outline, footnotes } = outer);
743
844
  }
744
845
  }
745
846
 
746
847
  function render(value: PortableText, opts: RenderOptions): string {
747
848
  const components =
748
- opts.classes || opts.renderBlock || opts.embeds
749
- ? buildComponents(opts.classes, undefined, opts.renderBlock, opts.embeds)
849
+ opts.classes || opts.renderBlock || opts.embeds || opts.resolved
850
+ ? buildComponents(opts.classes, undefined, opts.renderBlock, opts.embeds, opts.resolved)
750
851
  : defaultComponents;
751
852
 
752
853
  if (opts.bindingBase) {
753
854
  const base = opts.bindingBase;
754
- const bound = buildComponents(opts.classes, { base, keys: new Set(value.map((n) => n._key)) }, opts.renderBlock, opts.embeds);
855
+ const bound = buildComponents(opts.classes, { base, keys: new Set(value.map((n) => n._key)) }, opts.renderBlock, opts.embeds, opts.resolved);
755
856
  const out: string[] = [];
756
857
  for (let i = 0; i < value.length; ) {
757
858
  const node = value[i];
@@ -1,3 +1,14 @@
1
+ import {
2
+ focalInCrop,
3
+ formatImageRect,
4
+ imageValueUrl,
5
+ normalizeImageCrop,
6
+ normalizeImageFocal,
7
+ objectPosition,
8
+ parseImageRect,
9
+ type ImageCrop,
10
+ type ImageFocal,
11
+ } from "@bettercms-ai/image-url";
1
12
  /**
2
13
  * Where a rich-text image sits and how wide it is — `align` and `size` on a `builtin:image`
3
14
  * block's fields.
@@ -67,3 +78,46 @@ export function richTextImageLayout(fields: Record<string, unknown>): RichTextIm
67
78
  figureStyle: `max-width:${maxWidth};margin-left:${ml};margin-right:${mr}`,
68
79
  };
69
80
  }
81
+
82
+ /**
83
+ * RT9: a rich-text image's framing, the platform's own image vocabulary (`@bettercms-ai/image-url`):
84
+ * `crop`, how much to cut from each edge as 0–1 fractions of the source, and `focal`, a 0–1 point
85
+ * of the source. Both optional on a `builtin:image`'s fields and on a gallery image.
86
+ *
87
+ * The crop is applied the way every lane applies it, through `imageValueUrl` (`?rect=` on a
88
+ * BetterCMS media url; any other url is drawn uncropped); the focal point is `object-position`
89
+ * inside that crop. What is stored rides the `<img>` as `data-src` / `data-crop` / `data-focal`
90
+ * whenever there is a frame, so the ingest reads back exactly what was stored.
91
+ */
92
+ export interface RichTextImageFrame {
93
+ /** The url to draw: the stored one, with the crop applied when it can be. */
94
+ src: string;
95
+ crop: ImageCrop | null;
96
+ focal: ImageFocal | null;
97
+ /** `object-position` for the focal point, or null when there is none. */
98
+ position: string | null;
99
+ /** The `data-*` attributes carrying what is stored, in render order; empty when there is no frame. */
100
+ data: [string, string][];
101
+ }
102
+
103
+ export function richTextImageFrame(src: string, crop: unknown, focal: unknown): RichTextImageFrame {
104
+ const c = normalizeImageCrop(crop);
105
+ const f = normalizeImageFocal(focal);
106
+ if (!c && !f) return { src, crop: null, focal: null, position: null, data: [] };
107
+ const drawn = imageValueUrl({ url: src, crop: c, focal: f }) ?? src;
108
+ const data: [string, string][] = [];
109
+ if (drawn !== src) data.push(["data-src", src]);
110
+ if (c) data.push(["data-crop", formatImageRect(c)]);
111
+ if (f) data.push(["data-focal", `${f.x},${f.y}`]);
112
+ return { src: drawn, crop: c, focal: f, position: f ? objectPosition(focalInCrop(f, c)) : null, data };
113
+ }
114
+
115
+ /** The stored framing an `<img>` carries back (`richTextImageFrame`'s `data`), for the ingest. */
116
+ export function readImageFrame(img: { getAttribute(name: string): string | null }): { src: string | null; crop: ImageCrop | null; focal: ImageFocal | null } {
117
+ const xy = (img.getAttribute("data-focal") ?? "").split(",").map(Number);
118
+ return {
119
+ src: img.getAttribute("data-src"),
120
+ crop: parseImageRect(img.getAttribute("data-crop")),
121
+ focal: xy.length === 2 ? normalizeImageFocal({ x: xy[0], y: xy[1] }) : null,
122
+ };
123
+ }
package/src/index.ts CHANGED
@@ -8,6 +8,9 @@ export * from "./table";
8
8
  export * from "./key-takeaways";
9
9
  export * from "./gallery";
10
10
  export * from "./toc";
11
+ export * from "./footnote";
12
+ export * from "./columns";
13
+ export * from "./references";
11
14
  export * from "./styled";
12
15
  export * from "./read";
13
16
  export * from "./component-copy";