@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/dist/index.mjs CHANGED
@@ -1,86 +1,136 @@
1
1
  //#region src/escape.ts
2
- var e = {
2
+ var TEXT_ESCAPES = {
3
3
  "&": "&",
4
4
  "<": "&lt;",
5
5
  ">": "&gt;"
6
- }, t = {
7
- ...e,
6
+ };
7
+ var ATTR_ESCAPES = {
8
+ ...TEXT_ESCAPES,
8
9
  "\"": "&quot;"
9
10
  };
10
- function n(t) {
11
- return t.replace(/[&<>]/g, (t) => e[t]);
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
- function r(e) {
14
- return e.replace(/[&"<>]/g, (e) => t[e]);
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 i = /^(?:https?:|mailto:|tel:)/i, a = /^[a-z][a-z0-9+.-]*:/i;
17
- function o(e) {
18
- let t = e.trim();
19
- return t === "" ? "" : i.test(t) || !a.test(t) ? t : "";
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
- function s(e, t, n = {}) {
24
- let r = {}, i = (e) => n.attrMap?.[e] ?? e, a = (e, t) => {
25
- t != null && t !== "" && (r[i(e)] = t);
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 (!t) return r;
28
- switch (e) {
51
+ if (!attrs) return out;
52
+ switch (type) {
29
53
  case "orderedList":
30
- t.start !== null && t.start !== void 0 && t.start !== 1 && a("start", t.start);
54
+ if (attrs.start !== null && attrs.start !== void 0 && attrs.start !== 1) put("start", attrs.start);
31
55
  break;
32
56
  case "codeBlock":
33
- t.language && a("class", `language-${t.language}`);
57
+ if (attrs.language) put("class", `language-${attrs.language}`);
34
58
  break;
35
- case "link": a("target", t.target), a("rel", t.rel), r[i("href")] = o(String(t.href ?? "")), a("class", t.class);
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 r;
65
+ return out;
38
66
  }
39
67
  //#endregion
40
68
  //#region src/marks.ts
41
- function c(e, t) {
42
- return e.type === t.type && JSON.stringify(e.attrs ?? {}) === JSON.stringify(t.attrs ?? {});
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
- function l(e) {
45
- let t = [], n = [];
46
- for (let r of e) {
47
- let e = r.marks ?? [], i = 0;
48
- for (; i < n.length && i < e.length && c(n[i].mark, e[i]);) i++;
49
- n.length = i;
50
- for (let r = i; r < e.length; r++) {
51
- let i = {
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: e[r],
91
+ mark: marks[i],
54
92
  children: []
55
93
  };
56
- (n.length > 0 ? n[n.length - 1].children : t).push(i), n.push(i);
94
+ (stack.length > 0 ? stack[stack.length - 1].children : root).push(segment);
95
+ stack.push(segment);
57
96
  }
58
- (n.length > 0 ? n[n.length - 1].children : t).push({
97
+ (stack.length > 0 ? stack[stack.length - 1].children : root).push({
59
98
  kind: "text",
60
- text: r.text
99
+ text: node.text
61
100
  });
62
101
  }
63
- return t;
102
+ return root;
64
103
  }
65
104
  //#endregion
66
105
  //#region src/normalize.ts
67
- function u(e, t = {}) {
68
- let n;
69
- return n = e ? Array.isArray(e) ? e : e.type === "doc" ? e.content ?? [] : typeof e.type == "string" ? [e] : [] : [], t.withKeys ? d(n, {}) : n;
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 d(e, t) {
72
- return e.map((e) => {
73
- t[e.type] = (t[e.type] ?? 0) + 1;
74
- let n = {
75
- ...e,
76
- _key: `${e.type}-${t[e.type]}`
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
- return Array.isArray(n.content) && (n.content = d(n.content, t)), n;
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 f = [
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
- function p(e) {
92
- let t = e?.level;
93
- return `h${f.includes(t) ? t : 1}`;
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
- var m = {
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: !0
152
+ content: true
101
153
  },
102
154
  heading: {
103
- resolve: p,
104
- content: !0
155
+ resolve: resolveHeadingTag,
156
+ content: true
105
157
  },
106
158
  bulletList: {
107
159
  tag: "ul",
108
- content: !0
160
+ content: true
109
161
  },
110
162
  orderedList: {
111
163
  tag: "ol",
112
- content: !0
164
+ content: true
113
165
  },
114
166
  listItem: {
115
167
  tag: "li",
116
- content: !0
168
+ content: true
117
169
  },
118
170
  codeBlock: {
119
171
  tag: "pre",
120
172
  children: [{
121
173
  tag: "code",
122
- content: !0
174
+ content: true
123
175
  }]
124
176
  }
125
- }, h = {
177
+ };
178
+ var MARK_RENDER_MAP = {
126
179
  bold: {
127
180
  tag: "strong",
128
- content: !0
181
+ content: true
129
182
  },
130
183
  italic: {
131
184
  tag: "em",
132
- content: !0
185
+ content: true
133
186
  },
134
187
  strike: {
135
188
  tag: "s",
136
- content: !0
189
+ content: true
137
190
  },
138
191
  underline: {
139
192
  tag: "u",
140
- content: !0
193
+ content: true
141
194
  },
142
195
  code: {
143
196
  tag: "code",
144
- content: !0
197
+ content: true
145
198
  },
146
199
  link: {
147
200
  tag: "a",
148
- content: !0
201
+ content: true
149
202
  }
150
203
  };
151
204
  //#endregion
152
205
  //#region src/render-html.ts
153
- function g(e, t = {}) {
154
- return _(u(e), {
155
- renderers: t.renderers,
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 _(e, t) {
160
- let n = "", r = 0;
161
- for (; r < e.length;) {
162
- let i = e[r];
163
- if (i.type === "text" && !t.renderers?.text) {
164
- let i = [];
165
- for (; r < e.length && e[r].type === "text";) i.push(e[r]), r++;
166
- n += y(l(i), t);
167
- } else n += v(i, t), r++;
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 n;
234
+ return result;
170
235
  }
171
- function v(e, t) {
172
- let r = t.renderers?.[e.type];
173
- if (r) {
174
- let i = {
175
- ...t.renderers,
176
- [e.type]: void 0
177
- }, a = {
178
- renderers: i,
179
- warned: t.warned
180
- }, o = e.type === "text" ? n(e.text ?? "") : _(e.content ?? [], a);
181
- return r({
182
- ...e,
183
- children: o,
184
- context: { renderers: i }
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 (e.type === "text") return y(l([e]), t);
188
- let i = m[e.type];
189
- if (i === void 0) return x(t, e.type), "";
190
- if (i === null) return _(e.content ?? [], t);
191
- let a = s(e.type, e.attrs), o = _(e.content ?? [], t);
192
- if (i.children) {
193
- let e = o;
194
- for (let t = i.children.length - 1; t >= 0; t--) {
195
- let n = i.children[t];
196
- e = b(n.tag, n.content ? a : {}, e);
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 b(i.tag, {}, e);
269
+ return wrapTag(spec.tag, {}, inner);
199
270
  }
200
- return b(i.resolve ? i.resolve(e.attrs) : i.tag, a, o);
271
+ return wrapTag(spec.resolve ? spec.resolve(node.attrs) : spec.tag, attrs, children);
201
272
  }
202
- function y(e, t) {
203
- let r = "";
204
- for (let i of e) {
205
- if (i.kind === "text") {
206
- r += n(i.text);
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
- let e = y(i.children, t), a = t.renderers?.[i.mark.type];
210
- if (a) {
211
- r += a({
212
- ...i.mark,
213
- children: e,
214
- context: { renderers: t.renderers }
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
- let o = h[i.mark.type];
219
- if (!o) {
220
- x(t, i.mark.type), r += e;
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
- r += b(o.tag, s(i.mark.type, i.mark.attrs), e);
296
+ out += wrapTag(spec.tag, processAttrs(segment.mark.type, segment.mark.attrs), children);
224
297
  }
225
- return r;
298
+ return out;
226
299
  }
227
- function b(e, t, n) {
228
- let i = `<${e}`;
229
- for (let [e, n] of Object.entries(t)) i += ` ${e}="${r(String(n))}"`;
230
- return `${i}>${n}</${e}>`;
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 x(e, t) {
233
- 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.`));
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 { h as MARK_RENDER_MAP, m as NODE_RENDER_MAP, l as buildMarkTree, r as escapeAttr, n as escapeHtml, c as marksEqual, u as normalizeInput, s as processAttrs, g as renderRichTextToHtml, p as resolveHeadingTag, o as sanitizeUrl };
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
- * Structural stand-in for `@localess/client`'s `ContentRichText` so client
75
- * values pass without casting. Deliberately not imported — this package has
76
- * zero dependencies.
75
+ * Re-exported from `@localess/model` so `LocalessRichTextInput` accepts
76
+ * `ContentRichText` values without casting.
77
77
  */
78
- export interface ContentRichTextLike {
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[] | ContentRichTextLike | null | undefined;
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
  /**
@@ -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/client`) into a node list.
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[];