@localess/richtext 3.4.1 → 4.0.0-dev.20260905083404
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 +124 -75
- package/dist/index.js +323 -1
- package/dist/index.mjs +202 -126
- package/dist/model.d.ts +5 -8
- package/dist/normalize.d.ts +1 -1
- package/dist/test-utils/index.js +204 -1
- package/dist/test-utils/index.mjs +74 -63
- package/package.json +78 -75
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
|
|
10
|
-
dependencies
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
`
|
|
75
|
-
`
|
|
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,
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
//#region src/escape.ts
|
|
3
|
+
var TEXT_ESCAPES = {
|
|
4
|
+
"&": "&",
|
|
5
|
+
"<": "<",
|
|
6
|
+
">": ">"
|
|
7
|
+
};
|
|
8
|
+
var ATTR_ESCAPES = {
|
|
9
|
+
...TEXT_ESCAPES,
|
|
10
|
+
"\"": """
|
|
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;
|