@pramen/cms 0.0.49 → 0.0.51

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/react.ts CHANGED
@@ -13,8 +13,9 @@
13
13
  // normally write `<BlockRenderer .../>` — it's an ordinary component either way.
14
14
 
15
15
  import { createElement, Fragment } from "react";
16
- import type { ComponentType, ReactElement } from "react";
17
- import type { RenderedBlock, BlockTypeDef, BlockFieldsOf } from "./index";
16
+ import type { ComponentType, ReactElement, ReactNode } from "react";
17
+ import { isSafeHref, normalizeHref } from "./href";
18
+ import type { RenderedBlock, BlockTypeDef, BlockFieldsOf, RichTextDoc, RichTextNode, RichTextMark } from "./index";
18
19
 
19
20
  /** Props a component for a specific typed block type receives — `fields` is inferred from
20
21
  * the block type's schema via `BlockFieldsOf` (see `defineBlockType`). */
@@ -52,7 +53,9 @@ export function BlockRenderer({ blocks, region, components, fallback }: BlockRen
52
53
  Fragment,
53
54
  null,
54
55
  blocks.map((block, index) => {
55
- const Component = components[block.block_type];
56
+ // hasOwn: a block type slugged `constructor`/`valueOf` would otherwise resolve off
57
+ // the prototype and crash the whole page render (createBlockType accepts any string).
58
+ const Component = Object.hasOwn(components, block.block_type) ? components[block.block_type] : undefined;
56
59
  if (!Component) {
57
60
  return fallback
58
61
  ? createElement(fallback, { key: block.id, block })
@@ -74,3 +77,129 @@ export interface RegionRendererProps {
74
77
  export function RegionRenderer({ regions, name, components, fallback }: RegionRendererProps): ReactElement {
75
78
  return BlockRenderer({ blocks: regions[name] ?? [], region: name, components, fallback });
76
79
  }
80
+
81
+ // --- rich text ----------------------------------------------------------------------
82
+ // A `richtext` field is a document tree, not an HTML string, so it renders as REAL React
83
+ // elements — no `dangerouslySetInnerHTML`, and nothing to sanitize at render time (the
84
+ // write path already dropped every node/mark outside the allow-list). Override any node
85
+ // type through `components` when your design system wants its own element.
86
+
87
+ /** Props a rich-text node component receives. `children` is the already-rendered subtree. */
88
+ export interface RichTextNodeProps {
89
+ node: RichTextNode;
90
+ children: ReactNode;
91
+ }
92
+
93
+ /** Per-node-type component overrides, keyed by node type (`"paragraph"`, `"heading"`, …). */
94
+ export type RichTextNodeComponents = Record<string, ComponentType<RichTextNodeProps>>;
95
+
96
+ const MARK_TAGS: Record<string, string> = {
97
+ bold: "strong",
98
+ italic: "em",
99
+ underline: "u",
100
+ strike: "s",
101
+ code: "code",
102
+ highlight: "mark",
103
+ };
104
+
105
+ /** Wrap a text leaf in its marks, innermost-first. A `link` is the only mark carrying
106
+ * attributes we pass through; `target="_blank"` gets `rel` forced, since the renderer owns
107
+ * the markup and a bare `_blank` hands the opened page a `window.opener` handle. */
108
+ function renderMarks(text: string, marks: RichTextMark[] | undefined): ReactNode {
109
+ let out: ReactNode = text;
110
+ // Innermost-first, so `marks[0]` ends up OUTERMOST — matching ProseMirror's own
111
+ // serializer and RichTextMarks.astro. Folding forwards put marks[0] innermost, so the
112
+ // same document rendered `<code><a>x</a></code>` here and `<a><code>x</code></a>` in
113
+ // Astro: different clickable area, different CSS selectors, same content.
114
+ for (let i = (marks?.length ?? 0) - 1; i >= 0; i--) {
115
+ const mark = marks![i]!;
116
+ if (mark.type === "link") {
117
+ const attrs = mark.attrs ?? {};
118
+ // The href is the one attribute that can execute script, and this renderer declares
119
+ // itself a rescue for content that never passed through normalizeRichText (an app
120
+ // writing rows via its own mutation, a bootstrap seed, an import). So check it here
121
+ // too: an unsafe href drops the anchor and renders the text, never a live link.
122
+ if (!isSafeHref(attrs.href)) continue;
123
+ const target = typeof attrs.target === "string" ? attrs.target : undefined;
124
+ out = createElement(
125
+ "a",
126
+ {
127
+ href: normalizeHref(String(attrs.href ?? "")),
128
+ title: typeof attrs.title === "string" ? attrs.title : undefined,
129
+ target,
130
+ // Any named target opens a window holding a live `window.opener`, not just
131
+ // `_blank` — browsers imply noopener for `_blank` alone.
132
+ rel: target ? "noopener noreferrer" : undefined,
133
+ },
134
+ out,
135
+ );
136
+ } else {
137
+ // hasOwn: a plain index would resolve `constructor`/`toString` off the prototype.
138
+ out = createElement(Object.hasOwn(MARK_TAGS, mark.type) ? MARK_TAGS[mark.type]! : "span", null, out);
139
+ }
140
+ }
141
+ return out;
142
+ }
143
+
144
+ function renderRichTextNode(node: RichTextNode, key: number, components?: RichTextNodeComponents): ReactNode {
145
+ if (node.type === "text") return createElement(Fragment, { key }, renderMarks(node.text ?? "", node.marks));
146
+
147
+ const children = (node.content ?? []).map((child, i) => renderRichTextNode(child, i, components));
148
+ const Override = components && Object.hasOwn(components, node.type) ? components[node.type] : undefined;
149
+ if (Override) return createElement(Override, { key, node, children });
150
+
151
+ const attrs = node.attrs ?? {};
152
+ switch (node.type) {
153
+ case "paragraph":
154
+ return createElement("p", { key }, children);
155
+ case "heading": {
156
+ // Range-checked, matching RichText.astro. Both renderers declare themselves a rescue
157
+ // for hand-written content that never passed through normalizeRichText, so neither
158
+ // may trust the attribute — an out-of-range level would emit <h0>/<h99>.
159
+ // Integer too: `h2.5` throws InvalidCharacterError in document.createElement.
160
+ const raw = attrs.level;
161
+ const level = typeof raw === "number" && Number.isInteger(raw) && raw >= 1 && raw <= 6 ? raw : 2;
162
+ return createElement(`h${level}`, { key }, children);
163
+ }
164
+ case "blockquote":
165
+ return createElement("blockquote", { key }, children);
166
+ case "codeBlock":
167
+ return createElement(
168
+ "pre",
169
+ { key },
170
+ createElement("code", { className: typeof attrs.language === "string" ? `language-${attrs.language}` : undefined }, children),
171
+ );
172
+ case "bulletList":
173
+ return createElement("ul", { key }, children);
174
+ case "orderedList":
175
+ return createElement("ol", { key, start: typeof attrs.start === "number" ? attrs.start : undefined }, children);
176
+ case "listItem":
177
+ return createElement("li", { key }, children);
178
+ case "taskList":
179
+ return createElement("ul", { key, "data-type": "taskList" }, children);
180
+ case "taskItem":
181
+ return createElement("li", { key, "data-type": "taskItem", "data-checked": attrs.checked === true ? "true" : "false" }, children);
182
+ case "hardBreak":
183
+ return createElement("br", { key });
184
+ case "horizontalRule":
185
+ return createElement("hr", { key });
186
+ default:
187
+ // Unreachable through the write path (normalizeRichText drops unknown types), so
188
+ // rendering the subtree is a rescue for hand-written content, not a policy.
189
+ return createElement(Fragment, { key }, children);
190
+ }
191
+ }
192
+
193
+ export interface RichTextRendererProps {
194
+ value: RichTextDoc | null | undefined;
195
+ components?: RichTextNodeComponents;
196
+ }
197
+
198
+ /** Render a rich-text document as React elements. */
199
+ export function RichTextRenderer({ value, components }: RichTextRendererProps): ReactElement {
200
+ return createElement(
201
+ Fragment,
202
+ null,
203
+ (value?.content ?? []).map((node, i) => renderRichTextNode(node, i, components)),
204
+ );
205
+ }