@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 +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/dist/index.mjs
CHANGED
|
@@ -1,86 +1,136 @@
|
|
|
1
1
|
//#region src/escape.ts
|
|
2
|
-
var
|
|
2
|
+
var TEXT_ESCAPES = {
|
|
3
3
|
"&": "&",
|
|
4
4
|
"<": "<",
|
|
5
5
|
">": ">"
|
|
6
|
-
}
|
|
7
|
-
|
|
6
|
+
};
|
|
7
|
+
var ATTR_ESCAPES = {
|
|
8
|
+
...TEXT_ESCAPES,
|
|
8
9
|
"\"": """
|
|
9
10
|
};
|
|
10
|
-
|
|
11
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Escapes text content for safe HTML output. The escape set (`& < >`) matches
|
|
13
|
+
* TipTap's `generateHTML` DOM serialization — parity-tested; do not widen it
|
|
14
|
+
* without updating the parity fixtures.
|
|
15
|
+
*/
|
|
16
|
+
function escapeHtml(text) {
|
|
17
|
+
return text.replace(/[&<>]/g, (ch) => TEXT_ESCAPES[ch]);
|
|
12
18
|
}
|
|
13
|
-
|
|
14
|
-
|
|
19
|
+
/** Escapes an attribute value for safe double-quoted HTML output (`& " < >`). */
|
|
20
|
+
function escapeAttr(value) {
|
|
21
|
+
return value.replace(/[&"<>]/g, (ch) => ATTR_ESCAPES[ch]);
|
|
15
22
|
}
|
|
16
|
-
var
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
23
|
+
var SAFE_SCHEME = /^(?:https?:|mailto:|tel:)/i;
|
|
24
|
+
var HAS_SCHEME = /^[a-z][a-z0-9+.-]*:/i;
|
|
25
|
+
/**
|
|
26
|
+
* Allowlist URL sanitizer for link hrefs: `http:`, `https:`, `mailto:`, `tel:`
|
|
27
|
+
* and scheme-less (relative/protocol-relative/fragment/query) URLs pass;
|
|
28
|
+
* everything else (e.g. `javascript:`, `data:`) becomes `''`.
|
|
29
|
+
*/
|
|
30
|
+
function sanitizeUrl(url) {
|
|
31
|
+
const trimmed = url.trim();
|
|
32
|
+
if (trimmed === "") return "";
|
|
33
|
+
if (SAFE_SCHEME.test(trimmed)) return trimmed;
|
|
34
|
+
if (!HAS_SCHEME.test(trimmed)) return trimmed;
|
|
35
|
+
return "";
|
|
20
36
|
}
|
|
21
37
|
//#endregion
|
|
22
38
|
//#region src/attrs.ts
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
39
|
+
/**
|
|
40
|
+
* Normalizes a node/mark's stored attrs into the attributes to emit, in the
|
|
41
|
+
* order TipTap's `generateHTML` emits them (parity-tested — adjust order here
|
|
42
|
+
* and in the fixtures together if the parity test disagrees).
|
|
43
|
+
*/
|
|
44
|
+
function processAttrs(type, attrs, options = {}) {
|
|
45
|
+
const out = {};
|
|
46
|
+
const name = (key) => options.attrMap?.[key] ?? key;
|
|
47
|
+
const put = (key, value) => {
|
|
48
|
+
if (value === null || value === void 0 || value === "") return;
|
|
49
|
+
out[name(key)] = value;
|
|
26
50
|
};
|
|
27
|
-
if (!
|
|
28
|
-
switch (
|
|
51
|
+
if (!attrs) return out;
|
|
52
|
+
switch (type) {
|
|
29
53
|
case "orderedList":
|
|
30
|
-
|
|
54
|
+
if (attrs.start !== null && attrs.start !== void 0 && attrs.start !== 1) put("start", attrs.start);
|
|
31
55
|
break;
|
|
32
56
|
case "codeBlock":
|
|
33
|
-
|
|
57
|
+
if (attrs.language) put("class", `language-${attrs.language}`);
|
|
34
58
|
break;
|
|
35
|
-
case "link":
|
|
59
|
+
case "link":
|
|
60
|
+
put("target", attrs.target);
|
|
61
|
+
put("rel", attrs.rel);
|
|
62
|
+
out[name("href")] = sanitizeUrl(String(attrs.href ?? ""));
|
|
63
|
+
put("class", attrs.class);
|
|
36
64
|
}
|
|
37
|
-
return
|
|
65
|
+
return out;
|
|
38
66
|
}
|
|
39
67
|
//#endregion
|
|
40
68
|
//#region src/marks.ts
|
|
41
|
-
|
|
42
|
-
|
|
69
|
+
/** Deep equality of two marks (type + attrs). Attr key order must match, which holds for editor-produced documents. */
|
|
70
|
+
function marksEqual(a, b) {
|
|
71
|
+
return a.type === b.type && JSON.stringify(a.attrs ?? {}) === JSON.stringify(b.attrs ?? {});
|
|
43
72
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
73
|
+
/**
|
|
74
|
+
* Folds a run of consecutive text nodes into a tree in which adjacent nodes
|
|
75
|
+
* sharing the same outer marks share one wrapper — the same merging
|
|
76
|
+
* ProseMirror's DOM serializer performs, so output matches TipTap's
|
|
77
|
+
* `generateHTML` (one `<a>` per link span, `<strong>a<em>b</em></strong>`
|
|
78
|
+
* instead of sibling `<strong>` wrappers).
|
|
79
|
+
*/
|
|
80
|
+
function buildMarkTree(nodes) {
|
|
81
|
+
const root = [];
|
|
82
|
+
const stack = [];
|
|
83
|
+
for (const node of nodes) {
|
|
84
|
+
const marks = node.marks ?? [];
|
|
85
|
+
let depth = 0;
|
|
86
|
+
while (depth < stack.length && depth < marks.length && marksEqual(stack[depth].mark, marks[depth])) depth++;
|
|
87
|
+
stack.length = depth;
|
|
88
|
+
for (let i = depth; i < marks.length; i++) {
|
|
89
|
+
const segment = {
|
|
52
90
|
kind: "mark",
|
|
53
|
-
mark:
|
|
91
|
+
mark: marks[i],
|
|
54
92
|
children: []
|
|
55
93
|
};
|
|
56
|
-
(
|
|
94
|
+
(stack.length > 0 ? stack[stack.length - 1].children : root).push(segment);
|
|
95
|
+
stack.push(segment);
|
|
57
96
|
}
|
|
58
|
-
(
|
|
97
|
+
(stack.length > 0 ? stack[stack.length - 1].children : root).push({
|
|
59
98
|
kind: "text",
|
|
60
|
-
text:
|
|
99
|
+
text: node.text
|
|
61
100
|
});
|
|
62
101
|
}
|
|
63
|
-
return
|
|
102
|
+
return root;
|
|
64
103
|
}
|
|
65
104
|
//#endregion
|
|
66
105
|
//#region src/normalize.ts
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
106
|
+
/**
|
|
107
|
+
* Flattens any accepted rich text input (document, node, node array, or the
|
|
108
|
+
* loose `ContentRichText` shape from `@localess/model`) into a node list.
|
|
109
|
+
* Never throws; malformed input yields `[]`.
|
|
110
|
+
*/
|
|
111
|
+
function normalizeInput(input, options = {}) {
|
|
112
|
+
let nodes;
|
|
113
|
+
if (!input) nodes = [];
|
|
114
|
+
else if (Array.isArray(input)) nodes = input;
|
|
115
|
+
else if (input.type === "doc") nodes = input.content ?? [];
|
|
116
|
+
else if (typeof input.type === "string") nodes = [input];
|
|
117
|
+
else nodes = [];
|
|
118
|
+
return options.withKeys ? addKeys(nodes, {}) : nodes;
|
|
70
119
|
}
|
|
71
|
-
function
|
|
72
|
-
return
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
...
|
|
76
|
-
_key: `${
|
|
120
|
+
function addKeys(nodes, counters) {
|
|
121
|
+
return nodes.map((node) => {
|
|
122
|
+
counters[node.type] = (counters[node.type] ?? 0) + 1;
|
|
123
|
+
const keyed = {
|
|
124
|
+
...node,
|
|
125
|
+
_key: `${node.type}-${counters[node.type]}`
|
|
77
126
|
};
|
|
78
|
-
|
|
127
|
+
if (Array.isArray(keyed.content)) keyed.content = addKeys(keyed.content, counters);
|
|
128
|
+
return keyed;
|
|
79
129
|
});
|
|
80
130
|
}
|
|
81
131
|
//#endregion
|
|
82
132
|
//#region src/render-map.ts
|
|
83
|
-
var
|
|
133
|
+
var HEADING_LEVELS = [
|
|
84
134
|
1,
|
|
85
135
|
2,
|
|
86
136
|
3,
|
|
@@ -88,149 +138,175 @@ var f = [
|
|
|
88
138
|
5,
|
|
89
139
|
6
|
|
90
140
|
];
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
141
|
+
/** Invalid levels fall back to h1, matching TipTap's first-configured-level behavior. */
|
|
142
|
+
function resolveHeadingTag(attrs) {
|
|
143
|
+
const level = attrs?.level;
|
|
144
|
+
return `h${HEADING_LEVELS.includes(level) ? level : 1}`;
|
|
94
145
|
}
|
|
95
|
-
|
|
146
|
+
/** `null` = transparent (render children only, no element). Missing key = unknown type. */
|
|
147
|
+
var NODE_RENDER_MAP = {
|
|
96
148
|
doc: null,
|
|
97
149
|
text: null,
|
|
98
150
|
paragraph: {
|
|
99
151
|
tag: "p",
|
|
100
|
-
content:
|
|
152
|
+
content: true
|
|
101
153
|
},
|
|
102
154
|
heading: {
|
|
103
|
-
resolve:
|
|
104
|
-
content:
|
|
155
|
+
resolve: resolveHeadingTag,
|
|
156
|
+
content: true
|
|
105
157
|
},
|
|
106
158
|
bulletList: {
|
|
107
159
|
tag: "ul",
|
|
108
|
-
content:
|
|
160
|
+
content: true
|
|
109
161
|
},
|
|
110
162
|
orderedList: {
|
|
111
163
|
tag: "ol",
|
|
112
|
-
content:
|
|
164
|
+
content: true
|
|
113
165
|
},
|
|
114
166
|
listItem: {
|
|
115
167
|
tag: "li",
|
|
116
|
-
content:
|
|
168
|
+
content: true
|
|
117
169
|
},
|
|
118
170
|
codeBlock: {
|
|
119
171
|
tag: "pre",
|
|
120
172
|
children: [{
|
|
121
173
|
tag: "code",
|
|
122
|
-
content:
|
|
174
|
+
content: true
|
|
123
175
|
}]
|
|
124
176
|
}
|
|
125
|
-
}
|
|
177
|
+
};
|
|
178
|
+
var MARK_RENDER_MAP = {
|
|
126
179
|
bold: {
|
|
127
180
|
tag: "strong",
|
|
128
|
-
content:
|
|
181
|
+
content: true
|
|
129
182
|
},
|
|
130
183
|
italic: {
|
|
131
184
|
tag: "em",
|
|
132
|
-
content:
|
|
185
|
+
content: true
|
|
133
186
|
},
|
|
134
187
|
strike: {
|
|
135
188
|
tag: "s",
|
|
136
|
-
content:
|
|
189
|
+
content: true
|
|
137
190
|
},
|
|
138
191
|
underline: {
|
|
139
192
|
tag: "u",
|
|
140
|
-
content:
|
|
193
|
+
content: true
|
|
141
194
|
},
|
|
142
195
|
code: {
|
|
143
196
|
tag: "code",
|
|
144
|
-
content:
|
|
197
|
+
content: true
|
|
145
198
|
},
|
|
146
199
|
link: {
|
|
147
200
|
tag: "a",
|
|
148
|
-
content:
|
|
201
|
+
content: true
|
|
149
202
|
}
|
|
150
203
|
};
|
|
151
204
|
//#endregion
|
|
152
205
|
//#region src/render-html.ts
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
206
|
+
/**
|
|
207
|
+
* Renders Localess rich text JSON to an HTML string. Framework-neutral,
|
|
208
|
+
* dependency-free, and byte-compatible with TipTap's `generateHTML` for the
|
|
209
|
+
* node set the Localess Studio editor produces.
|
|
210
|
+
*/
|
|
211
|
+
function renderRichTextToHtml(input, options = {}) {
|
|
212
|
+
return renderNodes(normalizeInput(input), {
|
|
213
|
+
renderers: options.renderers,
|
|
156
214
|
warned: /* @__PURE__ */ new Set()
|
|
157
215
|
});
|
|
158
216
|
}
|
|
159
|
-
function
|
|
160
|
-
let
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
217
|
+
function renderNodes(nodes, ctx) {
|
|
218
|
+
let result = "";
|
|
219
|
+
let i = 0;
|
|
220
|
+
while (i < nodes.length) {
|
|
221
|
+
const node = nodes[i];
|
|
222
|
+
if (node.type === "text" && !ctx.renderers?.text) {
|
|
223
|
+
const run = [];
|
|
224
|
+
while (i < nodes.length && nodes[i].type === "text") {
|
|
225
|
+
run.push(nodes[i]);
|
|
226
|
+
i++;
|
|
227
|
+
}
|
|
228
|
+
result += renderSegments(buildMarkTree(run), ctx);
|
|
229
|
+
} else {
|
|
230
|
+
result += renderNode(node, ctx);
|
|
231
|
+
i++;
|
|
232
|
+
}
|
|
168
233
|
}
|
|
169
|
-
return
|
|
234
|
+
return result;
|
|
170
235
|
}
|
|
171
|
-
function
|
|
172
|
-
|
|
173
|
-
if (
|
|
174
|
-
|
|
175
|
-
...
|
|
176
|
-
[
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
236
|
+
function renderNode(node, ctx) {
|
|
237
|
+
const custom = ctx.renderers?.[node.type];
|
|
238
|
+
if (custom) {
|
|
239
|
+
const childRenderers = {
|
|
240
|
+
...ctx.renderers,
|
|
241
|
+
[node.type]: void 0
|
|
242
|
+
};
|
|
243
|
+
const childCtx = {
|
|
244
|
+
renderers: childRenderers,
|
|
245
|
+
warned: ctx.warned
|
|
246
|
+
};
|
|
247
|
+
const children = node.type === "text" ? escapeHtml(node.text ?? "") : renderNodes(node.content ?? [], childCtx);
|
|
248
|
+
return custom({
|
|
249
|
+
...node,
|
|
250
|
+
children,
|
|
251
|
+
context: { renderers: childRenderers }
|
|
185
252
|
});
|
|
186
253
|
}
|
|
187
|
-
if (
|
|
188
|
-
|
|
189
|
-
if (
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
254
|
+
if (node.type === "text") return renderSegments(buildMarkTree([node]), ctx);
|
|
255
|
+
const spec = NODE_RENDER_MAP[node.type];
|
|
256
|
+
if (spec === void 0) {
|
|
257
|
+
warnUnknown(ctx, node.type);
|
|
258
|
+
return "";
|
|
259
|
+
}
|
|
260
|
+
if (spec === null) return renderNodes(node.content ?? [], ctx);
|
|
261
|
+
const attrs = processAttrs(node.type, node.attrs);
|
|
262
|
+
const children = renderNodes(node.content ?? [], ctx);
|
|
263
|
+
if (spec.children) {
|
|
264
|
+
let inner = children;
|
|
265
|
+
for (let i = spec.children.length - 1; i >= 0; i--) {
|
|
266
|
+
const child = spec.children[i];
|
|
267
|
+
inner = wrapTag(child.tag, child.content ? attrs : {}, inner);
|
|
197
268
|
}
|
|
198
|
-
return
|
|
269
|
+
return wrapTag(spec.tag, {}, inner);
|
|
199
270
|
}
|
|
200
|
-
return
|
|
271
|
+
return wrapTag(spec.resolve ? spec.resolve(node.attrs) : spec.tag, attrs, children);
|
|
201
272
|
}
|
|
202
|
-
function
|
|
203
|
-
let
|
|
204
|
-
for (
|
|
205
|
-
if (
|
|
206
|
-
|
|
273
|
+
function renderSegments(segments, ctx) {
|
|
274
|
+
let out = "";
|
|
275
|
+
for (const segment of segments) {
|
|
276
|
+
if (segment.kind === "text") {
|
|
277
|
+
out += escapeHtml(segment.text);
|
|
207
278
|
continue;
|
|
208
279
|
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
280
|
+
const children = renderSegments(segment.children, ctx);
|
|
281
|
+
const custom = ctx.renderers?.[segment.mark.type];
|
|
282
|
+
if (custom) {
|
|
283
|
+
out += custom({
|
|
284
|
+
...segment.mark,
|
|
285
|
+
children,
|
|
286
|
+
context: { renderers: ctx.renderers }
|
|
215
287
|
});
|
|
216
288
|
continue;
|
|
217
289
|
}
|
|
218
|
-
|
|
219
|
-
if (!
|
|
220
|
-
|
|
290
|
+
const spec = MARK_RENDER_MAP[segment.mark.type];
|
|
291
|
+
if (!spec) {
|
|
292
|
+
warnUnknown(ctx, segment.mark.type);
|
|
293
|
+
out += children;
|
|
221
294
|
continue;
|
|
222
295
|
}
|
|
223
|
-
|
|
296
|
+
out += wrapTag(spec.tag, processAttrs(segment.mark.type, segment.mark.attrs), children);
|
|
224
297
|
}
|
|
225
|
-
return
|
|
298
|
+
return out;
|
|
226
299
|
}
|
|
227
|
-
function
|
|
228
|
-
let
|
|
229
|
-
for (
|
|
230
|
-
return `${
|
|
300
|
+
function wrapTag(tag, attrs, children) {
|
|
301
|
+
let open = `<${tag}`;
|
|
302
|
+
for (const [name, value] of Object.entries(attrs)) open += ` ${name}="${escapeAttr(String(value))}"`;
|
|
303
|
+
return `${open}>${children}</${tag}>`;
|
|
231
304
|
}
|
|
232
|
-
function
|
|
233
|
-
typeof process
|
|
305
|
+
function warnUnknown(ctx, type) {
|
|
306
|
+
if (typeof process !== "undefined" && process.env && process.env.NODE_ENV === "production") return;
|
|
307
|
+
if (ctx.warned.has(type)) return;
|
|
308
|
+
ctx.warned.add(type);
|
|
309
|
+
console.warn(`[@localess/richtext] Unknown rich text element "${type}" was skipped. Provide a custom renderer to handle it.`);
|
|
234
310
|
}
|
|
235
311
|
//#endregion
|
|
236
|
-
export {
|
|
312
|
+
export { MARK_RENDER_MAP, NODE_RENDER_MAP, buildMarkTree, escapeAttr, escapeHtml, marksEqual, normalizeInput, processAttrs, renderRichTextToHtml, resolveHeadingTag, sanitizeUrl };
|
package/dist/model.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ContentRichText } from '@localess/model';
|
|
1
2
|
/**
|
|
2
3
|
* Attributes of a `link` mark as stored by the Localess Studio editor
|
|
3
4
|
* (TipTap Link extension JSON).
|
|
@@ -71,16 +72,12 @@ export interface LocalessRichTextDocument {
|
|
|
71
72
|
content?: LocalessRichTextNode[];
|
|
72
73
|
}
|
|
73
74
|
/**
|
|
74
|
-
*
|
|
75
|
-
* values
|
|
76
|
-
* zero dependencies.
|
|
75
|
+
* Re-exported from `@localess/model` so `LocalessRichTextInput` accepts
|
|
76
|
+
* `ContentRichText` values without casting.
|
|
77
77
|
*/
|
|
78
|
-
export
|
|
79
|
-
type?: string;
|
|
80
|
-
content?: ContentRichTextLike[];
|
|
81
|
-
}
|
|
78
|
+
export type { ContentRichText };
|
|
82
79
|
/** Anything a render function accepts. */
|
|
83
|
-
export type LocalessRichTextInput = LocalessRichTextDocument | LocalessRichTextNode | LocalessRichTextNode[] |
|
|
80
|
+
export type LocalessRichTextInput = LocalessRichTextDocument | LocalessRichTextNode | LocalessRichTextNode[] | ContentRichText | null | undefined;
|
|
84
81
|
/** Union of every known node and mark type name. */
|
|
85
82
|
export type LocalessRichTextElement = LocalessRichTextNode['type'] | LocalessRichTextMark['type'] | 'doc';
|
|
86
83
|
/**
|
package/dist/normalize.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ export interface NormalizeInputOptions {
|
|
|
5
5
|
}
|
|
6
6
|
/**
|
|
7
7
|
* Flattens any accepted rich text input (document, node, node array, or the
|
|
8
|
-
* loose `ContentRichText` shape from `@localess/
|
|
8
|
+
* loose `ContentRichText` shape from `@localess/model`) into a node list.
|
|
9
9
|
* Never throws; malformed input yields `[]`.
|
|
10
10
|
*/
|
|
11
11
|
export declare function normalizeInput(input: LocalessRichTextInput, options?: NormalizeInputOptions): LocalessRichTextNodeWithKey[];
|