@localess/richtext 3.4.1-dev.20260831165456 → 3.4.1-dev.20260901200805
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/dist/index.js +323 -1
- package/dist/index.mjs +202 -126
- package/dist/test-utils/index.js +204 -1
- package/dist/test-utils/index.mjs +74 -63
- package/package.json +1 -1
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/client`) 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;
|
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/client`) 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/test-utils/index.js
CHANGED
|
@@ -1 +1,204 @@
|
|
|
1
|
-
Object.defineProperty(exports,Symbol.toStringTag,
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
//#region src/test-utils/fixtures.ts
|
|
3
|
+
var doc = (...content) => ({
|
|
4
|
+
type: "doc",
|
|
5
|
+
content
|
|
6
|
+
});
|
|
7
|
+
var p = (...content) => ({
|
|
8
|
+
type: "paragraph",
|
|
9
|
+
content
|
|
10
|
+
});
|
|
11
|
+
var t = (text, marks) => ({
|
|
12
|
+
type: "text",
|
|
13
|
+
text,
|
|
14
|
+
...marks ? { marks } : {}
|
|
15
|
+
});
|
|
16
|
+
var li = (...content) => ({
|
|
17
|
+
type: "listItem",
|
|
18
|
+
content
|
|
19
|
+
});
|
|
20
|
+
var link = {
|
|
21
|
+
type: "link",
|
|
22
|
+
attrs: {
|
|
23
|
+
href: "https://example.com",
|
|
24
|
+
target: "_blank",
|
|
25
|
+
rel: "noopener noreferrer nofollow",
|
|
26
|
+
class: null
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Shared correctness corpus. Every renderer in every framework package must
|
|
31
|
+
* produce exactly these strings (DOM-roundtrip-normalized where the framework
|
|
32
|
+
* renders through a real DOM). `parity: true` fixtures are additionally
|
|
33
|
+
* asserted byte-identical to TipTap's `generateHTML`.
|
|
34
|
+
*/
|
|
35
|
+
var richTextFixtures = [
|
|
36
|
+
{
|
|
37
|
+
title: "empty doc",
|
|
38
|
+
input: doc(),
|
|
39
|
+
expected: "",
|
|
40
|
+
parity: true
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
title: "null input",
|
|
44
|
+
input: null,
|
|
45
|
+
expected: "",
|
|
46
|
+
parity: false
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
title: "plain paragraph",
|
|
50
|
+
input: doc(p(t("Hello world"))),
|
|
51
|
+
expected: "<p>Hello world</p>",
|
|
52
|
+
parity: true
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
title: "two paragraphs",
|
|
56
|
+
input: doc(p(t("One")), p(t("Two"))),
|
|
57
|
+
expected: "<p>One</p><p>Two</p>",
|
|
58
|
+
parity: true
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
title: "all six heading levels",
|
|
62
|
+
input: doc(...[
|
|
63
|
+
1,
|
|
64
|
+
2,
|
|
65
|
+
3,
|
|
66
|
+
4,
|
|
67
|
+
5,
|
|
68
|
+
6
|
|
69
|
+
].map((level) => ({
|
|
70
|
+
type: "heading",
|
|
71
|
+
attrs: { level },
|
|
72
|
+
content: [t(`H${level}`)]
|
|
73
|
+
}))),
|
|
74
|
+
expected: "<h1>H1</h1><h2>H2</h2><h3>H3</h3><h4>H4</h4><h5>H5</h5><h6>H6</h6>",
|
|
75
|
+
parity: true
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
title: "invalid heading level falls back to h1",
|
|
79
|
+
input: doc({
|
|
80
|
+
type: "heading",
|
|
81
|
+
attrs: { level: 9 },
|
|
82
|
+
content: [t("Big")]
|
|
83
|
+
}),
|
|
84
|
+
expected: "<h1>Big</h1>",
|
|
85
|
+
parity: true
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
title: "every simple mark",
|
|
89
|
+
input: doc(p(t("b", [{ type: "bold" }]), t("i", [{ type: "italic" }]), t("s", [{ type: "strike" }]), t("u", [{ type: "underline" }]), t("c", [{ type: "code" }]))),
|
|
90
|
+
expected: "<p><strong>b</strong><em>i</em><s>s</s><u>u</u><code>c</code></p>",
|
|
91
|
+
parity: true
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
title: "nested marks fold with marks[0] outermost",
|
|
95
|
+
input: doc(p(t("x", [{ type: "bold" }, { type: "italic" }]))),
|
|
96
|
+
expected: "<p><strong><em>x</em></strong></p>",
|
|
97
|
+
parity: true
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
title: "adjacent nodes sharing an outer mark merge into one wrapper",
|
|
101
|
+
input: doc(p(t("a", [{ type: "bold" }]), t("b", [{ type: "bold" }, { type: "italic" }]))),
|
|
102
|
+
expected: "<p><strong>a<em>b</em></strong></p>",
|
|
103
|
+
parity: true
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
title: "bullet list with paragraphs in items",
|
|
107
|
+
input: doc({
|
|
108
|
+
type: "bulletList",
|
|
109
|
+
content: [li(p(t("One"))), li(p(t("Two")))]
|
|
110
|
+
}),
|
|
111
|
+
expected: "<ul><li><p>One</p></li><li><p>Two</p></li></ul>",
|
|
112
|
+
parity: true
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
title: "ordered list omits start=1",
|
|
116
|
+
input: doc({
|
|
117
|
+
type: "orderedList",
|
|
118
|
+
attrs: { start: 1 },
|
|
119
|
+
content: [li(p(t("One")))]
|
|
120
|
+
}),
|
|
121
|
+
expected: "<ol><li><p>One</p></li></ol>",
|
|
122
|
+
parity: true
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
title: "ordered list emits start=3",
|
|
126
|
+
input: doc({
|
|
127
|
+
type: "orderedList",
|
|
128
|
+
attrs: { start: 3 },
|
|
129
|
+
content: [li(p(t("Three")))]
|
|
130
|
+
}),
|
|
131
|
+
expected: "<ol start=\"3\"><li><p>Three</p></li></ol>",
|
|
132
|
+
parity: true
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
title: "nested bullet list inside a list item",
|
|
136
|
+
input: doc({
|
|
137
|
+
type: "bulletList",
|
|
138
|
+
content: [li(p(t("Outer")), {
|
|
139
|
+
type: "bulletList",
|
|
140
|
+
content: [li(p(t("Inner")))]
|
|
141
|
+
})]
|
|
142
|
+
}),
|
|
143
|
+
expected: "<ul><li><p>Outer</p><ul><li><p>Inner</p></li></ul></li></ul>",
|
|
144
|
+
parity: true
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
title: "code block without language",
|
|
148
|
+
input: doc({
|
|
149
|
+
type: "codeBlock",
|
|
150
|
+
attrs: { language: null },
|
|
151
|
+
content: [t("const x = 1;")]
|
|
152
|
+
}),
|
|
153
|
+
expected: "<pre><code>const x = 1;</code></pre>",
|
|
154
|
+
parity: true
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
title: "code block with language class",
|
|
158
|
+
input: doc({
|
|
159
|
+
type: "codeBlock",
|
|
160
|
+
attrs: { language: "js" },
|
|
161
|
+
content: [t("const x = 1;")]
|
|
162
|
+
}),
|
|
163
|
+
expected: "<pre><code class=\"language-js\">const x = 1;</code></pre>",
|
|
164
|
+
parity: true
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
title: "link with editor-default attrs",
|
|
168
|
+
input: doc(p(t("Visit", [link]))),
|
|
169
|
+
expected: "<p><a target=\"_blank\" rel=\"noopener noreferrer nofollow\" href=\"https://example.com\">Visit</a></p>",
|
|
170
|
+
parity: true
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
title: "link spanning differently-marked text renders one anchor",
|
|
174
|
+
input: doc(p(t("go ", [link]), t("bold", [link, { type: "bold" }]), t(" now", [link]))),
|
|
175
|
+
expected: "<p><a target=\"_blank\" rel=\"noopener noreferrer nofollow\" href=\"https://example.com\">go <strong>bold</strong> now</a></p>",
|
|
176
|
+
parity: true
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
title: "text escaping of angle brackets and ampersands",
|
|
180
|
+
input: doc(p(t("a < b & c > d"))),
|
|
181
|
+
expected: "<p>a < b & c > d</p>",
|
|
182
|
+
parity: true
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
title: "javascript: href is sanitized to empty (intentionally stricter than TipTap)",
|
|
186
|
+
input: doc(p(t("x", [{
|
|
187
|
+
type: "link",
|
|
188
|
+
attrs: { href: "javascript:alert(1)" }
|
|
189
|
+
}]))),
|
|
190
|
+
expected: "<p><a href=\"\">x</a></p>",
|
|
191
|
+
parity: false
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
title: "unknown node types are skipped",
|
|
195
|
+
input: doc({
|
|
196
|
+
type: "schema",
|
|
197
|
+
attrs: { data: {} }
|
|
198
|
+
}, p(t("kept"))),
|
|
199
|
+
expected: "<p>kept</p>",
|
|
200
|
+
parity: false
|
|
201
|
+
}
|
|
202
|
+
];
|
|
203
|
+
//#endregion
|
|
204
|
+
exports.richTextFixtures = richTextFixtures;
|
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
//#region src/test-utils/fixtures.ts
|
|
2
|
-
var
|
|
2
|
+
var doc = (...content) => ({
|
|
3
3
|
type: "doc",
|
|
4
|
-
content
|
|
5
|
-
})
|
|
4
|
+
content
|
|
5
|
+
});
|
|
6
|
+
var p = (...content) => ({
|
|
6
7
|
type: "paragraph",
|
|
7
|
-
content
|
|
8
|
-
})
|
|
8
|
+
content
|
|
9
|
+
});
|
|
10
|
+
var t = (text, marks) => ({
|
|
9
11
|
type: "text",
|
|
10
|
-
text
|
|
11
|
-
...
|
|
12
|
-
})
|
|
12
|
+
text,
|
|
13
|
+
...marks ? { marks } : {}
|
|
14
|
+
});
|
|
15
|
+
var li = (...content) => ({
|
|
13
16
|
type: "listItem",
|
|
14
|
-
content
|
|
15
|
-
})
|
|
17
|
+
content
|
|
18
|
+
});
|
|
19
|
+
var link = {
|
|
16
20
|
type: "link",
|
|
17
21
|
attrs: {
|
|
18
22
|
href: "https://example.com",
|
|
@@ -20,173 +24,180 @@ var e = (...e) => ({
|
|
|
20
24
|
rel: "noopener noreferrer nofollow",
|
|
21
25
|
class: null
|
|
22
26
|
}
|
|
23
|
-
}
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Shared correctness corpus. Every renderer in every framework package must
|
|
30
|
+
* produce exactly these strings (DOM-roundtrip-normalized where the framework
|
|
31
|
+
* renders through a real DOM). `parity: true` fixtures are additionally
|
|
32
|
+
* asserted byte-identical to TipTap's `generateHTML`.
|
|
33
|
+
*/
|
|
34
|
+
var richTextFixtures = [
|
|
24
35
|
{
|
|
25
36
|
title: "empty doc",
|
|
26
|
-
input:
|
|
37
|
+
input: doc(),
|
|
27
38
|
expected: "",
|
|
28
|
-
parity:
|
|
39
|
+
parity: true
|
|
29
40
|
},
|
|
30
41
|
{
|
|
31
42
|
title: "null input",
|
|
32
43
|
input: null,
|
|
33
44
|
expected: "",
|
|
34
|
-
parity:
|
|
45
|
+
parity: false
|
|
35
46
|
},
|
|
36
47
|
{
|
|
37
48
|
title: "plain paragraph",
|
|
38
|
-
input:
|
|
49
|
+
input: doc(p(t("Hello world"))),
|
|
39
50
|
expected: "<p>Hello world</p>",
|
|
40
|
-
parity:
|
|
51
|
+
parity: true
|
|
41
52
|
},
|
|
42
53
|
{
|
|
43
54
|
title: "two paragraphs",
|
|
44
|
-
input:
|
|
55
|
+
input: doc(p(t("One")), p(t("Two"))),
|
|
45
56
|
expected: "<p>One</p><p>Two</p>",
|
|
46
|
-
parity:
|
|
57
|
+
parity: true
|
|
47
58
|
},
|
|
48
59
|
{
|
|
49
60
|
title: "all six heading levels",
|
|
50
|
-
input:
|
|
61
|
+
input: doc(...[
|
|
51
62
|
1,
|
|
52
63
|
2,
|
|
53
64
|
3,
|
|
54
65
|
4,
|
|
55
66
|
5,
|
|
56
67
|
6
|
|
57
|
-
].map((
|
|
68
|
+
].map((level) => ({
|
|
58
69
|
type: "heading",
|
|
59
|
-
attrs: { level
|
|
60
|
-
content: [
|
|
70
|
+
attrs: { level },
|
|
71
|
+
content: [t(`H${level}`)]
|
|
61
72
|
}))),
|
|
62
73
|
expected: "<h1>H1</h1><h2>H2</h2><h3>H3</h3><h4>H4</h4><h5>H5</h5><h6>H6</h6>",
|
|
63
|
-
parity:
|
|
74
|
+
parity: true
|
|
64
75
|
},
|
|
65
76
|
{
|
|
66
77
|
title: "invalid heading level falls back to h1",
|
|
67
|
-
input:
|
|
78
|
+
input: doc({
|
|
68
79
|
type: "heading",
|
|
69
80
|
attrs: { level: 9 },
|
|
70
|
-
content: [
|
|
81
|
+
content: [t("Big")]
|
|
71
82
|
}),
|
|
72
83
|
expected: "<h1>Big</h1>",
|
|
73
|
-
parity:
|
|
84
|
+
parity: true
|
|
74
85
|
},
|
|
75
86
|
{
|
|
76
87
|
title: "every simple mark",
|
|
77
|
-
input:
|
|
88
|
+
input: doc(p(t("b", [{ type: "bold" }]), t("i", [{ type: "italic" }]), t("s", [{ type: "strike" }]), t("u", [{ type: "underline" }]), t("c", [{ type: "code" }]))),
|
|
78
89
|
expected: "<p><strong>b</strong><em>i</em><s>s</s><u>u</u><code>c</code></p>",
|
|
79
|
-
parity:
|
|
90
|
+
parity: true
|
|
80
91
|
},
|
|
81
92
|
{
|
|
82
93
|
title: "nested marks fold with marks[0] outermost",
|
|
83
|
-
input:
|
|
94
|
+
input: doc(p(t("x", [{ type: "bold" }, { type: "italic" }]))),
|
|
84
95
|
expected: "<p><strong><em>x</em></strong></p>",
|
|
85
|
-
parity:
|
|
96
|
+
parity: true
|
|
86
97
|
},
|
|
87
98
|
{
|
|
88
99
|
title: "adjacent nodes sharing an outer mark merge into one wrapper",
|
|
89
|
-
input:
|
|
100
|
+
input: doc(p(t("a", [{ type: "bold" }]), t("b", [{ type: "bold" }, { type: "italic" }]))),
|
|
90
101
|
expected: "<p><strong>a<em>b</em></strong></p>",
|
|
91
|
-
parity:
|
|
102
|
+
parity: true
|
|
92
103
|
},
|
|
93
104
|
{
|
|
94
105
|
title: "bullet list with paragraphs in items",
|
|
95
|
-
input:
|
|
106
|
+
input: doc({
|
|
96
107
|
type: "bulletList",
|
|
97
|
-
content: [
|
|
108
|
+
content: [li(p(t("One"))), li(p(t("Two")))]
|
|
98
109
|
}),
|
|
99
110
|
expected: "<ul><li><p>One</p></li><li><p>Two</p></li></ul>",
|
|
100
|
-
parity:
|
|
111
|
+
parity: true
|
|
101
112
|
},
|
|
102
113
|
{
|
|
103
114
|
title: "ordered list omits start=1",
|
|
104
|
-
input:
|
|
115
|
+
input: doc({
|
|
105
116
|
type: "orderedList",
|
|
106
117
|
attrs: { start: 1 },
|
|
107
|
-
content: [
|
|
118
|
+
content: [li(p(t("One")))]
|
|
108
119
|
}),
|
|
109
120
|
expected: "<ol><li><p>One</p></li></ol>",
|
|
110
|
-
parity:
|
|
121
|
+
parity: true
|
|
111
122
|
},
|
|
112
123
|
{
|
|
113
124
|
title: "ordered list emits start=3",
|
|
114
|
-
input:
|
|
125
|
+
input: doc({
|
|
115
126
|
type: "orderedList",
|
|
116
127
|
attrs: { start: 3 },
|
|
117
|
-
content: [
|
|
128
|
+
content: [li(p(t("Three")))]
|
|
118
129
|
}),
|
|
119
130
|
expected: "<ol start=\"3\"><li><p>Three</p></li></ol>",
|
|
120
|
-
parity:
|
|
131
|
+
parity: true
|
|
121
132
|
},
|
|
122
133
|
{
|
|
123
134
|
title: "nested bullet list inside a list item",
|
|
124
|
-
input:
|
|
135
|
+
input: doc({
|
|
125
136
|
type: "bulletList",
|
|
126
|
-
content: [
|
|
137
|
+
content: [li(p(t("Outer")), {
|
|
127
138
|
type: "bulletList",
|
|
128
|
-
content: [
|
|
139
|
+
content: [li(p(t("Inner")))]
|
|
129
140
|
})]
|
|
130
141
|
}),
|
|
131
142
|
expected: "<ul><li><p>Outer</p><ul><li><p>Inner</p></li></ul></li></ul>",
|
|
132
|
-
parity:
|
|
143
|
+
parity: true
|
|
133
144
|
},
|
|
134
145
|
{
|
|
135
146
|
title: "code block without language",
|
|
136
|
-
input:
|
|
147
|
+
input: doc({
|
|
137
148
|
type: "codeBlock",
|
|
138
149
|
attrs: { language: null },
|
|
139
|
-
content: [
|
|
150
|
+
content: [t("const x = 1;")]
|
|
140
151
|
}),
|
|
141
152
|
expected: "<pre><code>const x = 1;</code></pre>",
|
|
142
|
-
parity:
|
|
153
|
+
parity: true
|
|
143
154
|
},
|
|
144
155
|
{
|
|
145
156
|
title: "code block with language class",
|
|
146
|
-
input:
|
|
157
|
+
input: doc({
|
|
147
158
|
type: "codeBlock",
|
|
148
159
|
attrs: { language: "js" },
|
|
149
|
-
content: [
|
|
160
|
+
content: [t("const x = 1;")]
|
|
150
161
|
}),
|
|
151
162
|
expected: "<pre><code class=\"language-js\">const x = 1;</code></pre>",
|
|
152
|
-
parity:
|
|
163
|
+
parity: true
|
|
153
164
|
},
|
|
154
165
|
{
|
|
155
166
|
title: "link with editor-default attrs",
|
|
156
|
-
input:
|
|
167
|
+
input: doc(p(t("Visit", [link]))),
|
|
157
168
|
expected: "<p><a target=\"_blank\" rel=\"noopener noreferrer nofollow\" href=\"https://example.com\">Visit</a></p>",
|
|
158
|
-
parity:
|
|
169
|
+
parity: true
|
|
159
170
|
},
|
|
160
171
|
{
|
|
161
172
|
title: "link spanning differently-marked text renders one anchor",
|
|
162
|
-
input:
|
|
173
|
+
input: doc(p(t("go ", [link]), t("bold", [link, { type: "bold" }]), t(" now", [link]))),
|
|
163
174
|
expected: "<p><a target=\"_blank\" rel=\"noopener noreferrer nofollow\" href=\"https://example.com\">go <strong>bold</strong> now</a></p>",
|
|
164
|
-
parity:
|
|
175
|
+
parity: true
|
|
165
176
|
},
|
|
166
177
|
{
|
|
167
178
|
title: "text escaping of angle brackets and ampersands",
|
|
168
|
-
input:
|
|
179
|
+
input: doc(p(t("a < b & c > d"))),
|
|
169
180
|
expected: "<p>a < b & c > d</p>",
|
|
170
|
-
parity:
|
|
181
|
+
parity: true
|
|
171
182
|
},
|
|
172
183
|
{
|
|
173
184
|
title: "javascript: href is sanitized to empty (intentionally stricter than TipTap)",
|
|
174
|
-
input:
|
|
185
|
+
input: doc(p(t("x", [{
|
|
175
186
|
type: "link",
|
|
176
187
|
attrs: { href: "javascript:alert(1)" }
|
|
177
188
|
}]))),
|
|
178
189
|
expected: "<p><a href=\"\">x</a></p>",
|
|
179
|
-
parity:
|
|
190
|
+
parity: false
|
|
180
191
|
},
|
|
181
192
|
{
|
|
182
193
|
title: "unknown node types are skipped",
|
|
183
|
-
input:
|
|
194
|
+
input: doc({
|
|
184
195
|
type: "schema",
|
|
185
196
|
attrs: { data: {} }
|
|
186
|
-
}, t(
|
|
197
|
+
}, p(t("kept"))),
|
|
187
198
|
expected: "<p>kept</p>",
|
|
188
|
-
parity:
|
|
199
|
+
parity: false
|
|
189
200
|
}
|
|
190
201
|
];
|
|
191
202
|
//#endregion
|
|
192
|
-
export {
|
|
203
|
+
export { richTextFixtures };
|
package/package.json
CHANGED