@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/package.json +3 -2
- package/src/accept.ts +12 -6
- package/src/columns.ts +117 -0
- package/src/footnote.ts +70 -0
- package/src/gallery.ts +24 -4
- package/src/href.ts +39 -0
- package/src/html.ts +152 -51
- package/src/image-layout.ts +54 -0
- package/src/index.ts +3 -0
- package/src/ingest.ts +210 -33
- package/src/plain.ts +10 -0
- package/src/read.ts +59 -1
- package/src/references.ts +147 -0
- package/src/schema.ts +32 -0
- package/src/toc.ts +25 -8
- package/src/types.ts +74 -2
- package/src/write-refusals.ts +14 -6
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
|
-
|
|
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
|
|
287
|
-
const
|
|
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"
|
|
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)
|
|
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
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
if (
|
|
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
|
|
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
|
-
|
|
686
|
-
|
|
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];
|
package/src/image-layout.ts
CHANGED
|
@@ -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";
|