@bettercms-ai/richtext 0.3.0 → 0.4.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.3.0",
3
+ "version": "0.4.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.",
@@ -15,6 +15,7 @@
15
15
  "exports": {
16
16
  ".": "./src/index.ts",
17
17
  "./html": "./src/html.ts",
18
+ "./heading": "./src/heading.ts",
18
19
  "./ingest": "./src/ingest.ts"
19
20
  },
20
21
  "scripts": {
@@ -49,6 +49,7 @@ interface LexNode {
49
49
  checked?: boolean;
50
50
  url?: string;
51
51
  target?: string | null;
52
+ rel?: string | null;
52
53
  mentionId?: string;
53
54
  children?: unknown[];
54
55
  blockAttrs?: Record<string, unknown>;
@@ -102,6 +103,8 @@ function spansFrom(
102
103
  if (href) {
103
104
  const def: PortableTextMarkDef = { _type: PT_TYPES.link, _key: ctx.key(), href };
104
105
  if (typeof node.target === "string" && node.target) def.target = node.target;
106
+ // `rel` rides next to `target` — the importer keeps `prev`/`next` on pagination links.
107
+ if (typeof node.rel === "string" && node.rel) (def as PortableTextMarkDef & { rel?: string }).rel = node.rel;
105
108
  markDefs.push(def);
106
109
  marks = [...inherited, def._key];
107
110
  }
package/src/heading.ts ADDED
@@ -0,0 +1,132 @@
1
+ /**
2
+ * A `richtext` block rendered AS a heading — `props.level` (1–6).
3
+ *
4
+ * Closes the gap between the two blocks that each held half of a title: `heading` has a level
5
+ * but a PLAIN `text` (the renderer escapes it, so `Equip Your Projects with <span
6
+ * class="text-yellow-500">ScrewFast</span>` loses its brand span), and `richtext` keeps the
7
+ * markup but had no level. With `level` set, the renderer emits `<hN>` around the rich copy
8
+ * instead of `<div class="bcms-text">`, so the marks survive AND the element is a real heading —
9
+ * the one site CSS sizes (`.bcms-page h1{font-size:…}` keys on the tag, not a class).
10
+ *
11
+ * WHY A LEVEL ON `richtext` RATHER THAN MARKS ON `heading.text`: `props.html` is already the one
12
+ * MARKUP slot every writer, override and coercion rule knows about (`MARKUP_PATH` in
13
+ * prop-value.ts, block-pass.ts and @bettercms-ai/ui/resolve). Making `heading.props.text` a
14
+ * Portable Text value would add a second markup slot, and every reader that does
15
+ * `String(p.text)` would print `[object Object]`. A number beside `html` touches nothing that
16
+ * reads `html`.
17
+ *
18
+ * ── The one rule both the validator and the renderer apply ──────────────────────────────────
19
+ * A heading holds PHRASING content only. `<h1><p>…</p><p>…</p></h1>` is not a heading, it is
20
+ * two paragraphs in a heading-shaped box, and it is what a naïve wrapper would emit for every
21
+ * editor that stores a lone line as `<p>…</p>`. So the value must reduce to ONE line of inline
22
+ * copy: a lone Portable Text text block, or html with at most a bare `<p>`/`<hN>` wrapper around
23
+ * inline markup. The write schema refuses anything else with a message naming the fix; the
24
+ * renderer, which also sees values no block schema checked (component overrides), falls back to
25
+ * the ordinary `<div class="bcms-text">` rather than emit invalid markup or drop copy.
26
+ */
27
+ import { isPortableTextBlock, type PortableTextBlock } from "./types";
28
+ import { readStructuredContent } from "./read";
29
+ import { portableTextToHtml } from "./html";
30
+
31
+ export type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
32
+
33
+ /**
34
+ * The level a `richtext` block renders at, or null for "not a heading" — which is every block
35
+ * stored before this existed. No clamping: `heading` clamps because its level is always a
36
+ * heading, but here an absent/invalid level must mean the ordinary paragraph render, so a
37
+ * stray `0`, `null` or `""` cannot quietly turn body copy into an `<h1>`.
38
+ */
39
+ export function richtextHeadingLevel(value: unknown): HeadingLevel | null {
40
+ if (value === null || value === undefined || value === "" || typeof value === "boolean") return null;
41
+ const n = Number(value);
42
+ return Number.isInteger(n) && n >= 1 && n <= 6 ? (n as HeadingLevel) : null;
43
+ }
44
+
45
+ /** Flow-content tags a heading may not contain. Phrasing tags (`span`, `a`, `strong`, `br`) pass. */
46
+ const BLOCK_TAG =
47
+ /<\/?(?:address|article|aside|blockquote|details|dialog|div|dl|dd|dt|fieldset|figcaption|figure|footer|form|h[1-6]|header|hr|li|main|nav|ol|p|pre|section|summary|table|tbody|td|tfoot|th|thead|tr|ul)\b/i;
48
+
49
+ /**
50
+ * An ATTRIBUTE-LESS lone `<p>` or `<hN>`. With attributes it is not unwrapped: dropping a
51
+ * `<p class="lead">` would silently change the design, so that value is refused instead.
52
+ */
53
+ const LONE_WRAPPER = /^<(p|h[1-6])>([\s\S]*)<\/\1>$/i;
54
+
55
+ /** `html` as the inner content of a heading, or null when it is not one line of inline copy. */
56
+ export function inlineHeadingHtml(html: string): string | null {
57
+ const m = LONE_WRAPPER.exec(html.trim());
58
+ const inner = m ? m[2]! : html;
59
+ return BLOCK_TAG.test(inner) ? null : inner;
60
+ }
61
+
62
+ /**
63
+ * The ONE Portable Text block a heading can be made of, or null. A list item, a quote, a table
64
+ * or a second block is not a heading; `normal` and an existing `h1`–`h6` style are (the level
65
+ * on the block, not the style in the value, decides the tag).
66
+ */
67
+ export function loneHeadingBlock(content: unknown[]): PortableTextBlock | null {
68
+ if (content.length !== 1) return null;
69
+ const node = content[0];
70
+ if (!isPortableTextBlock(node) || node.listItem !== undefined) return null;
71
+ const style = node.style ?? "normal";
72
+ return style === "normal" || /^h[1-6]$/.test(style) ? node : null;
73
+ }
74
+
75
+ /** True when these richtext props can render as a heading. What the write schema enforces. */
76
+ export function canRenderAsHeading(props: Record<string, unknown>): boolean {
77
+ const structured = readStructuredContent(props);
78
+ if (structured) return loneHeadingBlock(structured) !== null;
79
+ return inlineHeadingHtml(typeof props.html === "string" ? props.html : "") !== null;
80
+ }
81
+
82
+ /**
83
+ * The level the VALUE spells for itself — a lone `<h3>` wrapper, or a lone `h3`-styled Portable
84
+ * Text block — or null when it names none (a `<p>`, bare inline copy, `normal`).
85
+ *
86
+ * `props.level` decides the tag, so a value that names a DIFFERENT one is a contradiction the
87
+ * write schema refuses rather than silently overriding: `{ html: "<h3>T</h3>", level: 1 }` would
88
+ * store as an h3 and render as an h1, and the editor's block-style control reads the VALUE.
89
+ * Same class of defect as `headingLevelMismatch` in publish-static.ts, one layer up.
90
+ */
91
+ export function valueHeadingLevel(props: Record<string, unknown>): HeadingLevel | null {
92
+ const structured = readStructuredContent(props);
93
+ const tag = structured
94
+ ? loneHeadingBlock(structured)?.style
95
+ : LONE_WRAPPER.exec(String(props.html ?? "").trim())?.[1];
96
+ return tag && /^h[1-6]$/i.test(tag) ? (Number(tag[1]) as HeadingLevel) : null;
97
+ }
98
+
99
+ /**
100
+ * The inner markup + binding ADDRESS of a levelled richtext value, or null when the value is
101
+ * not one line of inline copy (the caller then renders it as ordinary rich text).
102
+ *
103
+ * Shared by all three renderers — render-page.ts, @bettercms-ai/next and @bettercms-ai/astro —
104
+ * because a heading assembled once per lane is exactly the divergence `parity.test.ts` exists to
105
+ * catch: three green suites and one lane shipping a `<div>` where the others ship an `<hN>`.
106
+ * The lanes differ only in how they SPELL attributes (a string, a React prop, an Astro spread),
107
+ * so the address comes back raw and each lane formats it the way it already does.
108
+ *
109
+ * The binding is the one each lane already emits, moved from the wrapper onto the heading so no
110
+ * `<div>` lands inside an `<hN>`: the html lane binds the whole field (`<blockId>__html`); the
111
+ * Portable Text lane binds the lone block by its `_key` (`<base>#<_key>`), exactly as
112
+ * `portableTextToHtml({ bindingBase })` addresses it — so the editor's writer is unchanged.
113
+ */
114
+ export function headingParts(
115
+ /** Any block's props — `{ html?, content? }`, as loose as the three lanes' own types are. */
116
+ props: { html?: unknown },
117
+ base: string | null,
118
+ ): { inner: string; address: string | null } | null {
119
+ const structured = readStructuredContent(props);
120
+ if (structured) {
121
+ const node = loneHeadingBlock(structured);
122
+ if (!node) return null;
123
+ // Style normalised to `normal` so the serialiser emits a lone <p> that `inline` unwraps; the
124
+ // block's LEVEL, not any h1–h6 style inside the value, decides the tag.
125
+ return {
126
+ inner: portableTextToHtml([{ ...node, style: "normal" }], { inline: true }),
127
+ address: base && node._key ? `${base}#${node._key}` : null,
128
+ };
129
+ }
130
+ const inner = inlineHeadingHtml(String(props.html ?? ""));
131
+ return inner === null ? null : { inner, address: base };
132
+ }
package/src/href.ts CHANGED
@@ -11,6 +11,12 @@ const SAFE_HREF_SCHEMES = ["http:", "https:", "mailto:", "tel:"];
11
11
  /** The href if it is safe to emit, otherwise null. Callers render the label as plain text. */
12
12
  export function safeHref(raw: string): string | null {
13
13
  const trimmed = raw.trim();
14
+ // An in-page FRAGMENT (`#where-it-comes-from`) can only move within the page, so it is as safe
15
+ // as a site-relative path. Refusing it cost every imported Contents list its links: the
16
+ // importer's Lexical anchors were dropped on conversion, on ingest and on render alike
17
+ // (astro-sumi, 2026-09-14). No whitespace or markup characters, so it cannot break out of the
18
+ // attribute it is written into.
19
+ if (trimmed.startsWith("#")) return /^#[^\s<>"'`]*$/.test(trimmed) ? trimmed : null;
14
20
  // A site-relative path has no scheme to check and cannot navigate off-origin.
15
21
  if (trimmed.startsWith("/") && !trimmed.startsWith("//")) return trimmed;
16
22
  try {
package/src/html.ts CHANGED
@@ -150,11 +150,21 @@ const components: Partial<PortableTextHtmlComponents> = {
150
150
  * renders the LABEL rather than deleting it — a blocked link must not lose the words.
151
151
  */
152
152
  [PT_TYPES.link]: ({ value, children }) => {
153
- const v = (value ?? {}) as { href?: unknown; target?: unknown };
153
+ const v = (value ?? {}) as { href?: unknown; target?: unknown; rel?: unknown };
154
154
  const href = typeof v.href === "string" ? safeHref(v.href) : null;
155
155
  if (!href) return children;
156
- const target = v.target === "_blank" ? ' target="_blank" rel="noopener noreferrer"' : "";
157
- return `<a href="${esc(href)}"${target}>${children}</a>`;
156
+ // `rel` is author data too (the importer keeps `prev`/`next` on pagination links), so only
157
+ // plain link-type tokens pass; a new tab always adds noopener + noreferrer.
158
+ const rel = new Set(
159
+ typeof v.rel === "string" ? v.rel.toLowerCase().split(/\s+/).filter((t) => /^[a-z][a-z-]*$/.test(t)) : [],
160
+ );
161
+ const target = v.target === "_blank" ? ' target="_blank"' : "";
162
+ if (target) {
163
+ rel.add("noopener");
164
+ rel.add("noreferrer");
165
+ }
166
+ const relAttr = rel.size ? ` rel="${esc([...rel].join(" "))}"` : "";
167
+ return `<a href="${esc(href)}"${target}${relAttr}>${children}</a>`;
158
168
  },
159
169
  /**
160
170
  * The site's own inline markup, written back out as itself.