@localess/richtext 3.4.1-dev.20260831165456

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 ADDED
@@ -0,0 +1,75 @@
1
+ ---
2
+ name: localess-richtext
3
+ description: Framework-neutral rich text model and renderer for Localess TipTap JSON content. Zero dependencies. Use when rendering Localess RICH_TEXT fields to HTML or building a framework-specific rich text walker.
4
+ ---
5
+
6
+ # @localess/richtext
7
+
8
+ Renders Localess rich text field values (TipTap/ProseMirror JSON produced by
9
+ the Localess Studio editor) without TipTap at runtime. Zero production
10
+ dependencies; safe in browsers, SSR, and edge runtimes.
11
+
12
+ Framework packages (`@localess/react`, `@localess/vue`, `@localess/svelte`,
13
+ `@localess/astro`, `@localess/angular`) ship idiomatic wrappers — prefer those
14
+ in app code. Use this package directly for custom pipelines (emails, static
15
+ generation, other frameworks).
16
+
17
+ ## Render to HTML
18
+
19
+ ```ts
20
+ import { renderRichTextToHtml } from '@localess/richtext';
21
+
22
+ const html = renderRichTextToHtml(data.body);
23
+ ```
24
+
25
+ - Input: `LocalessRichTextInput` — a `doc`, a node, a node array, `null`, or
26
+ `@localess/client`'s `ContentRichText` (structurally compatible, no cast).
27
+ - Output is byte-identical to TipTap's `generateHTML` for the Studio's
28
+ extension set, except link `href`s pass a protocol allowlist
29
+ (`http:`/`https:`/`mailto:`/`tel:`/scheme-less); `javascript:`/`data:`
30
+ hrefs become `""`.
31
+ - Never throws; malformed input renders `''`.
32
+
33
+ ## Supported node set
34
+
35
+ Nodes: `doc`, `paragraph`, `heading` (1–6), `bulletList`, `orderedList`
36
+ (`start`), `listItem`, `codeBlock` (`language` → `class="language-x"` on
37
+ `<code>`), `text`. Marks: `bold` → `<strong>`, `italic` → `<em>`,
38
+ `strike` → `<s>`, `underline` → `<u>`, `code` → `<code>`, `link` → `<a>`.
39
+
40
+ Unknown types are skipped with a dev-only warning unless a custom renderer is
41
+ provided for that type string.
42
+
43
+ ## Custom renderers
44
+
45
+ ```ts
46
+ renderRichTextToHtml(data.body, {
47
+ renderers: {
48
+ heading: ({ attrs, children }) => `<h${attrs.level} class="title">${children}</h${attrs.level}>`,
49
+ },
50
+ });
51
+ ```
52
+
53
+ `children` arrives pre-rendered. `props.context.renderers` has the current
54
+ type unset — pass it to a nested `renderRichTextToHtml` call to re-render your
55
+ own node without infinite recursion.
56
+
57
+ ## Building a native walker
58
+
59
+ The helpers encode the algorithms once so walkers are mechanical translations:
60
+ `normalizeInput(input, { withKeys: true })` (keyed node list),
61
+ `buildMarkTree(textRun)` (adjacent-mark merging), `processAttrs(type, attrs,
62
+ { attrMap })` (attribute normalization; React passes `{ class: 'className' }`),
63
+ `NODE_RENDER_MAP` / `MARK_RENDER_MAP` / `resolveHeadingTag` (default table).
64
+ See `@localess/react`'s `src/core/richtext.ts` for the reference walker.
65
+
66
+ ## Test fixtures
67
+
68
+ ```ts
69
+ import { richTextFixtures } from '@localess/richtext/test-utils';
70
+ ```
71
+
72
+ `{ title, input, expected, parity }` corpus asserted by every Localess
73
+ renderer. `parity: true` fixtures are additionally byte-compared to TipTap's
74
+ `generateHTML` — parity is normative; never weaken an assertion to
75
+ `toContain`.
@@ -0,0 +1,10 @@
1
+ export interface ProcessAttrsOptions {
2
+ /** Per-framework attribute renames, e.g. `{ class: 'className' }` for React. */
3
+ attrMap?: Record<string, string>;
4
+ }
5
+ /**
6
+ * Normalizes a node/mark's stored attrs into the attributes to emit, in the
7
+ * order TipTap's `generateHTML` emits them (parity-tested — adjust order here
8
+ * and in the fixtures together if the parity test disagrees).
9
+ */
10
+ export declare function processAttrs(type: string, attrs: Record<string, any> | undefined, options?: ProcessAttrsOptions): Record<string, any>;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Escapes text content for safe HTML output. The escape set (`& < >`) matches
3
+ * TipTap's `generateHTML` DOM serialization — parity-tested; do not widen it
4
+ * without updating the parity fixtures.
5
+ */
6
+ export declare function escapeHtml(text: string): string;
7
+ /** Escapes an attribute value for safe double-quoted HTML output (`& " < >`). */
8
+ export declare function escapeAttr(value: string): string;
9
+ /**
10
+ * Allowlist URL sanitizer for link hrefs: `http:`, `https:`, `mailto:`, `tel:`
11
+ * and scheme-less (relative/protocol-relative/fragment/query) URLs pass;
12
+ * everything else (e.g. `javascript:`, `data:`) becomes `''`.
13
+ */
14
+ export declare function sanitizeUrl(url: string): string;
@@ -0,0 +1,7 @@
1
+ export * from './attrs';
2
+ export * from './escape';
3
+ export * from './marks';
4
+ export * from './model';
5
+ export * from './normalize';
6
+ export * from './render-html';
7
+ export * from './render-map';
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});var e={"&":`&amp;`,"<":`&lt;`,">":`&gt;`},t={...e,'"':`&quot;`};function n(t){return t.replace(/[&<>]/g,t=>e[t])}function r(e){return e.replace(/[&"<>]/g,e=>t[e])}var i=/^(?:https?:|mailto:|tel:)/i,a=/^[a-z][a-z0-9+.-]*:/i;function o(e){let t=e.trim();return t===``?``:i.test(t)||!a.test(t)?t:``}function s(e,t,n={}){let r={},i=e=>n.attrMap?.[e]??e,a=(e,t)=>{t!=null&&t!==``&&(r[i(e)]=t)};if(!t)return r;switch(e){case`orderedList`:t.start!==null&&t.start!==void 0&&t.start!==1&&a(`start`,t.start);break;case`codeBlock`:t.language&&a(`class`,`language-${t.language}`);break;case`link`:a(`target`,t.target),a(`rel`,t.rel),r[i(`href`)]=o(String(t.href??``)),a(`class`,t.class)}return r}function c(e,t){return e.type===t.type&&JSON.stringify(e.attrs??{})===JSON.stringify(t.attrs??{})}function l(e){let t=[],n=[];for(let r of e){let e=r.marks??[],i=0;for(;i<n.length&&i<e.length&&c(n[i].mark,e[i]);)i++;n.length=i;for(let r=i;r<e.length;r++){let i={kind:`mark`,mark:e[r],children:[]};(n.length>0?n[n.length-1].children:t).push(i),n.push(i)}(n.length>0?n[n.length-1].children:t).push({kind:`text`,text:r.text})}return t}function u(e,t={}){let n;return n=e?Array.isArray(e)?e:e.type===`doc`?e.content??[]:typeof e.type==`string`?[e]:[]:[],t.withKeys?d(n,{}):n}function d(e,t){return e.map(e=>{t[e.type]=(t[e.type]??0)+1;let n={...e,_key:`${e.type}-${t[e.type]}`};return Array.isArray(n.content)&&(n.content=d(n.content,t)),n})}var f=[1,2,3,4,5,6];function p(e){let t=e?.level;return`h${f.includes(t)?t:1}`}var m={doc:null,text:null,paragraph:{tag:`p`,content:!0},heading:{resolve:p,content:!0},bulletList:{tag:`ul`,content:!0},orderedList:{tag:`ol`,content:!0},listItem:{tag:`li`,content:!0},codeBlock:{tag:`pre`,children:[{tag:`code`,content:!0}]}},h={bold:{tag:`strong`,content:!0},italic:{tag:`em`,content:!0},strike:{tag:`s`,content:!0},underline:{tag:`u`,content:!0},code:{tag:`code`,content:!0},link:{tag:`a`,content:!0}};function g(e,t={}){return _(u(e),{renderers:t.renderers,warned:new Set})}function _(e,t){let n=``,r=0;for(;r<e.length;){let i=e[r];if(i.type===`text`&&!t.renderers?.text){let i=[];for(;r<e.length&&e[r].type===`text`;)i.push(e[r]),r++;n+=y(l(i),t)}else n+=v(i,t),r++}return n}function v(e,t){let r=t.renderers?.[e.type];if(r){let i={...t.renderers,[e.type]:void 0},a={renderers:i,warned:t.warned},o=e.type===`text`?n(e.text??``):_(e.content??[],a);return r({...e,children:o,context:{renderers:i}})}if(e.type===`text`)return y(l([e]),t);let i=m[e.type];if(i===void 0)return x(t,e.type),``;if(i===null)return _(e.content??[],t);let a=s(e.type,e.attrs),o=_(e.content??[],t);if(i.children){let e=o;for(let t=i.children.length-1;t>=0;t--){let n=i.children[t];e=b(n.tag,n.content?a:{},e)}return b(i.tag,{},e)}return b(i.resolve?i.resolve(e.attrs):i.tag,a,o)}function y(e,t){let r=``;for(let i of e){if(i.kind===`text`){r+=n(i.text);continue}let e=y(i.children,t),a=t.renderers?.[i.mark.type];if(a){r+=a({...i.mark,children:e,context:{renderers:t.renderers}});continue}let o=h[i.mark.type];if(!o){x(t,i.mark.type),r+=e;continue}r+=b(o.tag,s(i.mark.type,i.mark.attrs),e)}return r}function b(e,t,n){let i=`<${e}`;for(let[e,n]of Object.entries(t))i+=` ${e}="${r(String(n))}"`;return`${i}>${n}</${e}>`}function x(e,t){typeof process<`u`&&process.env&&process.env.NODE_ENV===`production`||e.warned.has(t)||(e.warned.add(t),console.warn(`[@localess/richtext] Unknown rich text element "${t}" was skipped. Provide a custom renderer to handle it.`))}exports.MARK_RENDER_MAP=h,exports.NODE_RENDER_MAP=m,exports.buildMarkTree=l,exports.escapeAttr=r,exports.escapeHtml=n,exports.marksEqual=c,exports.normalizeInput=u,exports.processAttrs=s,exports.renderRichTextToHtml=g,exports.resolveHeadingTag=p,exports.sanitizeUrl=o;
package/dist/index.mjs ADDED
@@ -0,0 +1,236 @@
1
+ //#region src/escape.ts
2
+ var e = {
3
+ "&": "&amp;",
4
+ "<": "&lt;",
5
+ ">": "&gt;"
6
+ }, t = {
7
+ ...e,
8
+ "\"": "&quot;"
9
+ };
10
+ function n(t) {
11
+ return t.replace(/[&<>]/g, (t) => e[t]);
12
+ }
13
+ function r(e) {
14
+ return e.replace(/[&"<>]/g, (e) => t[e]);
15
+ }
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 : "";
20
+ }
21
+ //#endregion
22
+ //#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);
26
+ };
27
+ if (!t) return r;
28
+ switch (e) {
29
+ case "orderedList":
30
+ t.start !== null && t.start !== void 0 && t.start !== 1 && a("start", t.start);
31
+ break;
32
+ case "codeBlock":
33
+ t.language && a("class", `language-${t.language}`);
34
+ break;
35
+ case "link": a("target", t.target), a("rel", t.rel), r[i("href")] = o(String(t.href ?? "")), a("class", t.class);
36
+ }
37
+ return r;
38
+ }
39
+ //#endregion
40
+ //#region src/marks.ts
41
+ function c(e, t) {
42
+ return e.type === t.type && JSON.stringify(e.attrs ?? {}) === JSON.stringify(t.attrs ?? {});
43
+ }
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 = {
52
+ kind: "mark",
53
+ mark: e[r],
54
+ children: []
55
+ };
56
+ (n.length > 0 ? n[n.length - 1].children : t).push(i), n.push(i);
57
+ }
58
+ (n.length > 0 ? n[n.length - 1].children : t).push({
59
+ kind: "text",
60
+ text: r.text
61
+ });
62
+ }
63
+ return t;
64
+ }
65
+ //#endregion
66
+ //#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;
70
+ }
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]}`
77
+ };
78
+ return Array.isArray(n.content) && (n.content = d(n.content, t)), n;
79
+ });
80
+ }
81
+ //#endregion
82
+ //#region src/render-map.ts
83
+ var f = [
84
+ 1,
85
+ 2,
86
+ 3,
87
+ 4,
88
+ 5,
89
+ 6
90
+ ];
91
+ function p(e) {
92
+ let t = e?.level;
93
+ return `h${f.includes(t) ? t : 1}`;
94
+ }
95
+ var m = {
96
+ doc: null,
97
+ text: null,
98
+ paragraph: {
99
+ tag: "p",
100
+ content: !0
101
+ },
102
+ heading: {
103
+ resolve: p,
104
+ content: !0
105
+ },
106
+ bulletList: {
107
+ tag: "ul",
108
+ content: !0
109
+ },
110
+ orderedList: {
111
+ tag: "ol",
112
+ content: !0
113
+ },
114
+ listItem: {
115
+ tag: "li",
116
+ content: !0
117
+ },
118
+ codeBlock: {
119
+ tag: "pre",
120
+ children: [{
121
+ tag: "code",
122
+ content: !0
123
+ }]
124
+ }
125
+ }, h = {
126
+ bold: {
127
+ tag: "strong",
128
+ content: !0
129
+ },
130
+ italic: {
131
+ tag: "em",
132
+ content: !0
133
+ },
134
+ strike: {
135
+ tag: "s",
136
+ content: !0
137
+ },
138
+ underline: {
139
+ tag: "u",
140
+ content: !0
141
+ },
142
+ code: {
143
+ tag: "code",
144
+ content: !0
145
+ },
146
+ link: {
147
+ tag: "a",
148
+ content: !0
149
+ }
150
+ };
151
+ //#endregion
152
+ //#region src/render-html.ts
153
+ function g(e, t = {}) {
154
+ return _(u(e), {
155
+ renderers: t.renderers,
156
+ warned: /* @__PURE__ */ new Set()
157
+ });
158
+ }
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++;
168
+ }
169
+ return n;
170
+ }
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 }
185
+ });
186
+ }
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);
197
+ }
198
+ return b(i.tag, {}, e);
199
+ }
200
+ return b(i.resolve ? i.resolve(e.attrs) : i.tag, a, o);
201
+ }
202
+ function y(e, t) {
203
+ let r = "";
204
+ for (let i of e) {
205
+ if (i.kind === "text") {
206
+ r += n(i.text);
207
+ continue;
208
+ }
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 }
215
+ });
216
+ continue;
217
+ }
218
+ let o = h[i.mark.type];
219
+ if (!o) {
220
+ x(t, i.mark.type), r += e;
221
+ continue;
222
+ }
223
+ r += b(o.tag, s(i.mark.type, i.mark.attrs), e);
224
+ }
225
+ return r;
226
+ }
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}>`;
231
+ }
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.`));
234
+ }
235
+ //#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 };
@@ -0,0 +1,24 @@
1
+ import { LocalessRichTextMark } from './model';
2
+ export interface MarkTreeText {
3
+ kind: 'text';
4
+ text: string;
5
+ }
6
+ export interface MarkTreeMark {
7
+ kind: 'mark';
8
+ mark: LocalessRichTextMark;
9
+ children: MarkTreeSegment[];
10
+ }
11
+ export type MarkTreeSegment = MarkTreeText | MarkTreeMark;
12
+ /** Deep equality of two marks (type + attrs). Attr key order must match, which holds for editor-produced documents. */
13
+ export declare function marksEqual(a: LocalessRichTextMark, b: LocalessRichTextMark): boolean;
14
+ /**
15
+ * Folds a run of consecutive text nodes into a tree in which adjacent nodes
16
+ * sharing the same outer marks share one wrapper — the same merging
17
+ * ProseMirror's DOM serializer performs, so output matches TipTap's
18
+ * `generateHTML` (one `<a>` per link span, `<strong>a<em>b</em></strong>`
19
+ * instead of sibling `<strong>` wrappers).
20
+ */
21
+ export declare function buildMarkTree(nodes: Array<{
22
+ text: string;
23
+ marks?: LocalessRichTextMark[];
24
+ }>): MarkTreeSegment[];
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Attributes of a `link` mark as stored by the Localess Studio editor
3
+ * (TipTap Link extension JSON).
4
+ */
5
+ export interface LocalessRichTextLinkAttrs {
6
+ href: string;
7
+ target?: string | null;
8
+ rel?: string | null;
9
+ class?: string | null;
10
+ }
11
+ /**
12
+ * A rich text mark — inline formatting applied to a `text` node.
13
+ * Exactly the mark set the Localess Studio editor can produce.
14
+ */
15
+ export type LocalessRichTextMark = {
16
+ type: 'bold';
17
+ } | {
18
+ type: 'italic';
19
+ } | {
20
+ type: 'strike';
21
+ } | {
22
+ type: 'underline';
23
+ } | {
24
+ type: 'code';
25
+ } | {
26
+ type: 'link';
27
+ attrs: LocalessRichTextLinkAttrs;
28
+ };
29
+ /**
30
+ * A rich text node — exactly the node set the Localess Studio editor can produce.
31
+ */
32
+ export type LocalessRichTextNode = {
33
+ type: 'paragraph';
34
+ content?: LocalessRichTextNode[];
35
+ } | {
36
+ type: 'heading';
37
+ attrs: {
38
+ level: 1 | 2 | 3 | 4 | 5 | 6;
39
+ };
40
+ content?: LocalessRichTextNode[];
41
+ } | {
42
+ type: 'bulletList';
43
+ content?: LocalessRichTextNode[];
44
+ } | {
45
+ type: 'orderedList';
46
+ attrs?: {
47
+ start?: number;
48
+ };
49
+ content?: LocalessRichTextNode[];
50
+ } | {
51
+ type: 'listItem';
52
+ content?: LocalessRichTextNode[];
53
+ } | {
54
+ type: 'codeBlock';
55
+ attrs?: {
56
+ language?: string | null;
57
+ };
58
+ content?: LocalessRichTextNode[];
59
+ } | {
60
+ type: 'text';
61
+ text: string;
62
+ marks?: LocalessRichTextMark[];
63
+ };
64
+ /** A node with an optional stable key injected by `normalizeInput(input, { withKeys: true })`. */
65
+ export type LocalessRichTextNodeWithKey = LocalessRichTextNode & {
66
+ _key?: string;
67
+ };
68
+ /** The root document node (`getJSON()` output of the Studio editor). */
69
+ export interface LocalessRichTextDocument {
70
+ type: 'doc';
71
+ content?: LocalessRichTextNode[];
72
+ }
73
+ /**
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.
77
+ */
78
+ export interface ContentRichTextLike {
79
+ type?: string;
80
+ content?: ContentRichTextLike[];
81
+ }
82
+ /** Anything a render function accepts. */
83
+ export type LocalessRichTextInput = LocalessRichTextDocument | LocalessRichTextNode | LocalessRichTextNode[] | ContentRichTextLike | null | undefined;
84
+ /** Union of every known node and mark type name. */
85
+ export type LocalessRichTextElement = LocalessRichTextNode['type'] | LocalessRichTextMark['type'] | 'doc';
86
+ /**
87
+ * Props passed to a custom renderer. `children` is the pre-rendered content of
88
+ * the node/mark; `context.renderers` is the override map with the current
89
+ * type unset (loop prevention) for safe re-entrant rendering.
90
+ */
91
+ export interface LocalessRichTextRendererProps<TOut> {
92
+ type: string;
93
+ attrs?: Record<string, any>;
94
+ text?: string;
95
+ marks?: LocalessRichTextMark[];
96
+ content?: LocalessRichTextNode[];
97
+ children: TOut;
98
+ context: {
99
+ renderers?: LocalessRichTextRenderers<TOut>;
100
+ };
101
+ _key?: string;
102
+ }
103
+ /** A custom renderer for one node or mark type. */
104
+ export type LocalessRichTextRenderer<TOut> = (props: LocalessRichTextRendererProps<TOut>) => TOut;
105
+ /**
106
+ * Override map: node/mark type name → custom renderer. Unknown type names are
107
+ * allowed — that is the forward-compat seam for future node types.
108
+ */
109
+ export type LocalessRichTextRenderers<TOut> = Record<string, LocalessRichTextRenderer<TOut> | undefined>;
@@ -0,0 +1,11 @@
1
+ import { LocalessRichTextInput, LocalessRichTextNodeWithKey } from './model';
2
+ export interface NormalizeInputOptions {
3
+ /** Inject recursive `_key` values (`paragraph-1`, `text-3`, …) for keyed renderers. */
4
+ withKeys?: boolean;
5
+ }
6
+ /**
7
+ * Flattens any accepted rich text input (document, node, node array, or the
8
+ * loose `ContentRichText` shape from `@localess/client`) into a node list.
9
+ * Never throws; malformed input yields `[]`.
10
+ */
11
+ export declare function normalizeInput(input: LocalessRichTextInput, options?: NormalizeInputOptions): LocalessRichTextNodeWithKey[];
@@ -0,0 +1,11 @@
1
+ import { LocalessRichTextInput, LocalessRichTextRenderers } from './model';
2
+ export interface LocalessRichTextHtmlOptions {
3
+ /** Per-type overrides. A custom renderer receives pre-rendered `children` and a loop-safe `context`. */
4
+ renderers?: LocalessRichTextRenderers<string>;
5
+ }
6
+ /**
7
+ * Renders Localess rich text JSON to an HTML string. Framework-neutral,
8
+ * dependency-free, and byte-compatible with TipTap's `generateHTML` for the
9
+ * node set the Localess Studio editor produces.
10
+ */
11
+ export declare function renderRichTextToHtml(input: LocalessRichTextInput, options?: LocalessRichTextHtmlOptions): string;
@@ -0,0 +1,18 @@
1
+ /** Declarative default rendering for one node/mark type. */
2
+ export interface RichTextRenderSpec {
3
+ tag?: string;
4
+ /** Dynamic tag resolution (heading levels). Wins over `tag`. */
5
+ resolve?: (attrs: Record<string, any> | undefined) => string;
6
+ /** Render children inside this element. */
7
+ content?: boolean;
8
+ /** Static nested structure; node attrs attach to the child with `content: true`. */
9
+ children?: Array<{
10
+ tag: string;
11
+ content?: boolean;
12
+ }>;
13
+ }
14
+ /** Invalid levels fall back to h1, matching TipTap's first-configured-level behavior. */
15
+ export declare function resolveHeadingTag(attrs: Record<string, any> | undefined): string;
16
+ /** `null` = transparent (render children only, no element). Missing key = unknown type. */
17
+ export declare const NODE_RENDER_MAP: Record<string, RichTextRenderSpec | null>;
18
+ export declare const MARK_RENDER_MAP: Record<string, RichTextRenderSpec>;
@@ -0,0 +1,14 @@
1
+ import { LocalessRichTextInput } from '../model';
2
+ export interface RichTextFixture {
3
+ title: string;
4
+ input: LocalessRichTextInput;
5
+ expected: string;
6
+ parity: boolean;
7
+ }
8
+ /**
9
+ * Shared correctness corpus. Every renderer in every framework package must
10
+ * produce exactly these strings (DOM-roundtrip-normalized where the framework
11
+ * renders through a real DOM). `parity: true` fixtures are additionally
12
+ * asserted byte-identical to TipTap's `generateHTML`.
13
+ */
14
+ export declare const richTextFixtures: RichTextFixture[];
@@ -0,0 +1 @@
1
+ export * from './fixtures';
@@ -0,0 +1 @@
1
+ Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});var e=(...e)=>({type:`doc`,content:e}),t=(...e)=>({type:`paragraph`,content:e}),n=(e,t)=>({type:`text`,text:e,...t?{marks:t}:{}}),r=(...e)=>({type:`listItem`,content:e}),i={type:`link`,attrs:{href:`https://example.com`,target:`_blank`,rel:`noopener noreferrer nofollow`,class:null}},a=[{title:`empty doc`,input:e(),expected:``,parity:!0},{title:`null input`,input:null,expected:``,parity:!1},{title:`plain paragraph`,input:e(t(n(`Hello world`))),expected:`<p>Hello world</p>`,parity:!0},{title:`two paragraphs`,input:e(t(n(`One`)),t(n(`Two`))),expected:`<p>One</p><p>Two</p>`,parity:!0},{title:`all six heading levels`,input:e(...[1,2,3,4,5,6].map(e=>({type:`heading`,attrs:{level:e},content:[n(`H${e}`)]}))),expected:`<h1>H1</h1><h2>H2</h2><h3>H3</h3><h4>H4</h4><h5>H5</h5><h6>H6</h6>`,parity:!0},{title:`invalid heading level falls back to h1`,input:e({type:`heading`,attrs:{level:9},content:[n(`Big`)]}),expected:`<h1>Big</h1>`,parity:!0},{title:`every simple mark`,input:e(t(n(`b`,[{type:`bold`}]),n(`i`,[{type:`italic`}]),n(`s`,[{type:`strike`}]),n(`u`,[{type:`underline`}]),n(`c`,[{type:`code`}]))),expected:`<p><strong>b</strong><em>i</em><s>s</s><u>u</u><code>c</code></p>`,parity:!0},{title:`nested marks fold with marks[0] outermost`,input:e(t(n(`x`,[{type:`bold`},{type:`italic`}]))),expected:`<p><strong><em>x</em></strong></p>`,parity:!0},{title:`adjacent nodes sharing an outer mark merge into one wrapper`,input:e(t(n(`a`,[{type:`bold`}]),n(`b`,[{type:`bold`},{type:`italic`}]))),expected:`<p><strong>a<em>b</em></strong></p>`,parity:!0},{title:`bullet list with paragraphs in items`,input:e({type:`bulletList`,content:[r(t(n(`One`))),r(t(n(`Two`)))]}),expected:`<ul><li><p>One</p></li><li><p>Two</p></li></ul>`,parity:!0},{title:`ordered list omits start=1`,input:e({type:`orderedList`,attrs:{start:1},content:[r(t(n(`One`)))]}),expected:`<ol><li><p>One</p></li></ol>`,parity:!0},{title:`ordered list emits start=3`,input:e({type:`orderedList`,attrs:{start:3},content:[r(t(n(`Three`)))]}),expected:`<ol start="3"><li><p>Three</p></li></ol>`,parity:!0},{title:`nested bullet list inside a list item`,input:e({type:`bulletList`,content:[r(t(n(`Outer`)),{type:`bulletList`,content:[r(t(n(`Inner`)))]})]}),expected:`<ul><li><p>Outer</p><ul><li><p>Inner</p></li></ul></li></ul>`,parity:!0},{title:`code block without language`,input:e({type:`codeBlock`,attrs:{language:null},content:[n(`const x = 1;`)]}),expected:`<pre><code>const x = 1;</code></pre>`,parity:!0},{title:`code block with language class`,input:e({type:`codeBlock`,attrs:{language:`js`},content:[n(`const x = 1;`)]}),expected:`<pre><code class="language-js">const x = 1;</code></pre>`,parity:!0},{title:`link with editor-default attrs`,input:e(t(n(`Visit`,[i]))),expected:`<p><a target="_blank" rel="noopener noreferrer nofollow" href="https://example.com">Visit</a></p>`,parity:!0},{title:`link spanning differently-marked text renders one anchor`,input:e(t(n(`go `,[i]),n(`bold`,[i,{type:`bold`}]),n(` now`,[i]))),expected:`<p><a target="_blank" rel="noopener noreferrer nofollow" href="https://example.com">go <strong>bold</strong> now</a></p>`,parity:!0},{title:`text escaping of angle brackets and ampersands`,input:e(t(n(`a < b & c > d`))),expected:`<p>a &lt; b &amp; c &gt; d</p>`,parity:!0},{title:`javascript: href is sanitized to empty (intentionally stricter than TipTap)`,input:e(t(n(`x`,[{type:`link`,attrs:{href:`javascript:alert(1)`}}]))),expected:`<p><a href="">x</a></p>`,parity:!1},{title:`unknown node types are skipped`,input:e({type:`schema`,attrs:{data:{}}},t(n(`kept`))),expected:`<p>kept</p>`,parity:!1}];exports.richTextFixtures=a;
@@ -0,0 +1,192 @@
1
+ //#region src/test-utils/fixtures.ts
2
+ var e = (...e) => ({
3
+ type: "doc",
4
+ content: e
5
+ }), t = (...e) => ({
6
+ type: "paragraph",
7
+ content: e
8
+ }), n = (e, t) => ({
9
+ type: "text",
10
+ text: e,
11
+ ...t ? { marks: t } : {}
12
+ }), r = (...e) => ({
13
+ type: "listItem",
14
+ content: e
15
+ }), i = {
16
+ type: "link",
17
+ attrs: {
18
+ href: "https://example.com",
19
+ target: "_blank",
20
+ rel: "noopener noreferrer nofollow",
21
+ class: null
22
+ }
23
+ }, a = [
24
+ {
25
+ title: "empty doc",
26
+ input: e(),
27
+ expected: "",
28
+ parity: !0
29
+ },
30
+ {
31
+ title: "null input",
32
+ input: null,
33
+ expected: "",
34
+ parity: !1
35
+ },
36
+ {
37
+ title: "plain paragraph",
38
+ input: e(t(n("Hello world"))),
39
+ expected: "<p>Hello world</p>",
40
+ parity: !0
41
+ },
42
+ {
43
+ title: "two paragraphs",
44
+ input: e(t(n("One")), t(n("Two"))),
45
+ expected: "<p>One</p><p>Two</p>",
46
+ parity: !0
47
+ },
48
+ {
49
+ title: "all six heading levels",
50
+ input: e(...[
51
+ 1,
52
+ 2,
53
+ 3,
54
+ 4,
55
+ 5,
56
+ 6
57
+ ].map((e) => ({
58
+ type: "heading",
59
+ attrs: { level: e },
60
+ content: [n(`H${e}`)]
61
+ }))),
62
+ expected: "<h1>H1</h1><h2>H2</h2><h3>H3</h3><h4>H4</h4><h5>H5</h5><h6>H6</h6>",
63
+ parity: !0
64
+ },
65
+ {
66
+ title: "invalid heading level falls back to h1",
67
+ input: e({
68
+ type: "heading",
69
+ attrs: { level: 9 },
70
+ content: [n("Big")]
71
+ }),
72
+ expected: "<h1>Big</h1>",
73
+ parity: !0
74
+ },
75
+ {
76
+ title: "every simple mark",
77
+ input: e(t(n("b", [{ type: "bold" }]), n("i", [{ type: "italic" }]), n("s", [{ type: "strike" }]), n("u", [{ type: "underline" }]), n("c", [{ type: "code" }]))),
78
+ expected: "<p><strong>b</strong><em>i</em><s>s</s><u>u</u><code>c</code></p>",
79
+ parity: !0
80
+ },
81
+ {
82
+ title: "nested marks fold with marks[0] outermost",
83
+ input: e(t(n("x", [{ type: "bold" }, { type: "italic" }]))),
84
+ expected: "<p><strong><em>x</em></strong></p>",
85
+ parity: !0
86
+ },
87
+ {
88
+ title: "adjacent nodes sharing an outer mark merge into one wrapper",
89
+ input: e(t(n("a", [{ type: "bold" }]), n("b", [{ type: "bold" }, { type: "italic" }]))),
90
+ expected: "<p><strong>a<em>b</em></strong></p>",
91
+ parity: !0
92
+ },
93
+ {
94
+ title: "bullet list with paragraphs in items",
95
+ input: e({
96
+ type: "bulletList",
97
+ content: [r(t(n("One"))), r(t(n("Two")))]
98
+ }),
99
+ expected: "<ul><li><p>One</p></li><li><p>Two</p></li></ul>",
100
+ parity: !0
101
+ },
102
+ {
103
+ title: "ordered list omits start=1",
104
+ input: e({
105
+ type: "orderedList",
106
+ attrs: { start: 1 },
107
+ content: [r(t(n("One")))]
108
+ }),
109
+ expected: "<ol><li><p>One</p></li></ol>",
110
+ parity: !0
111
+ },
112
+ {
113
+ title: "ordered list emits start=3",
114
+ input: e({
115
+ type: "orderedList",
116
+ attrs: { start: 3 },
117
+ content: [r(t(n("Three")))]
118
+ }),
119
+ expected: "<ol start=\"3\"><li><p>Three</p></li></ol>",
120
+ parity: !0
121
+ },
122
+ {
123
+ title: "nested bullet list inside a list item",
124
+ input: e({
125
+ type: "bulletList",
126
+ content: [r(t(n("Outer")), {
127
+ type: "bulletList",
128
+ content: [r(t(n("Inner")))]
129
+ })]
130
+ }),
131
+ expected: "<ul><li><p>Outer</p><ul><li><p>Inner</p></li></ul></li></ul>",
132
+ parity: !0
133
+ },
134
+ {
135
+ title: "code block without language",
136
+ input: e({
137
+ type: "codeBlock",
138
+ attrs: { language: null },
139
+ content: [n("const x = 1;")]
140
+ }),
141
+ expected: "<pre><code>const x = 1;</code></pre>",
142
+ parity: !0
143
+ },
144
+ {
145
+ title: "code block with language class",
146
+ input: e({
147
+ type: "codeBlock",
148
+ attrs: { language: "js" },
149
+ content: [n("const x = 1;")]
150
+ }),
151
+ expected: "<pre><code class=\"language-js\">const x = 1;</code></pre>",
152
+ parity: !0
153
+ },
154
+ {
155
+ title: "link with editor-default attrs",
156
+ input: e(t(n("Visit", [i]))),
157
+ expected: "<p><a target=\"_blank\" rel=\"noopener noreferrer nofollow\" href=\"https://example.com\">Visit</a></p>",
158
+ parity: !0
159
+ },
160
+ {
161
+ title: "link spanning differently-marked text renders one anchor",
162
+ input: e(t(n("go ", [i]), n("bold", [i, { type: "bold" }]), n(" now", [i]))),
163
+ expected: "<p><a target=\"_blank\" rel=\"noopener noreferrer nofollow\" href=\"https://example.com\">go <strong>bold</strong> now</a></p>",
164
+ parity: !0
165
+ },
166
+ {
167
+ title: "text escaping of angle brackets and ampersands",
168
+ input: e(t(n("a < b & c > d"))),
169
+ expected: "<p>a &lt; b &amp; c &gt; d</p>",
170
+ parity: !0
171
+ },
172
+ {
173
+ title: "javascript: href is sanitized to empty (intentionally stricter than TipTap)",
174
+ input: e(t(n("x", [{
175
+ type: "link",
176
+ attrs: { href: "javascript:alert(1)" }
177
+ }]))),
178
+ expected: "<p><a href=\"\">x</a></p>",
179
+ parity: !1
180
+ },
181
+ {
182
+ title: "unknown node types are skipped",
183
+ input: e({
184
+ type: "schema",
185
+ attrs: { data: {} }
186
+ }, t(n("kept"))),
187
+ expected: "<p>kept</p>",
188
+ parity: !1
189
+ }
190
+ ];
191
+ //#endregion
192
+ export { a as richTextFixtures };
package/package.json ADDED
@@ -0,0 +1,75 @@
1
+ {
2
+ "name": "@localess/richtext",
3
+ "version": "3.4.1-dev.20260831165456",
4
+ "description": "Framework-neutral rich text model and renderer for Localess's TipTap JSON content.",
5
+ "keywords": [
6
+ "localess",
7
+ "sdk",
8
+ "richtext",
9
+ "tiptap",
10
+ "javascript",
11
+ "typescript"
12
+ ],
13
+ "author": "Lessify",
14
+ "homepage": "https://github.com/Lessify/localess-js",
15
+ "sideEffects": false,
16
+ "files": [
17
+ "dist",
18
+ "SKILL.md"
19
+ ],
20
+ "main": "dist/index.js",
21
+ "module": "dist/index.mjs",
22
+ "types": "dist/index.d.ts",
23
+ "exports": {
24
+ ".": {
25
+ "types": "./dist/index.d.ts",
26
+ "import": "./dist/index.mjs",
27
+ "require": "./dist/index.js"
28
+ },
29
+ "./test-utils": {
30
+ "types": "./dist/test-utils/index.d.ts",
31
+ "import": "./dist/test-utils/index.mjs",
32
+ "require": "./dist/test-utils/index.js"
33
+ }
34
+ },
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/Lessify/localess-js.git",
38
+ "directory": "packages/richtext"
39
+ },
40
+ "bugs": {
41
+ "url": "https://github.com/Lessify/localess-js/issues"
42
+ },
43
+ "scripts": {
44
+ "build": "vite build",
45
+ "test": "vitest run",
46
+ "test:watch": "vitest",
47
+ "test:coverage": "vitest run --coverage"
48
+ },
49
+ "license": "MIT",
50
+ "devDependencies": {
51
+ "@tiptap/extension-bold": "^3.22.5",
52
+ "@tiptap/extension-bullet-list": "^3.22.5",
53
+ "@tiptap/extension-code": "^3.22.5",
54
+ "@tiptap/extension-code-block-lowlight": "^3.22.5",
55
+ "@tiptap/extension-document": "^3.22.5",
56
+ "@tiptap/extension-heading": "^3.22.5",
57
+ "@tiptap/extension-history": "^3.22.5",
58
+ "@tiptap/extension-italic": "^3.22.5",
59
+ "@tiptap/extension-link": "^3.22.5",
60
+ "@tiptap/extension-list-item": "^3.22.5",
61
+ "@tiptap/extension-ordered-list": "^3.22.5",
62
+ "@tiptap/extension-paragraph": "^3.22.5",
63
+ "@tiptap/extension-strike": "^3.22.5",
64
+ "@tiptap/extension-text": "^3.22.5",
65
+ "@tiptap/extension-underline": "^3.22.5",
66
+ "@tiptap/html": "^3.22.5",
67
+ "@types/node": "^24",
68
+ "typescript": "^5.9.3",
69
+ "vite": "^8.0.16",
70
+ "vite-plugin-dts": "^5.0.0"
71
+ },
72
+ "engines": {
73
+ "node": ">= 24.0.0"
74
+ }
75
+ }