@localess/richtext 3.4.1 → 4.0.0-dev.20260905071322

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/SKILL.md CHANGED
@@ -1,75 +1,124 @@
1
- ---
2
- name: localess-richtext
3
- description: Framework-neutral rich text model and renderer for Localess TipTap JSON content. Zero dependencies. Use when rendering Localess RICH_TEXT fields to HTML or building a framework-specific rich text walker.
4
- ---
5
-
6
- # @localess/richtext
7
-
8
- Renders Localess rich text field values (TipTap/ProseMirror JSON produced by
9
- the Localess Studio editor) without TipTap at runtime. Zero production
10
- dependencies; safe in browsers, SSR, and edge runtimes.
11
-
12
- Framework packages (`@localess/react`, `@localess/vue`, `@localess/svelte`,
13
- `@localess/astro`, `@localess/angular`) ship idiomatic wrappers — prefer those
14
- in app code. Use this package directly for custom pipelines (emails, static
15
- generation, other frameworks).
16
-
17
- ## Render to HTML
18
-
19
- ```ts
20
- import { renderRichTextToHtml } from '@localess/richtext';
21
-
22
- const html = renderRichTextToHtml(data.body);
23
- ```
24
-
25
- - Input: `LocalessRichTextInput` — a `doc`, a node, a node array, `null`, or
26
- `@localess/client`'s `ContentRichText` (structurally compatible, no cast).
27
- - Output is byte-identical to TipTap's `generateHTML` for the Studio's
28
- extension set, except link `href`s pass a protocol allowlist
29
- (`http:`/`https:`/`mailto:`/`tel:`/scheme-less); `javascript:`/`data:`
30
- hrefs become `""`.
31
- - Never throws; malformed input renders `''`.
32
-
33
- ## Supported node set
34
-
35
- Nodes: `doc`, `paragraph`, `heading` (1–6), `bulletList`, `orderedList`
36
- (`start`), `listItem`, `codeBlock` (`language` → `class="language-x"` on
37
- `<code>`), `text`. Marks: `bold` `<strong>`, `italic` → `<em>`,
38
- `strike` → `<s>`, `underline` → `<u>`, `code` → `<code>`, `link` → `<a>`.
39
-
40
- Unknown types are skipped with a dev-only warning unless a custom renderer is
41
- provided for that type string.
42
-
43
- ## Custom renderers
44
-
45
- ```ts
46
- renderRichTextToHtml(data.body, {
47
- renderers: {
48
- heading: ({ attrs, children }) => `<h${attrs.level} class="title">${children}</h${attrs.level}>`,
49
- },
50
- });
51
- ```
52
-
53
- `children` arrives pre-rendered. `props.context.renderers` has the current
54
- type unset pass it to a nested `renderRichTextToHtml` call to re-render your
55
- own node without infinite recursion.
56
-
57
- ## Building a native walker
58
-
59
- The helpers encode the algorithms once so walkers are mechanical translations:
60
- `normalizeInput(input, { withKeys: true })` (keyed node list),
61
- `buildMarkTree(textRun)` (adjacent-mark merging), `processAttrs(type, attrs,
62
- { attrMap })` (attribute normalization; React passes `{ class: 'className' }`),
63
- `NODE_RENDER_MAP` / `MARK_RENDER_MAP` / `resolveHeadingTag` (default table).
64
- See `@localess/react`'s `src/core/richtext.ts` for the reference walker.
65
-
66
- ## Test fixtures
67
-
68
- ```ts
69
- import { richTextFixtures } from '@localess/richtext/test-utils';
70
- ```
71
-
72
- `{ title, input, expected, parity }` corpus asserted by every Localess
73
- renderer. `parity: true` fixtures are additionally byte-compared to TipTap's
74
- `generateHTML` parity is normative; never weaken an assertion to
75
- `toContain`.
1
+ ---
2
+ name: localess-richtext
3
+ description: Framework-neutral rich text model and renderer for Localess TipTap JSON content. Zero dependencies beyond the shared @localess/model types package. Use when rendering Localess RICH_TEXT fields to HTML or building a framework-specific rich text walker.
4
+ ---
5
+
6
+ # @localess/richtext
7
+
8
+ Renders Localess rich text field values (TipTap/ProseMirror JSON produced by
9
+ the Localess Studio editor) without TipTap at runtime. Zero external
10
+ dependencies its only dependency is the in-monorepo, itself-zero-dependency
11
+ `@localess/model` types package; safe in browsers, SSR, and edge runtimes.
12
+ Requires Node.js >= 24.0.0 when used server-side.
13
+
14
+ ```bash
15
+ npm install @localess/richtext
16
+ ```
17
+
18
+ Framework packages (`@localess/react`, `@localess/vue`, `@localess/svelte`,
19
+ `@localess/astro`, `@localess/angular`) ship idiomatic wrappers — prefer those
20
+ in app code. Use this package directly for custom pipelines (emails, static
21
+ generation, other frameworks).
22
+
23
+ ## Render to HTML
24
+
25
+ ```ts
26
+ import { renderRichTextToHtml } from '@localess/richtext';
27
+
28
+ const html = renderRichTextToHtml(data.body);
29
+ ```
30
+
31
+ - Input: `LocalessRichTextInput` a `doc`, a node, a node array, `null`, or
32
+ `ContentRichText` (re-exported from `@localess/model`, no cast).
33
+ - Output is byte-identical to TipTap's `generateHTML` for the Studio's
34
+ extension set, except link `href`s pass a protocol allowlist
35
+ (`http:`/`https:`/`mailto:`/`tel:`/scheme-less); `javascript:`/`data:`
36
+ hrefs become `""`.
37
+ - Never throws; malformed input renders `''`.
38
+
39
+ ## Supported node set
40
+
41
+ Nodes: `doc`, `paragraph` `<p>`, `heading` (`level` 1–6 → `<h1>`…`<h6>`;
42
+ any other level falls back to `<h1>`), `bulletList` → `<ul>`, `orderedList`
43
+ `<ol>` (`start` emitted only when present and not `1`), `listItem` → `<li>`,
44
+ `codeBlock` → `<pre><code>` (`language` → `class="language-x"` on `<code>`),
45
+ `text`. Marks: `bold` → `<strong>`, `italic` → `<em>`, `strike` → `<s>`,
46
+ `underline` → `<u>`, `code` → `<code>`, `link` → `<a>` (`target`, `rel`,
47
+ sanitized `href`, `class`, in that order; `null`/empty attrs are dropped).
48
+
49
+ Adjacent `text` nodes sharing outer marks are merged into one wrapper
50
+ (`<strong>a<em>b</em></strong>`, one `<a>` per link span), matching
51
+ ProseMirror's serializer. Text is escaped `& < >`; attribute values `& " < >`.
52
+
53
+ Unknown node/mark types are skipped (marks: their children are still emitted)
54
+ with a `console.warn` once per type per render suppressed when
55
+ `process.env.NODE_ENV === 'production'` unless a custom renderer is provided
56
+ for that type string.
57
+
58
+ ## Custom renderers
59
+
60
+ ```ts
61
+ renderRichTextToHtml(data.body, {
62
+ renderers: {
63
+ heading: ({ attrs, children }) => `<h${attrs.level} class="title">${children}</h${attrs.level}>`,
64
+ },
65
+ });
66
+ ```
67
+
68
+ `children` arrives pre-rendered. `props.context.renderers` has the current
69
+ type unset pass it to a nested `renderRichTextToHtml` call to re-render your
70
+ own node without infinite recursion.
71
+
72
+ Renderer props (`LocalessRichTextRendererProps<TOut>`): `type`, `attrs?`,
73
+ `text?`, `marks?`, `content?`, `children`, `context: { renderers? }`, `_key?`.
74
+ Options type: `LocalessRichTextHtmlOptions` (`{ renderers?:
75
+ LocalessRichTextRenderers<string> }`). A custom `text` renderer receives the
76
+ HTML-escaped text as `children` and disables adjacent-mark merging for that
77
+ render; a custom mark renderer receives `context.renderers` unchanged (marks
78
+ don't nest into themselves).
79
+
80
+ ## Building a native walker
81
+
82
+ The helpers encode the algorithms once so walkers are mechanical translations:
83
+ `normalizeInput(input, { withKeys: true })` (keyed node list, `_key` =
84
+ `paragraph-1`, `text-3`, …; never throws, malformed input → `[]`),
85
+ `buildMarkTree(textRun)` → `MarkTreeSegment[]` (adjacent-mark merging;
86
+ `marksEqual(a, b)` is the comparison it uses), `processAttrs(type, attrs,
87
+ { attrMap })` (attribute normalization; React passes `{ class: 'className' }`),
88
+ `NODE_RENDER_MAP` / `MARK_RENDER_MAP` / `resolveHeadingTag` (default table;
89
+ `null` entry = transparent, missing key = unknown), `escapeHtml` /
90
+ `escapeAttr` / `sanitizeUrl` (escaping and URL allowlist).
91
+ See `@localess/react`'s `src/core/richtext.ts` for the reference walker.
92
+
93
+ ## Test fixtures
94
+
95
+ ```ts
96
+ import { richTextFixtures } from '@localess/richtext/test-utils';
97
+ ```
98
+
99
+ `richTextFixtures: RichTextFixture[]` — a `{ title, input, expected, parity }`
100
+ corpus asserted by every Localess renderer. `parity: true` fixtures are
101
+ additionally byte-compared to TipTap's `generateHTML` — parity is normative;
102
+ never weaken an assertion to `toContain`.
103
+
104
+ ## Exports Reference
105
+
106
+ ```typescript
107
+ // @localess/richtext
108
+ export { renderRichTextToHtml } // HTML string renderer
109
+ export { normalizeInput, buildMarkTree, marksEqual, processAttrs } // walker helpers
110
+ export { escapeHtml, escapeAttr, sanitizeUrl } // escaping / URL policy
111
+ export { NODE_RENDER_MAP, MARK_RENDER_MAP, resolveHeadingTag } // default render table
112
+ export type {
113
+ LocalessRichTextDocument, LocalessRichTextNode, LocalessRichTextNodeWithKey,
114
+ LocalessRichTextMark, LocalessRichTextLinkAttrs, LocalessRichTextElement,
115
+ LocalessRichTextInput, ContentRichText /* re-exported from @localess/model */,
116
+ LocalessRichTextRenderer, LocalessRichTextRenderers, LocalessRichTextRendererProps,
117
+ LocalessRichTextHtmlOptions, NormalizeInputOptions, ProcessAttrsOptions,
118
+ RichTextRenderSpec, MarkTreeSegment, MarkTreeText, MarkTreeMark,
119
+ }
120
+
121
+ // @localess/richtext/test-utils
122
+ export { richTextFixtures }
123
+ export type { RichTextFixture }
124
+ ```
package/dist/index.js CHANGED
@@ -1 +1,323 @@
1
- Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});var e={"&":`&amp;`,"<":`&lt;`,">":`&gt;`},t={...e,'"':`&quot;`};function n(t){return t.replace(/[&<>]/g,t=>e[t])}function r(e){return e.replace(/[&"<>]/g,e=>t[e])}var i=/^(?:https?:|mailto:|tel:)/i,a=/^[a-z][a-z0-9+.-]*:/i;function o(e){let t=e.trim();return t===``?``:i.test(t)||!a.test(t)?t:``}function s(e,t,n={}){let r={},i=e=>n.attrMap?.[e]??e,a=(e,t)=>{t!=null&&t!==``&&(r[i(e)]=t)};if(!t)return r;switch(e){case`orderedList`:t.start!==null&&t.start!==void 0&&t.start!==1&&a(`start`,t.start);break;case`codeBlock`:t.language&&a(`class`,`language-${t.language}`);break;case`link`:a(`target`,t.target),a(`rel`,t.rel),r[i(`href`)]=o(String(t.href??``)),a(`class`,t.class)}return r}function c(e,t){return e.type===t.type&&JSON.stringify(e.attrs??{})===JSON.stringify(t.attrs??{})}function l(e){let t=[],n=[];for(let r of e){let e=r.marks??[],i=0;for(;i<n.length&&i<e.length&&c(n[i].mark,e[i]);)i++;n.length=i;for(let r=i;r<e.length;r++){let i={kind:`mark`,mark:e[r],children:[]};(n.length>0?n[n.length-1].children:t).push(i),n.push(i)}(n.length>0?n[n.length-1].children:t).push({kind:`text`,text:r.text})}return t}function u(e,t={}){let n;return n=e?Array.isArray(e)?e:e.type===`doc`?e.content??[]:typeof e.type==`string`?[e]:[]:[],t.withKeys?d(n,{}):n}function d(e,t){return e.map(e=>{t[e.type]=(t[e.type]??0)+1;let n={...e,_key:`${e.type}-${t[e.type]}`};return Array.isArray(n.content)&&(n.content=d(n.content,t)),n})}var f=[1,2,3,4,5,6];function p(e){let t=e?.level;return`h${f.includes(t)?t:1}`}var m={doc:null,text:null,paragraph:{tag:`p`,content:!0},heading:{resolve:p,content:!0},bulletList:{tag:`ul`,content:!0},orderedList:{tag:`ol`,content:!0},listItem:{tag:`li`,content:!0},codeBlock:{tag:`pre`,children:[{tag:`code`,content:!0}]}},h={bold:{tag:`strong`,content:!0},italic:{tag:`em`,content:!0},strike:{tag:`s`,content:!0},underline:{tag:`u`,content:!0},code:{tag:`code`,content:!0},link:{tag:`a`,content:!0}};function g(e,t={}){return _(u(e),{renderers:t.renderers,warned:new Set})}function _(e,t){let n=``,r=0;for(;r<e.length;){let i=e[r];if(i.type===`text`&&!t.renderers?.text){let i=[];for(;r<e.length&&e[r].type===`text`;)i.push(e[r]),r++;n+=y(l(i),t)}else n+=v(i,t),r++}return n}function v(e,t){let r=t.renderers?.[e.type];if(r){let i={...t.renderers,[e.type]:void 0},a={renderers:i,warned:t.warned},o=e.type===`text`?n(e.text??``):_(e.content??[],a);return r({...e,children:o,context:{renderers:i}})}if(e.type===`text`)return y(l([e]),t);let i=m[e.type];if(i===void 0)return x(t,e.type),``;if(i===null)return _(e.content??[],t);let a=s(e.type,e.attrs),o=_(e.content??[],t);if(i.children){let e=o;for(let t=i.children.length-1;t>=0;t--){let n=i.children[t];e=b(n.tag,n.content?a:{},e)}return b(i.tag,{},e)}return b(i.resolve?i.resolve(e.attrs):i.tag,a,o)}function y(e,t){let r=``;for(let i of e){if(i.kind===`text`){r+=n(i.text);continue}let e=y(i.children,t),a=t.renderers?.[i.mark.type];if(a){r+=a({...i.mark,children:e,context:{renderers:t.renderers}});continue}let o=h[i.mark.type];if(!o){x(t,i.mark.type),r+=e;continue}r+=b(o.tag,s(i.mark.type,i.mark.attrs),e)}return r}function b(e,t,n){let i=`<${e}`;for(let[e,n]of Object.entries(t))i+=` ${e}="${r(String(n))}"`;return`${i}>${n}</${e}>`}function x(e,t){typeof process<`u`&&process.env&&process.env.NODE_ENV===`production`||e.warned.has(t)||(e.warned.add(t),console.warn(`[@localess/richtext] Unknown rich text element "${t}" was skipped. Provide a custom renderer to handle it.`))}exports.MARK_RENDER_MAP=h,exports.NODE_RENDER_MAP=m,exports.buildMarkTree=l,exports.escapeAttr=r,exports.escapeHtml=n,exports.marksEqual=c,exports.normalizeInput=u,exports.processAttrs=s,exports.renderRichTextToHtml=g,exports.resolveHeadingTag=p,exports.sanitizeUrl=o;
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ //#region src/escape.ts
3
+ var TEXT_ESCAPES = {
4
+ "&": "&amp;",
5
+ "<": "&lt;",
6
+ ">": "&gt;"
7
+ };
8
+ var ATTR_ESCAPES = {
9
+ ...TEXT_ESCAPES,
10
+ "\"": "&quot;"
11
+ };
12
+ /**
13
+ * Escapes text content for safe HTML output. The escape set (`& < >`) matches
14
+ * TipTap's `generateHTML` DOM serialization — parity-tested; do not widen it
15
+ * without updating the parity fixtures.
16
+ */
17
+ function escapeHtml(text) {
18
+ return text.replace(/[&<>]/g, (ch) => TEXT_ESCAPES[ch]);
19
+ }
20
+ /** Escapes an attribute value for safe double-quoted HTML output (`& " < >`). */
21
+ function escapeAttr(value) {
22
+ return value.replace(/[&"<>]/g, (ch) => ATTR_ESCAPES[ch]);
23
+ }
24
+ var SAFE_SCHEME = /^(?:https?:|mailto:|tel:)/i;
25
+ var HAS_SCHEME = /^[a-z][a-z0-9+.-]*:/i;
26
+ /**
27
+ * Allowlist URL sanitizer for link hrefs: `http:`, `https:`, `mailto:`, `tel:`
28
+ * and scheme-less (relative/protocol-relative/fragment/query) URLs pass;
29
+ * everything else (e.g. `javascript:`, `data:`) becomes `''`.
30
+ */
31
+ function sanitizeUrl(url) {
32
+ const trimmed = url.trim();
33
+ if (trimmed === "") return "";
34
+ if (SAFE_SCHEME.test(trimmed)) return trimmed;
35
+ if (!HAS_SCHEME.test(trimmed)) return trimmed;
36
+ return "";
37
+ }
38
+ //#endregion
39
+ //#region src/attrs.ts
40
+ /**
41
+ * Normalizes a node/mark's stored attrs into the attributes to emit, in the
42
+ * order TipTap's `generateHTML` emits them (parity-tested — adjust order here
43
+ * and in the fixtures together if the parity test disagrees).
44
+ */
45
+ function processAttrs(type, attrs, options = {}) {
46
+ const out = {};
47
+ const name = (key) => options.attrMap?.[key] ?? key;
48
+ const put = (key, value) => {
49
+ if (value === null || value === void 0 || value === "") return;
50
+ out[name(key)] = value;
51
+ };
52
+ if (!attrs) return out;
53
+ switch (type) {
54
+ case "orderedList":
55
+ if (attrs.start !== null && attrs.start !== void 0 && attrs.start !== 1) put("start", attrs.start);
56
+ break;
57
+ case "codeBlock":
58
+ if (attrs.language) put("class", `language-${attrs.language}`);
59
+ break;
60
+ case "link":
61
+ put("target", attrs.target);
62
+ put("rel", attrs.rel);
63
+ out[name("href")] = sanitizeUrl(String(attrs.href ?? ""));
64
+ put("class", attrs.class);
65
+ }
66
+ return out;
67
+ }
68
+ //#endregion
69
+ //#region src/marks.ts
70
+ /** Deep equality of two marks (type + attrs). Attr key order must match, which holds for editor-produced documents. */
71
+ function marksEqual(a, b) {
72
+ return a.type === b.type && JSON.stringify(a.attrs ?? {}) === JSON.stringify(b.attrs ?? {});
73
+ }
74
+ /**
75
+ * Folds a run of consecutive text nodes into a tree in which adjacent nodes
76
+ * sharing the same outer marks share one wrapper — the same merging
77
+ * ProseMirror's DOM serializer performs, so output matches TipTap's
78
+ * `generateHTML` (one `<a>` per link span, `<strong>a<em>b</em></strong>`
79
+ * instead of sibling `<strong>` wrappers).
80
+ */
81
+ function buildMarkTree(nodes) {
82
+ const root = [];
83
+ const stack = [];
84
+ for (const node of nodes) {
85
+ const marks = node.marks ?? [];
86
+ let depth = 0;
87
+ while (depth < stack.length && depth < marks.length && marksEqual(stack[depth].mark, marks[depth])) depth++;
88
+ stack.length = depth;
89
+ for (let i = depth; i < marks.length; i++) {
90
+ const segment = {
91
+ kind: "mark",
92
+ mark: marks[i],
93
+ children: []
94
+ };
95
+ (stack.length > 0 ? stack[stack.length - 1].children : root).push(segment);
96
+ stack.push(segment);
97
+ }
98
+ (stack.length > 0 ? stack[stack.length - 1].children : root).push({
99
+ kind: "text",
100
+ text: node.text
101
+ });
102
+ }
103
+ return root;
104
+ }
105
+ //#endregion
106
+ //#region src/normalize.ts
107
+ /**
108
+ * Flattens any accepted rich text input (document, node, node array, or the
109
+ * loose `ContentRichText` shape from `@localess/model`) into a node list.
110
+ * Never throws; malformed input yields `[]`.
111
+ */
112
+ function normalizeInput(input, options = {}) {
113
+ let nodes;
114
+ if (!input) nodes = [];
115
+ else if (Array.isArray(input)) nodes = input;
116
+ else if (input.type === "doc") nodes = input.content ?? [];
117
+ else if (typeof input.type === "string") nodes = [input];
118
+ else nodes = [];
119
+ return options.withKeys ? addKeys(nodes, {}) : nodes;
120
+ }
121
+ function addKeys(nodes, counters) {
122
+ return nodes.map((node) => {
123
+ counters[node.type] = (counters[node.type] ?? 0) + 1;
124
+ const keyed = {
125
+ ...node,
126
+ _key: `${node.type}-${counters[node.type]}`
127
+ };
128
+ if (Array.isArray(keyed.content)) keyed.content = addKeys(keyed.content, counters);
129
+ return keyed;
130
+ });
131
+ }
132
+ //#endregion
133
+ //#region src/render-map.ts
134
+ var HEADING_LEVELS = [
135
+ 1,
136
+ 2,
137
+ 3,
138
+ 4,
139
+ 5,
140
+ 6
141
+ ];
142
+ /** Invalid levels fall back to h1, matching TipTap's first-configured-level behavior. */
143
+ function resolveHeadingTag(attrs) {
144
+ const level = attrs?.level;
145
+ return `h${HEADING_LEVELS.includes(level) ? level : 1}`;
146
+ }
147
+ /** `null` = transparent (render children only, no element). Missing key = unknown type. */
148
+ var NODE_RENDER_MAP = {
149
+ doc: null,
150
+ text: null,
151
+ paragraph: {
152
+ tag: "p",
153
+ content: true
154
+ },
155
+ heading: {
156
+ resolve: resolveHeadingTag,
157
+ content: true
158
+ },
159
+ bulletList: {
160
+ tag: "ul",
161
+ content: true
162
+ },
163
+ orderedList: {
164
+ tag: "ol",
165
+ content: true
166
+ },
167
+ listItem: {
168
+ tag: "li",
169
+ content: true
170
+ },
171
+ codeBlock: {
172
+ tag: "pre",
173
+ children: [{
174
+ tag: "code",
175
+ content: true
176
+ }]
177
+ }
178
+ };
179
+ var MARK_RENDER_MAP = {
180
+ bold: {
181
+ tag: "strong",
182
+ content: true
183
+ },
184
+ italic: {
185
+ tag: "em",
186
+ content: true
187
+ },
188
+ strike: {
189
+ tag: "s",
190
+ content: true
191
+ },
192
+ underline: {
193
+ tag: "u",
194
+ content: true
195
+ },
196
+ code: {
197
+ tag: "code",
198
+ content: true
199
+ },
200
+ link: {
201
+ tag: "a",
202
+ content: true
203
+ }
204
+ };
205
+ //#endregion
206
+ //#region src/render-html.ts
207
+ /**
208
+ * Renders Localess rich text JSON to an HTML string. Framework-neutral,
209
+ * dependency-free, and byte-compatible with TipTap's `generateHTML` for the
210
+ * node set the Localess Studio editor produces.
211
+ */
212
+ function renderRichTextToHtml(input, options = {}) {
213
+ return renderNodes(normalizeInput(input), {
214
+ renderers: options.renderers,
215
+ warned: /* @__PURE__ */ new Set()
216
+ });
217
+ }
218
+ function renderNodes(nodes, ctx) {
219
+ let result = "";
220
+ let i = 0;
221
+ while (i < nodes.length) {
222
+ const node = nodes[i];
223
+ if (node.type === "text" && !ctx.renderers?.text) {
224
+ const run = [];
225
+ while (i < nodes.length && nodes[i].type === "text") {
226
+ run.push(nodes[i]);
227
+ i++;
228
+ }
229
+ result += renderSegments(buildMarkTree(run), ctx);
230
+ } else {
231
+ result += renderNode(node, ctx);
232
+ i++;
233
+ }
234
+ }
235
+ return result;
236
+ }
237
+ function renderNode(node, ctx) {
238
+ const custom = ctx.renderers?.[node.type];
239
+ if (custom) {
240
+ const childRenderers = {
241
+ ...ctx.renderers,
242
+ [node.type]: void 0
243
+ };
244
+ const childCtx = {
245
+ renderers: childRenderers,
246
+ warned: ctx.warned
247
+ };
248
+ const children = node.type === "text" ? escapeHtml(node.text ?? "") : renderNodes(node.content ?? [], childCtx);
249
+ return custom({
250
+ ...node,
251
+ children,
252
+ context: { renderers: childRenderers }
253
+ });
254
+ }
255
+ if (node.type === "text") return renderSegments(buildMarkTree([node]), ctx);
256
+ const spec = NODE_RENDER_MAP[node.type];
257
+ if (spec === void 0) {
258
+ warnUnknown(ctx, node.type);
259
+ return "";
260
+ }
261
+ if (spec === null) return renderNodes(node.content ?? [], ctx);
262
+ const attrs = processAttrs(node.type, node.attrs);
263
+ const children = renderNodes(node.content ?? [], ctx);
264
+ if (spec.children) {
265
+ let inner = children;
266
+ for (let i = spec.children.length - 1; i >= 0; i--) {
267
+ const child = spec.children[i];
268
+ inner = wrapTag(child.tag, child.content ? attrs : {}, inner);
269
+ }
270
+ return wrapTag(spec.tag, {}, inner);
271
+ }
272
+ return wrapTag(spec.resolve ? spec.resolve(node.attrs) : spec.tag, attrs, children);
273
+ }
274
+ function renderSegments(segments, ctx) {
275
+ let out = "";
276
+ for (const segment of segments) {
277
+ if (segment.kind === "text") {
278
+ out += escapeHtml(segment.text);
279
+ continue;
280
+ }
281
+ const children = renderSegments(segment.children, ctx);
282
+ const custom = ctx.renderers?.[segment.mark.type];
283
+ if (custom) {
284
+ out += custom({
285
+ ...segment.mark,
286
+ children,
287
+ context: { renderers: ctx.renderers }
288
+ });
289
+ continue;
290
+ }
291
+ const spec = MARK_RENDER_MAP[segment.mark.type];
292
+ if (!spec) {
293
+ warnUnknown(ctx, segment.mark.type);
294
+ out += children;
295
+ continue;
296
+ }
297
+ out += wrapTag(spec.tag, processAttrs(segment.mark.type, segment.mark.attrs), children);
298
+ }
299
+ return out;
300
+ }
301
+ function wrapTag(tag, attrs, children) {
302
+ let open = `<${tag}`;
303
+ for (const [name, value] of Object.entries(attrs)) open += ` ${name}="${escapeAttr(String(value))}"`;
304
+ return `${open}>${children}</${tag}>`;
305
+ }
306
+ function warnUnknown(ctx, type) {
307
+ if (typeof process !== "undefined" && process.env && process.env.NODE_ENV === "production") return;
308
+ if (ctx.warned.has(type)) return;
309
+ ctx.warned.add(type);
310
+ console.warn(`[@localess/richtext] Unknown rich text element "${type}" was skipped. Provide a custom renderer to handle it.`);
311
+ }
312
+ //#endregion
313
+ exports.MARK_RENDER_MAP = MARK_RENDER_MAP;
314
+ exports.NODE_RENDER_MAP = NODE_RENDER_MAP;
315
+ exports.buildMarkTree = buildMarkTree;
316
+ exports.escapeAttr = escapeAttr;
317
+ exports.escapeHtml = escapeHtml;
318
+ exports.marksEqual = marksEqual;
319
+ exports.normalizeInput = normalizeInput;
320
+ exports.processAttrs = processAttrs;
321
+ exports.renderRichTextToHtml = renderRichTextToHtml;
322
+ exports.resolveHeadingTag = resolveHeadingTag;
323
+ exports.sanitizeUrl = sanitizeUrl;