@pramen/cms 0.0.49 → 0.0.50
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/README.md +364 -1
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +138 -0
- package/dist/href.d.ts +14 -0
- package/dist/href.js +22 -0
- package/dist/index.d.ts +519 -19
- package/dist/index.js +1726 -83
- package/dist/react.d.ts +15 -2
- package/dist/react.js +96 -1
- package/package.json +8 -3
- package/src/cli.ts +148 -0
- package/src/href.ts +24 -0
- package/src/index.ts +1958 -91
- package/src/react.ts +132 -3
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
|
|
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
|
-
|
|
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
|
+
}
|