@inshapardaz/likhari-converters 0.1.10 → 0.1.11-dev.105

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.
@@ -1,8 +1,8 @@
1
1
  import type { FormatConverter } from '../types.js';
2
2
  /**
3
- * Placeholder — HTML import/export is Phase 2 work (lexical-editor-spec.md
4
- * §13). Registered now so FormatRegistry's shape is stable and callers get a
5
- * clear error instead of a missing format.
3
+ * HTML export/import (lexical-editor-spec.md §2.2). Export is pure string
4
+ * generation over the serialized tree; import needs a DOM parser, which the
5
+ * browser (and jsdom in tests) provides.
6
6
  */
7
7
  export declare const htmlConverter: FormatConverter;
8
8
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/html/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,UAAU,CAAC;AAEhD;;;;GAIG;AACH,eAAO,MAAM,aAAa,EAAE,eAQ3B,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/html/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,UAAU,CAAC;AAIhD;;;;GAIG;AACH,eAAO,MAAM,aAAa,EAAE,eAI3B,CAAC"}
@@ -1,14 +1,12 @@
1
+ import { serializeHtml } from './serialize.js';
2
+ import { parseHtml } from './parse.js';
1
3
  /**
2
- * Placeholder — HTML import/export is Phase 2 work (lexical-editor-spec.md
3
- * §13). Registered now so FormatRegistry's shape is stable and callers get a
4
- * clear error instead of a missing format.
4
+ * HTML export/import (lexical-editor-spec.md §2.2). Export is pure string
5
+ * generation over the serialized tree; import needs a DOM parser, which the
6
+ * browser (and jsdom in tests) provides.
5
7
  */
6
8
  export const htmlConverter = {
7
9
  id: 'html',
8
- serialize() {
9
- throw new Error('@inshapardaz/likhari-converters: HTML export is not implemented yet (Phase 2, see docs/lexical-editor-spec.md §13).');
10
- },
11
- parse() {
12
- throw new Error('@inshapardaz/likhari-converters: HTML import is not implemented yet (Phase 2, see docs/lexical-editor-spec.md §13).');
13
- },
10
+ serialize: (state, ctx) => serializeHtml(state, ctx),
11
+ parse: (input, ctx) => parseHtml(input, ctx),
14
12
  };
@@ -0,0 +1,8 @@
1
+ import type { SerializedEditorState } from 'lexical';
2
+ import type { ConverterContext } from '../types.js';
3
+ /** Link hrefs with an executable scheme are dropped on import. */
4
+ export declare function safeUrl(url: string): string | null;
5
+ /** Image sources: only http(s), relative and base64 image data URIs. */
6
+ export declare function safeImageSrc(src: string): string | null;
7
+ export declare function parseHtml(input: string, _ctx?: ConverterContext): SerializedEditorState;
8
+ //# sourceMappingURL=parse.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"parse.d.ts","sourceRoot":"","sources":["../../src/html/parse.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,SAAS,CAAC;AACrD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,UAAU,CAAC;AAiCjD,kEAAkE;AAClE,wBAAgB,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAIlD;AAED,wEAAwE;AACxE,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAKvD;AAiRD,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,gBAAgB,GAAG,qBAAqB,CAMvF"}
@@ -0,0 +1,324 @@
1
+ import { FORMAT_BOLD, FORMAT_CODE, FORMAT_HIGHLIGHT, FORMAT_ITALIC, FORMAT_STRIKETHROUGH, FORMAT_SUBSCRIPT, FORMAT_SUPERSCRIPT, FORMAT_UNDERLINE, INDENT_PX, elementBase, makeState, paragraph, textNode, } from '../shared/serialized.js';
2
+ const ALIGNMENTS = new Set(['left', 'right', 'center', 'justify', 'start', 'end']);
3
+ const TAG_FORMATS = {
4
+ b: FORMAT_BOLD, strong: FORMAT_BOLD,
5
+ i: FORMAT_ITALIC, em: FORMAT_ITALIC,
6
+ u: FORMAT_UNDERLINE, ins: FORMAT_UNDERLINE,
7
+ s: FORMAT_STRIKETHROUGH, strike: FORMAT_STRIKETHROUGH, del: FORMAT_STRIKETHROUGH,
8
+ code: FORMAT_CODE, sub: FORMAT_SUBSCRIPT, sup: FORMAT_SUPERSCRIPT, mark: FORMAT_HIGHLIGHT,
9
+ };
10
+ /** Dropped with their content: never useful in a document. */
11
+ const SKIP_TAGS = new Set(['script', 'style', 'template', 'head', 'title', 'meta', 'link', 'noscript', 'iframe', 'object', 'embed']);
12
+ /** Transparent containers whose children are processed as if in place. */
13
+ const BLOCK_CONTAINERS = new Set(['div', 'section', 'article', 'main', 'header', 'footer', 'aside', 'nav', 'body', 'html', 'figure', 'tbody', 'thead', 'tfoot', 'details', 'summary', 'span-block']);
14
+ const BLOCK_TAGS = new Set([
15
+ 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'ul', 'ol', 'li', 'table', 'tr', 'td', 'th',
16
+ 'hr', 'pre', 'img', 'figcaption', 'dl', 'dt', 'dd', ...BLOCK_CONTAINERS,
17
+ ]);
18
+ /** Link hrefs with an executable scheme are dropped on import. */
19
+ export function safeUrl(url) {
20
+ const trimmed = url.trim();
21
+ if (/^(javascript|vbscript|data:text\/html)/i.test(trimmed.replace(/[\u0000- ]/g, '')))
22
+ return null;
23
+ return trimmed;
24
+ }
25
+ /** Image sources: only http(s), relative and base64 image data URIs. */
26
+ export function safeImageSrc(src) {
27
+ const trimmed = src.trim();
28
+ if (!trimmed)
29
+ return null;
30
+ if (/^data:/i.test(trimmed))
31
+ return /^data:image\/(png|jpe?g|gif|webp|svg\+xml|avif|bmp);/i.test(trimmed) ? trimmed : null;
32
+ return safeUrl(trimmed);
33
+ }
34
+ function blockExtras(el) {
35
+ const extra = {};
36
+ const dir = el.getAttribute('dir');
37
+ if (dir === 'rtl' || dir === 'ltr')
38
+ extra.direction = dir;
39
+ const align = el.style?.textAlign;
40
+ if (align && ALIGNMENTS.has(align))
41
+ extra.format = align;
42
+ const padding = el.style?.paddingInlineStart || el.style?.paddingLeft || el.style?.paddingRight;
43
+ const px = padding && /^([\d.]+)px$/.exec(padding);
44
+ if (px)
45
+ extra.indent = Math.min(Math.round(Number(px[1]) / INDENT_PX), 10);
46
+ return extra;
47
+ }
48
+ function inlineStyleOf(el) {
49
+ // Only carry presentation the editor itself produces (font, size, color).
50
+ const keep = [];
51
+ for (const prop of ['font-family', 'font-size', 'color', 'background-color']) {
52
+ const value = el.style?.getPropertyValue(prop);
53
+ if (value)
54
+ keep.push(`${prop}: ${value}`);
55
+ }
56
+ return keep.join('; ');
57
+ }
58
+ function formatsFromElement(el, base) {
59
+ let format = base.format | (TAG_FORMATS[el.tagName.toLowerCase()] ?? 0);
60
+ const weight = el.style?.fontWeight;
61
+ if (weight === 'bold' || Number(weight) >= 600)
62
+ format |= FORMAT_BOLD;
63
+ if (el.style?.fontStyle === 'italic')
64
+ format |= FORMAT_ITALIC;
65
+ const deco = el.style?.textDecoration || el.style?.getPropertyValue('text-decoration-line') || '';
66
+ if (deco.includes('underline'))
67
+ format |= FORMAT_UNDERLINE;
68
+ if (deco.includes('line-through'))
69
+ format |= FORMAT_STRIKETHROUGH;
70
+ const own = inlineStyleOf(el);
71
+ return { format, style: [base.style, own].filter(Boolean).join('; ') };
72
+ }
73
+ /** Collapse HTML whitespace like a browser does inside flowing text. */
74
+ function collapse(text) {
75
+ return text.replace(/[ \t\r\n\f]+/g, ' ');
76
+ }
77
+ function collectInline(node, state, out, preserveWs) {
78
+ if (node.nodeType === 3) {
79
+ const raw = node.nodeValue ?? '';
80
+ const text = preserveWs ? raw : collapse(raw);
81
+ if (text)
82
+ out.push(textNode(text, state.format, state.style));
83
+ return;
84
+ }
85
+ if (node.nodeType !== 1)
86
+ return;
87
+ const el = node;
88
+ const tag = el.tagName.toLowerCase();
89
+ if (SKIP_TAGS.has(tag))
90
+ return;
91
+ if (tag === 'br') {
92
+ out.push({ type: 'linebreak', version: 1 });
93
+ return;
94
+ }
95
+ if (tag === 'a') {
96
+ const url = safeUrl(el.getAttribute('href') ?? '');
97
+ const children = [];
98
+ el.childNodes.forEach((child) => collectInline(child, formatsFromElement(el, state), children, preserveWs));
99
+ if (url === null || url === '') {
100
+ out.push(...children);
101
+ return;
102
+ }
103
+ out.push(elementBase('link', children, {
104
+ url,
105
+ rel: el.getAttribute('rel'),
106
+ target: el.getAttribute('target'),
107
+ title: el.getAttribute('title'),
108
+ }));
109
+ return;
110
+ }
111
+ const next = formatsFromElement(el, state);
112
+ el.childNodes.forEach((child) => collectInline(child, next, out, preserveWs));
113
+ }
114
+ /** Trim the leading/trailing space a block's source whitespace left behind. */
115
+ function trimInline(nodes) {
116
+ const first = nodes[0];
117
+ if (first && first.type === 'text')
118
+ first.text = String(first.text).replace(/^ +/, '');
119
+ const last = nodes[nodes.length - 1];
120
+ if (last && last.type === 'text')
121
+ last.text = String(last.text).replace(/ +$/, '');
122
+ return nodes.filter((n) => n.type !== 'text' || n.text !== '');
123
+ }
124
+ function inlineChildrenOf(el, preserveWs = false) {
125
+ const out = [];
126
+ el.childNodes.forEach((child) => collectInline(child, { format: 0, style: '' }, out, preserveWs));
127
+ return trimInline(out);
128
+ }
129
+ /**
130
+ * Children of a "flow" container (body, div, td, li...): runs of inline
131
+ * content become paragraphs, block children become blocks.
132
+ */
133
+ function flowBlocks(el) {
134
+ const blocks = [];
135
+ let run = [];
136
+ const flush = () => {
137
+ if (run.length === 0)
138
+ return;
139
+ const out = [];
140
+ for (const n of run)
141
+ collectInline(n, { format: 0, style: '' }, out, false);
142
+ const children = trimInline(out);
143
+ if (children.length > 0)
144
+ blocks.push(paragraph(children));
145
+ run = [];
146
+ };
147
+ el.childNodes.forEach((child) => {
148
+ if (child.nodeType === 1) {
149
+ const tag = child.tagName.toLowerCase();
150
+ if (SKIP_TAGS.has(tag))
151
+ return;
152
+ if (BLOCK_TAGS.has(tag)) {
153
+ flush();
154
+ blocks.push(...convertBlock(child));
155
+ return;
156
+ }
157
+ }
158
+ run.push(child);
159
+ });
160
+ flush();
161
+ return blocks;
162
+ }
163
+ function convertList(el) {
164
+ const tag = el.tagName.toLowerCase();
165
+ const isCheck = el.getAttribute('data-list-type') === 'check';
166
+ const listType = isCheck ? 'check' : tag === 'ol' ? 'number' : 'bullet';
167
+ const start = tag === 'ol' ? Number(el.getAttribute('start')) || 1 : 1;
168
+ const items = [];
169
+ el.childNodes.forEach((child) => {
170
+ if (child.nodeType !== 1)
171
+ return;
172
+ const li = child;
173
+ if (li.tagName.toLowerCase() !== 'li')
174
+ return;
175
+ items.push(...convertListItem(li, isCheck));
176
+ });
177
+ return elementBase('list', items, { listType, start, tag: tag === 'ol' ? 'ol' : 'ul', ...blockExtras(el) });
178
+ }
179
+ /** One <li> may yield several listitems (inline content, then nested lists). */
180
+ function convertListItem(li, isCheck) {
181
+ const items = [];
182
+ let run = [];
183
+ const checked = isCheck ? li.getAttribute('data-checked') === 'true' : undefined;
184
+ const makeItem = (children) => elementBase('listitem', children, { value: 1, ...(isCheck ? { checked: !!checked } : {}), ...blockExtras(li) });
185
+ const flush = () => {
186
+ const out = [];
187
+ for (const n of run)
188
+ collectInline(n, { format: 0, style: '' }, out, false);
189
+ const children = trimInline(out);
190
+ if (children.length > 0)
191
+ items.push(makeItem(children));
192
+ run = [];
193
+ };
194
+ li.childNodes.forEach((child) => {
195
+ if (child.nodeType === 1) {
196
+ const tag = child.tagName.toLowerCase();
197
+ if (tag === 'ul' || tag === 'ol') {
198
+ flush();
199
+ items.push(makeItem([convertList(child)]));
200
+ return;
201
+ }
202
+ if (tag === 'p') {
203
+ // <li><p>text</p></li>: treat the paragraph as the item's own text.
204
+ run.push(...Array.from(child.childNodes));
205
+ return;
206
+ }
207
+ }
208
+ run.push(child);
209
+ });
210
+ flush();
211
+ return items;
212
+ }
213
+ function convertTable(el) {
214
+ const rows = [];
215
+ el.querySelectorAll('tr').forEach((tr) => {
216
+ // Skip rows of tables nested inside a cell.
217
+ if (tr.closest('table') !== el)
218
+ return;
219
+ const cells = [];
220
+ Array.from(tr.children).forEach((cell) => {
221
+ const tag = cell.tagName.toLowerCase();
222
+ if (tag !== 'td' && tag !== 'th')
223
+ return;
224
+ let children = flowBlocks(cell);
225
+ if (children.length === 0)
226
+ children = [paragraph()];
227
+ cells.push(elementBase('tablecell', children, {
228
+ headerState: tag === 'th' ? 1 : 0,
229
+ colSpan: Number(cell.getAttribute('colspan')) || 1,
230
+ rowSpan: Number(cell.getAttribute('rowspan')) || 1,
231
+ backgroundColor: null,
232
+ width: null,
233
+ }));
234
+ });
235
+ if (cells.length > 0)
236
+ rows.push(elementBase('tablerow', cells, { height: null }));
237
+ });
238
+ return elementBase('table', rows);
239
+ }
240
+ function convertImage(img, caption) {
241
+ const src = safeImageSrc(img.getAttribute('src') ?? '');
242
+ if (!src)
243
+ return null;
244
+ const dim = (name) => {
245
+ const n = Number(img.getAttribute(name));
246
+ return Number.isFinite(n) && n > 0 ? Math.round(n) : null;
247
+ };
248
+ const declared = img.getAttribute('data-link-type');
249
+ return {
250
+ type: 'image',
251
+ version: 1,
252
+ src,
253
+ altText: img.getAttribute('alt') ?? '',
254
+ caption,
255
+ linkType: declared === 'embedded' || /^data:/i.test(src) ? 'embedded' : 'linked',
256
+ width: dim('width'),
257
+ height: dim('height'),
258
+ };
259
+ }
260
+ function convertBlock(el) {
261
+ const tag = el.tagName.toLowerCase();
262
+ switch (tag) {
263
+ case 'p':
264
+ return [paragraph(inlineChildrenOf(el), blockExtras(el))];
265
+ case 'h1':
266
+ case 'h2':
267
+ case 'h3':
268
+ case 'h4':
269
+ case 'h5':
270
+ case 'h6':
271
+ return [elementBase('heading', inlineChildrenOf(el), { tag, ...blockExtras(el) })];
272
+ case 'blockquote': {
273
+ const hasBlocks = Array.from(el.children).some((c) => BLOCK_TAGS.has(c.tagName.toLowerCase()));
274
+ if (!hasBlocks)
275
+ return [elementBase('quote', inlineChildrenOf(el), blockExtras(el))];
276
+ // Quote holds inline content only: flatten block children, one quote each.
277
+ return flowBlocks(el).map((block) => (block.type === 'paragraph' ? elementBase('quote', block.children ?? [], blockExtras(el)) : block));
278
+ }
279
+ case 'pre': {
280
+ const text = (el.textContent ?? '').replace(/\n$/, '');
281
+ return [paragraph(text ? [textNode(text, FORMAT_CODE)] : [])];
282
+ }
283
+ case 'ul':
284
+ case 'ol':
285
+ return [convertList(el)];
286
+ case 'li':
287
+ return convertListItem(el, false).length > 0 ? [elementBase('list', convertListItem(el, false), { listType: 'bullet', start: 1, tag: 'ul' })] : [];
288
+ case 'table':
289
+ return [convertTable(el)];
290
+ case 'hr':
291
+ return [{ type: 'horizontalrule', version: 1 }];
292
+ case 'img': {
293
+ const image = convertImage(el, null);
294
+ return image ? [image] : [];
295
+ }
296
+ case 'figure': {
297
+ const img = el.querySelector('img');
298
+ const figcaption = el.querySelector('figcaption');
299
+ if (!img)
300
+ return flowBlocks(el);
301
+ const caption = figcaption ? (figcaption.textContent ?? '').trim() || null : null;
302
+ const image = convertImage(img, caption);
303
+ return image ? [image] : [];
304
+ }
305
+ case 'div':
306
+ if (el.hasAttribute('data-likhari-page-break'))
307
+ return [{ type: 'page-break', version: 1 }];
308
+ return flowBlocks(el);
309
+ case 'figcaption':
310
+ return [];
311
+ case 'dt':
312
+ case 'dd':
313
+ return [paragraph(inlineChildrenOf(el), blockExtras(el))];
314
+ default:
315
+ return flowBlocks(el);
316
+ }
317
+ }
318
+ export function parseHtml(input, _ctx) {
319
+ if (typeof DOMParser === 'undefined') {
320
+ throw new Error('@inshapardaz/likhari-converters: HTML import needs a DOM (DOMParser); run it in a browser or jsdom.');
321
+ }
322
+ const doc = new DOMParser().parseFromString(input, 'text/html');
323
+ return makeState(flowBlocks(doc.body));
324
+ }
@@ -0,0 +1,8 @@
1
+ import type { SerializedEditorState } from 'lexical';
2
+ import type { ConverterContext } from '../types.js';
3
+ import { type SNode } from '../shared/serialized.js';
4
+ export declare function escapeHtml(value: string): string;
5
+ export declare function escapeAttr(value: string): string;
6
+ export declare function nodeToHtml(node: SNode, ctx?: ConverterContext): string;
7
+ export declare function serializeHtml(state: SerializedEditorState, ctx?: ConverterContext): string;
8
+ //# sourceMappingURL=serialize.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"serialize.d.ts","sourceRoot":"","sources":["../../src/html/serialize.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,SAAS,CAAC;AACrD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,UAAU,CAAC;AACjD,OAAO,EAE4E,KAAK,KAAK,EAC5F,MAAM,sBAAsB,CAAC;AAE9B,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAEhD;AAED,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAEhD;AAgGD,wBAAgB,UAAU,CAAC,IAAI,EAAE,KAAK,EAAE,GAAG,CAAC,EAAE,gBAAgB,GAAG,MAAM,CAuCtE;AAED,wBAAgB,aAAa,CAAC,KAAK,EAAE,qBAAqB,EAAE,GAAG,CAAC,EAAE,gBAAgB,GAAG,MAAM,CAE1F"}
@@ -0,0 +1,143 @@
1
+ import { FORMAT_BOLD, FORMAT_CODE, FORMAT_HIGHLIGHT, FORMAT_ITALIC, FORMAT_STRIKETHROUGH, FORMAT_SUBSCRIPT, FORMAT_SUPERSCRIPT, FORMAT_UNDERLINE, INDENT_PX, rootChildren, } from '../shared/serialized.js';
2
+ export function escapeHtml(value) {
3
+ return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
4
+ }
5
+ export function escapeAttr(value) {
6
+ return escapeHtml(value).replace(/"/g, '&quot;');
7
+ }
8
+ /** Wrapping order matters for round-trips: outermost first. */
9
+ const FORMAT_TAGS = [
10
+ [FORMAT_CODE, 'code'],
11
+ [FORMAT_BOLD, 'strong'],
12
+ [FORMAT_ITALIC, 'em'],
13
+ [FORMAT_UNDERLINE, 'u'],
14
+ [FORMAT_STRIKETHROUGH, 's'],
15
+ [FORMAT_SUBSCRIPT, 'sub'],
16
+ [FORMAT_SUPERSCRIPT, 'sup'],
17
+ [FORMAT_HIGHLIGHT, 'mark'],
18
+ ];
19
+ const ALIGNMENTS = new Set(['left', 'right', 'center', 'justify', 'start', 'end']);
20
+ function textToHtml(node) {
21
+ const text = String(node.text ?? '');
22
+ let out = escapeHtml(text).replace(/\t/g, '&#9;');
23
+ const format = Number(node.format ?? 0);
24
+ const style = String(node.style ?? '').trim();
25
+ if (style)
26
+ out = `<span style="${escapeAttr(style)}">${out}</span>`;
27
+ for (const [bit, tag] of FORMAT_TAGS) {
28
+ if (format & bit)
29
+ out = `<${tag}>${out}</${tag}>`;
30
+ }
31
+ return out;
32
+ }
33
+ function inlineChildren(node, ctx) {
34
+ return (node.children ?? []).map((child) => nodeToHtml(child, ctx)).join('');
35
+ }
36
+ /** `dir`, `style` (alignment, indent) and any extra attributes for a block. */
37
+ function blockAttrs(node, extra = '') {
38
+ let attrs = '';
39
+ if (node.direction === 'rtl' || node.direction === 'ltr')
40
+ attrs += ` dir="${node.direction}"`;
41
+ const styles = [];
42
+ const format = String(node.format ?? '');
43
+ if (ALIGNMENTS.has(format))
44
+ styles.push(`text-align: ${format}`);
45
+ const indent = Number(node.indent ?? 0);
46
+ if (indent > 0)
47
+ styles.push(`padding-inline-start: ${indent * INDENT_PX}px`);
48
+ if (styles.length > 0)
49
+ attrs += ` style="${styles.join('; ')}"`;
50
+ return attrs + extra;
51
+ }
52
+ function listToHtml(node, ctx) {
53
+ const listType = String(node.listType ?? 'bullet');
54
+ const tag = listType === 'number' ? 'ol' : 'ul';
55
+ let extra = '';
56
+ if (tag === 'ol' && Number(node.start) > 1)
57
+ extra += ` start="${Number(node.start)}"`;
58
+ if (listType === 'check')
59
+ extra += ' data-list-type="check"';
60
+ const items = (node.children ?? []).map((item) => listItemToHtml(item, listType, ctx)).join('');
61
+ return `<${tag}${blockAttrs(node, extra)}>${items}</${tag}>`;
62
+ }
63
+ function listItemToHtml(item, listType, ctx) {
64
+ const children = item.children ?? [];
65
+ // Lexical nests a sublist as a listitem whose only child is the list.
66
+ if (children.length === 1 && children[0].type === 'list') {
67
+ return `<li style="list-style-type: none">${listToHtml(children[0], ctx)}</li>`;
68
+ }
69
+ const checked = listType === 'check' ? ` data-checked="${item.checked ? 'true' : 'false'}"` : '';
70
+ return `<li${blockAttrs(item, checked)}>${inlineChildren(item, ctx)}</li>`;
71
+ }
72
+ function tableToHtml(node, ctx) {
73
+ const rows = (node.children ?? [])
74
+ .map((row) => {
75
+ const cells = (row.children ?? [])
76
+ .map((cell) => {
77
+ const header = Number(cell.headerState ?? 0) !== 0;
78
+ const tag = header ? 'th' : 'td';
79
+ const span = (Number(cell.colSpan) > 1 ? ` colspan="${Number(cell.colSpan)}"` : '') +
80
+ (Number(cell.rowSpan) > 1 ? ` rowspan="${Number(cell.rowSpan)}"` : '');
81
+ const content = (cell.children ?? []).map((child) => nodeToHtml(child, ctx)).join('');
82
+ return `<${tag}${span}>${content}</${tag}>`;
83
+ })
84
+ .join('');
85
+ return `<tr>${cells}</tr>`;
86
+ })
87
+ .join('');
88
+ return `<table><tbody>${rows}</tbody></table>`;
89
+ }
90
+ function imageToHtml(node, ctx) {
91
+ const rawSrc = String(node.src ?? '');
92
+ const src = ctx?.resolveImageUrl ? ctx.resolveImageUrl(rawSrc) : rawSrc;
93
+ let img = `<img src="${escapeAttr(src)}" alt="${escapeAttr(String(node.altText ?? ''))}"`;
94
+ if (node.width)
95
+ img += ` width="${Number(node.width)}"`;
96
+ if (node.height)
97
+ img += ` height="${Number(node.height)}"`;
98
+ img += ` data-link-type="${node.linkType === 'embedded' ? 'embedded' : 'linked'}">`;
99
+ const caption = typeof node.caption === 'string' && node.caption !== '' ? `<figcaption>${escapeHtml(node.caption)}</figcaption>` : '';
100
+ return `<figure>${img}${caption}</figure>`;
101
+ }
102
+ export function nodeToHtml(node, ctx) {
103
+ switch (node.type) {
104
+ case 'text':
105
+ return textToHtml(node);
106
+ case 'linebreak':
107
+ return '<br>';
108
+ case 'tab':
109
+ return '&#9;';
110
+ case 'paragraph':
111
+ return `<p${blockAttrs(node)}>${inlineChildren(node, ctx)}</p>`;
112
+ case 'heading': {
113
+ const tag = /^h[1-6]$/.test(String(node.tag)) ? String(node.tag) : 'h2';
114
+ return `<${tag}${blockAttrs(node)}>${inlineChildren(node, ctx)}</${tag}>`;
115
+ }
116
+ case 'quote':
117
+ return `<blockquote${blockAttrs(node)}>${inlineChildren(node, ctx)}</blockquote>`;
118
+ case 'list':
119
+ return listToHtml(node, ctx);
120
+ case 'link':
121
+ case 'autolink': {
122
+ const attrs = ` href="${escapeAttr(String(node.url ?? ''))}"` +
123
+ (node.rel ? ` rel="${escapeAttr(String(node.rel))}"` : '') +
124
+ (node.target ? ` target="${escapeAttr(String(node.target))}"` : '') +
125
+ (node.title ? ` title="${escapeAttr(String(node.title))}"` : '');
126
+ return `<a${attrs}>${inlineChildren(node, ctx)}</a>`;
127
+ }
128
+ case 'horizontalrule':
129
+ return '<hr>';
130
+ case 'page-break':
131
+ return '<div data-likhari-page-break style="page-break-after: always"></div>';
132
+ case 'image':
133
+ return imageToHtml(node, ctx);
134
+ case 'table':
135
+ return tableToHtml(node, ctx);
136
+ default:
137
+ // Unknown node (e.g. a future feature): keep its content, drop the wrapper.
138
+ return inlineChildren(node, ctx);
139
+ }
140
+ }
141
+ export function serializeHtml(state, ctx) {
142
+ return rootChildren(state).map((node) => nodeToHtml(node, ctx)).join('\n');
143
+ }
@@ -1,9 +1,8 @@
1
1
  import type { FormatConverter } from '../types.js';
2
2
  /**
3
- * Placeholder — the extended Markdown dialect (lexical-editor-spec.md §2.1)
4
- * is Phase 2 work (§13) and needs its own spec doc + sign-off before the
5
- * converter is built against it. Registered now so FormatRegistry's shape
6
- * is stable and callers get a clear error instead of a missing format.
3
+ * Extended Markdown (docs/markdown-dialect.md, ADR-0001): GFM plus a small
4
+ * set of directives, built on unified/remark so the same mdast tree can be
5
+ * rendered elsewhere.
7
6
  */
8
7
  export declare const markdownConverter: FormatConverter;
9
8
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/markdown/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,UAAU,CAAC;AAEhD;;;;;GAKG;AACH,eAAO,MAAM,iBAAiB,EAAE,eAY/B,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/markdown/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,UAAU,CAAC;AAIhD;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,EAAE,eAI/B,CAAC"}
@@ -1,15 +1,12 @@
1
+ import { serializeMarkdown } from './serialize.js';
2
+ import { parseMarkdown } from './parse.js';
1
3
  /**
2
- * Placeholder — the extended Markdown dialect (lexical-editor-spec.md §2.1)
3
- * is Phase 2 work (§13) and needs its own spec doc + sign-off before the
4
- * converter is built against it. Registered now so FormatRegistry's shape
5
- * is stable and callers get a clear error instead of a missing format.
4
+ * Extended Markdown (docs/markdown-dialect.md, ADR-0001): GFM plus a small
5
+ * set of directives, built on unified/remark so the same mdast tree can be
6
+ * rendered elsewhere.
6
7
  */
7
8
  export const markdownConverter = {
8
9
  id: 'markdown',
9
- serialize() {
10
- throw new Error('@inshapardaz/likhari-converters: the extended Markdown dialect is not implemented yet (Phase 2, see docs/lexical-editor-spec.md §2.1 and §13).');
11
- },
12
- parse() {
13
- throw new Error('@inshapardaz/likhari-converters: the extended Markdown dialect is not implemented yet (Phase 2, see docs/lexical-editor-spec.md §2.1 and §13).');
14
- },
10
+ serialize: (state, ctx) => serializeMarkdown(state, ctx),
11
+ parse: (input, ctx) => parseMarkdown(input, ctx),
15
12
  };
@@ -0,0 +1,22 @@
1
+ /** Minimal structural view of an mdast node: the dialect's tree is composed of
2
+ * several plugin-defined node types, so a loose type is more honest than a union. */
3
+ export interface MdNode {
4
+ type: string;
5
+ children?: MdNode[];
6
+ value?: string;
7
+ depth?: number;
8
+ ordered?: boolean;
9
+ start?: number | null;
10
+ spread?: boolean;
11
+ checked?: boolean | null;
12
+ url?: string;
13
+ title?: string | null;
14
+ alt?: string | null;
15
+ lang?: string | null;
16
+ name?: string;
17
+ attributes?: Record<string, string | null | undefined> | null;
18
+ align?: Array<'left' | 'right' | 'center' | null> | null;
19
+ }
20
+ export type Attrs = Record<string, string>;
21
+ export declare function directive(type: 'textDirective' | 'leafDirective' | 'containerDirective', name: string, attributes: Attrs, children?: MdNode[]): MdNode;
22
+ //# sourceMappingURL=mdast.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mdast.d.ts","sourceRoot":"","sources":["../../src/markdown/mdast.ts"],"names":[],"mappings":"AAAA;qFACqF;AACrF,MAAM,WAAW,MAAM;IACrB,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;IACpB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,OAAO,CAAC,EAAE,OAAO,GAAG,IAAI,CAAC;IACzB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,GAAG,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC,GAAG,IAAI,CAAC;IAC9D,KAAK,CAAC,EAAE,KAAK,CAAC,MAAM,GAAG,OAAO,GAAG,QAAQ,GAAG,IAAI,CAAC,GAAG,IAAI,CAAC;CAC1D;AAED,MAAM,MAAM,KAAK,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;AAE3C,wBAAgB,SAAS,CAAC,IAAI,EAAE,eAAe,GAAG,eAAe,GAAG,oBAAoB,EAAE,IAAI,EAAE,MAAM,EAAE,UAAU,EAAE,KAAK,EAAE,QAAQ,GAAE,MAAM,EAAO,GAAG,MAAM,CAE1J"}
@@ -0,0 +1,3 @@
1
+ export function directive(type, name, attributes, children = []) {
2
+ return { type, name, attributes, children };
3
+ }
@@ -0,0 +1,4 @@
1
+ import type { SerializedEditorState } from 'lexical';
2
+ import type { ConverterContext } from '../types.js';
3
+ export declare function parseMarkdown(input: string, _ctx?: ConverterContext): SerializedEditorState;
4
+ //# sourceMappingURL=parse.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"parse.d.ts","sourceRoot":"","sources":["../../src/markdown/parse.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,SAAS,CAAC;AACrD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,UAAU,CAAC;AAoRjD,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,gBAAgB,GAAG,qBAAqB,CAI3F"}
@@ -0,0 +1,283 @@
1
+ import { unified } from 'unified';
2
+ import remarkParse from 'remark-parse';
3
+ import remarkGfm from 'remark-gfm';
4
+ import remarkDirective from 'remark-directive';
5
+ import { FORMAT_BOLD, FORMAT_CODE, FORMAT_HIGHLIGHT, FORMAT_ITALIC, FORMAT_STRIKETHROUGH, FORMAT_SUBSCRIPT, FORMAT_SUPERSCRIPT, FORMAT_UNDERLINE, elementBase, makeState, paragraph, textNode, } from '../shared/serialized.js';
6
+ import { safeImageSrc, safeUrl } from '../html/parse.js';
7
+ const processor = unified().use(remarkParse).use(remarkGfm).use(remarkDirective);
8
+ const ALIGNMENTS = new Set(['left', 'right', 'center', 'justify', 'start', 'end']);
9
+ const DIRECTIVE_FORMATS = {
10
+ u: FORMAT_UNDERLINE,
11
+ sup: FORMAT_SUPERSCRIPT,
12
+ sub: FORMAT_SUBSCRIPT,
13
+ mark: FORMAT_HIGHLIGHT,
14
+ };
15
+ /** Inverse of the serializer's `styleToAttrs`. */
16
+ function attrsToStyle(attrs) {
17
+ const parts = [];
18
+ const map = [['font', 'font-family'], ['size', 'font-size'], ['color', 'color'], ['bg', 'background-color']];
19
+ for (const [attr, prop] of map) {
20
+ const value = attrs[attr];
21
+ // A value that could break out of the declaration is dropped.
22
+ if (value && !/[;{}<>]/.test(value))
23
+ parts.push(`${prop}: ${value}`);
24
+ }
25
+ if (attrs.style && !/[{}<>]/.test(attrs.style))
26
+ parts.push(attrs.style);
27
+ return parts.join('; ');
28
+ }
29
+ function inline(nodes, state, out) {
30
+ for (const node of nodes) {
31
+ switch (node.type) {
32
+ case 'text':
33
+ if (node.value)
34
+ out.push(textNode(node.value, state.format, state.style));
35
+ break;
36
+ case 'strong':
37
+ inline(node.children ?? [], { ...state, format: state.format | FORMAT_BOLD }, out);
38
+ break;
39
+ case 'emphasis':
40
+ inline(node.children ?? [], { ...state, format: state.format | FORMAT_ITALIC }, out);
41
+ break;
42
+ case 'delete':
43
+ inline(node.children ?? [], { ...state, format: state.format | FORMAT_STRIKETHROUGH }, out);
44
+ break;
45
+ case 'inlineCode':
46
+ if (node.value)
47
+ out.push(textNode(node.value, state.format | FORMAT_CODE, state.style));
48
+ break;
49
+ case 'break':
50
+ out.push({ type: 'linebreak', version: 1 });
51
+ break;
52
+ case 'html':
53
+ // A literal <br> (how the serializer writes a line break inside a table cell).
54
+ if (/^<br\s*\/?>$/i.test((node.value ?? '').trim()))
55
+ out.push({ type: 'linebreak', version: 1 });
56
+ break;
57
+ case 'link': {
58
+ const url = safeUrl(node.url ?? '');
59
+ const children = [];
60
+ inline(node.children ?? [], state, children);
61
+ if (!url)
62
+ out.push(...children);
63
+ else
64
+ out.push(elementBase('link', children, { url, rel: null, target: null, title: node.title ?? null }));
65
+ break;
66
+ }
67
+ case 'textDirective': {
68
+ const name = node.name ?? '';
69
+ if (name === 'span') {
70
+ const style = attrsToStyle(node.attributes ?? {});
71
+ inline(node.children ?? [], { ...state, style: [state.style, style].filter(Boolean).join('; ') }, out);
72
+ }
73
+ else {
74
+ inline(node.children ?? [], { ...state, format: state.format | (DIRECTIVE_FORMATS[name] ?? 0) }, out);
75
+ }
76
+ break;
77
+ }
78
+ default:
79
+ // Images inside a text run are lifted out by the caller; references
80
+ // and unknown inline nodes keep their text.
81
+ if (node.children)
82
+ inline(node.children, state, out);
83
+ else if (node.value)
84
+ out.push(textNode(node.value, state.format, state.style));
85
+ }
86
+ }
87
+ }
88
+ function inlineOf(nodes) {
89
+ const out = [];
90
+ inline(nodes, { format: 0, style: '' }, out);
91
+ // Merge neighbours that ended up with identical formatting (e.g. around a restored ":30").
92
+ return out.reduce((acc, n) => {
93
+ const prev = acc[acc.length - 1];
94
+ if (prev && prev.type === 'text' && n.type === 'text' && prev.format === n.format && prev.style === n.style)
95
+ prev.text = String(prev.text) + String(n.text);
96
+ else
97
+ acc.push(n);
98
+ return acc;
99
+ }, []);
100
+ }
101
+ function imageBlock(node) {
102
+ const src = safeImageSrc(node.url ?? '');
103
+ if (!src)
104
+ return null;
105
+ return {
106
+ type: 'image', version: 1, src, altText: node.alt ?? '', caption: null,
107
+ linkType: /^data:/i.test(src) ? 'embedded' : 'linked', width: null, height: null,
108
+ };
109
+ }
110
+ /** A paragraph's images become block images; text between them stays paragraphs. */
111
+ function paragraphBlocks(node, extra) {
112
+ const blocks = [];
113
+ let run = [];
114
+ const flush = () => {
115
+ const children = inlineOf(run);
116
+ if (children.length > 0)
117
+ blocks.push(paragraph(children, extra));
118
+ run = [];
119
+ };
120
+ for (const child of node.children ?? []) {
121
+ if (child.type === 'image') {
122
+ flush();
123
+ const image = imageBlock(child);
124
+ if (image)
125
+ blocks.push(image);
126
+ }
127
+ else
128
+ run.push(child);
129
+ }
130
+ flush();
131
+ return blocks;
132
+ }
133
+ function listBlock(node, extra) {
134
+ const isCheck = (node.children ?? []).some((item) => typeof item.checked === 'boolean');
135
+ const listType = isCheck ? 'check' : node.ordered ? 'number' : 'bullet';
136
+ const items = [];
137
+ for (const item of node.children ?? []) {
138
+ const textParts = [];
139
+ const flush = () => {
140
+ if (textParts.length === 0)
141
+ return;
142
+ const children = [];
143
+ textParts.forEach((p, i) => {
144
+ if (i > 0)
145
+ children.push({ type: 'linebreak', version: 1 });
146
+ children.push(...inlineOf(p.children ?? []));
147
+ });
148
+ items.push(elementBase('listitem', children, { value: 1, ...(isCheck ? { checked: item.checked === true } : {}) }));
149
+ textParts.length = 0;
150
+ };
151
+ for (const child of item.children ?? []) {
152
+ if (child.type === 'list') {
153
+ flush();
154
+ items.push(elementBase('listitem', [listBlock(child, {})], { value: 1 }));
155
+ }
156
+ else if (child.type === 'paragraph')
157
+ textParts.push(child);
158
+ else if (child.children)
159
+ textParts.push({ type: 'paragraph', children: child.children });
160
+ }
161
+ flush();
162
+ }
163
+ return elementBase('list', items, { listType, start: node.ordered ? node.start ?? 1 : 1, tag: node.ordered ? 'ol' : 'ul', ...extra });
164
+ }
165
+ function tableBlock(node) {
166
+ const rows = (node.children ?? []).map((row, rowIndex) => elementBase('tablerow', (row.children ?? []).map((cell) => elementBase('tablecell', [paragraph(inlineOf(cell.children ?? []))], {
167
+ headerState: rowIndex === 0 ? 1 : 0, colSpan: 1, rowSpan: 1, backgroundColor: null, width: null,
168
+ })), { height: null }));
169
+ return elementBase('table', rows);
170
+ }
171
+ function blockExtras(attrs) {
172
+ const extra = {};
173
+ if (attrs.align && ALIGNMENTS.has(attrs.align))
174
+ extra.format = attrs.align;
175
+ if (attrs.dir === 'rtl' || attrs.dir === 'ltr')
176
+ extra.direction = attrs.dir;
177
+ const indent = Number(attrs.indent);
178
+ if (Number.isInteger(indent) && indent > 0)
179
+ extra.indent = Math.min(indent, 10);
180
+ return extra;
181
+ }
182
+ function dim(value) {
183
+ const n = Number(value);
184
+ return Number.isFinite(n) && n > 0 ? Math.round(n) : null;
185
+ }
186
+ function blocks(nodes, extra = {}) {
187
+ const out = [];
188
+ for (const node of nodes) {
189
+ switch (node.type) {
190
+ case 'paragraph':
191
+ out.push(...paragraphBlocks(node, extra));
192
+ break;
193
+ case 'heading':
194
+ out.push(elementBase('heading', inlineOf(node.children ?? []), { tag: `h${Math.min(6, Math.max(1, node.depth ?? 2))}`, ...extra }));
195
+ break;
196
+ case 'blockquote':
197
+ for (const child of node.children ?? []) {
198
+ if (child.type === 'paragraph')
199
+ out.push(elementBase('quote', inlineOf(child.children ?? []), extra));
200
+ else
201
+ out.push(...blocks([child]));
202
+ }
203
+ break;
204
+ case 'list':
205
+ out.push(listBlock(node, extra));
206
+ break;
207
+ case 'code': {
208
+ const lines = (node.value ?? '').split('\n');
209
+ const children = [];
210
+ lines.forEach((line, i) => {
211
+ if (i > 0)
212
+ children.push({ type: 'linebreak', version: 1 });
213
+ if (line)
214
+ children.push(textNode(line, FORMAT_CODE));
215
+ });
216
+ out.push(paragraph(children, extra));
217
+ break;
218
+ }
219
+ case 'thematicBreak':
220
+ out.push({ type: 'horizontalrule', version: 1 });
221
+ break;
222
+ case 'table':
223
+ out.push(tableBlock(node));
224
+ break;
225
+ case 'containerDirective':
226
+ // Unknown containers are transparent: their content is kept.
227
+ out.push(...blocks(node.children ?? [], node.name === 'para' ? blockExtras(node.attributes ?? {}) : extra));
228
+ break;
229
+ case 'leafDirective': {
230
+ if (node.name === 'pagebreak')
231
+ out.push({ type: 'page-break', version: 1 });
232
+ else if (node.name === 'figure') {
233
+ const attrs = node.attributes ?? {};
234
+ const src = safeImageSrc(attrs.src ?? '');
235
+ if (src) {
236
+ const caption = inlineOf(node.children ?? []).map((n) => String(n.text ?? '')).join('');
237
+ out.push({
238
+ type: 'image', version: 1, src, altText: attrs.alt ?? '', caption: caption || null,
239
+ linkType: /^data:/i.test(src) ? 'embedded' : 'linked', width: dim(attrs.width), height: dim(attrs.height),
240
+ });
241
+ }
242
+ }
243
+ break;
244
+ }
245
+ case 'html':
246
+ case 'definition':
247
+ case 'yaml':
248
+ break; // raw HTML, link definitions and front matter carry no editable content
249
+ default:
250
+ if (node.children)
251
+ out.push(...blocks(node.children, extra));
252
+ else if (node.value)
253
+ out.push(paragraph([textNode(node.value)], extra));
254
+ }
255
+ }
256
+ return out;
257
+ }
258
+ const KNOWN_TEXT_DIRECTIVES = new Set(['span', ...Object.keys(DIRECTIVE_FORMATS)]);
259
+ /**
260
+ * `remark-directive` reads any `:word` as a directive ("10:30", "a:b"), which
261
+ * would silently eat ordinary prose. Directives the dialect doesn't define
262
+ * are turned back into the literal source text.
263
+ */
264
+ function restoreUnknownDirectives(node, source) {
265
+ if (!node.children)
266
+ return;
267
+ node.children = node.children.map((child) => {
268
+ const c = child;
269
+ const unknownText = c.type === 'textDirective' && !KNOWN_TEXT_DIRECTIVES.has(c.name ?? '');
270
+ const unknownLeaf = c.type === 'leafDirective' && c.name !== 'pagebreak' && c.name !== 'figure';
271
+ if ((unknownText || unknownLeaf) && c.position?.start.offset !== undefined && c.position.end.offset !== undefined) {
272
+ const raw = source.slice(c.position.start.offset, c.position.end.offset);
273
+ return unknownLeaf ? { type: 'paragraph', children: [{ type: 'text', value: raw }] } : { type: 'text', value: raw };
274
+ }
275
+ restoreUnknownDirectives(c, source);
276
+ return c;
277
+ });
278
+ }
279
+ export function parseMarkdown(input, _ctx) {
280
+ const tree = processor.parse(input);
281
+ restoreUnknownDirectives(tree, input);
282
+ return makeState(blocks(tree.children ?? []));
283
+ }
@@ -0,0 +1,9 @@
1
+ import type { SerializedEditorState } from 'lexical';
2
+ import type { ConverterContext } from '../types.js';
3
+ import { type Attrs } from './mdast.js';
4
+ /** Known presentation properties become readable attributes; anything else is kept verbatim in `style`. */
5
+ export declare function styleToAttrs(style: string): Attrs;
6
+ /** First strong character decides the direction a reader would infer without a hint. */
7
+ export declare function autoDirection(text: string): 'ltr' | 'rtl' | null;
8
+ export declare function serializeMarkdown(state: SerializedEditorState, ctx?: ConverterContext): string;
9
+ //# sourceMappingURL=serialize.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"serialize.d.ts","sourceRoot":"","sources":["../../src/markdown/serialize.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,SAAS,CAAC;AACrD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,UAAU,CAAC;AAKjD,OAAO,EAAa,KAAK,KAAK,EAAe,MAAM,SAAS,CAAC;AAyC7D,2GAA2G;AAC3G,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,KAAK,CAUjD;AAiHD,wFAAwF;AACxF,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,KAAK,GAAG,KAAK,GAAG,IAAI,CAMhE;AA4GD,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,qBAAqB,EAAE,GAAG,CAAC,EAAE,gBAAgB,GAAG,MAAM,CAI9F"}
@@ -0,0 +1,289 @@
1
+ import { unified } from 'unified';
2
+ import remarkStringify from 'remark-stringify';
3
+ import remarkGfm from 'remark-gfm';
4
+ import remarkDirective from 'remark-directive';
5
+ import { FORMAT_BOLD, FORMAT_CODE, FORMAT_HIGHLIGHT, FORMAT_ITALIC, FORMAT_STRIKETHROUGH, FORMAT_SUBSCRIPT, FORMAT_SUPERSCRIPT, FORMAT_UNDERLINE, rootChildren, } from '../shared/serialized.js';
6
+ import { directive } from './mdast.js';
7
+ const processor = unified()
8
+ .use(remarkGfm)
9
+ .use(remarkDirective)
10
+ .use(remarkStringify, { bullet: '-', emphasis: '*', strong: '*', rule: '-', fences: true, listItemIndent: 'one', incrementListMarker: true });
11
+ const FORMAT_WRAPPERS = [
12
+ [FORMAT_BOLD, { key: 'strong', wrap: (c) => ({ type: 'strong', children: c }) }],
13
+ [FORMAT_ITALIC, { key: 'emphasis', wrap: (c) => ({ type: 'emphasis', children: c }) }],
14
+ [FORMAT_STRIKETHROUGH, { key: 'delete', wrap: (c) => ({ type: 'delete', children: c }) }],
15
+ [FORMAT_UNDERLINE, { key: 'u', wrap: (c) => directive('textDirective', 'u', {}, c) }],
16
+ [FORMAT_SUPERSCRIPT, { key: 'sup', wrap: (c) => directive('textDirective', 'sup', {}, c) }],
17
+ [FORMAT_SUBSCRIPT, { key: 'sub', wrap: (c) => directive('textDirective', 'sub', {}, c) }],
18
+ [FORMAT_HIGHLIGHT, { key: 'mark', wrap: (c) => directive('textDirective', 'mark', {}, c) }],
19
+ ];
20
+ const STYLE_ATTRS = [
21
+ ['font-family', 'font'],
22
+ ['font-size', 'size'],
23
+ ['color', 'color'],
24
+ ['background-color', 'bg'],
25
+ ];
26
+ function parseStyle(style) {
27
+ return style
28
+ .split(';')
29
+ .map((part) => part.trim())
30
+ .filter(Boolean)
31
+ .map((part) => {
32
+ const i = part.indexOf(':');
33
+ return [part.slice(0, i).trim().toLowerCase(), part.slice(i + 1).trim()];
34
+ })
35
+ .filter(([k, v]) => k && v);
36
+ }
37
+ /** Known presentation properties become readable attributes; anything else is kept verbatim in `style`. */
38
+ export function styleToAttrs(style) {
39
+ const attrs = {};
40
+ const rest = [];
41
+ for (const [prop, value] of parseStyle(style)) {
42
+ const mapped = STYLE_ATTRS.find(([p]) => p === prop);
43
+ if (mapped)
44
+ attrs[mapped[1]] = value;
45
+ else
46
+ rest.push(`${prop}: ${value}`);
47
+ }
48
+ if (rest.length > 0)
49
+ attrs.style = rest.join('; ');
50
+ return attrs;
51
+ }
52
+ function runFor(node) {
53
+ const format = Number(node.format ?? 0);
54
+ const text = String(node.text ?? '');
55
+ const wrappers = [];
56
+ const attrs = styleToAttrs(String(node.style ?? ''));
57
+ if (Object.keys(attrs).length > 0) {
58
+ wrappers.push({ key: `span:${JSON.stringify(attrs)}`, wrap: (c) => directive('textDirective', 'span', attrs, c) });
59
+ }
60
+ for (const [bit, wrapper] of FORMAT_WRAPPERS)
61
+ if (format & bit)
62
+ wrappers.push(wrapper);
63
+ const leaf = format & FORMAT_CODE && text.trim() !== '' ? { type: 'inlineCode', value: text } : { type: 'text', value: text };
64
+ return { wrappers, leaf };
65
+ }
66
+ const WRAPPER_TYPES = new Set(['strong', 'emphasis', 'delete', 'textDirective']);
67
+ /**
68
+ * Markdown emphasis must hug its text (`**bold** x`, never `** bold**`), so
69
+ * whitespace at a wrapper's edges is moved outside it. A wrapper holding only
70
+ * whitespace dissolves into plain text.
71
+ */
72
+ function hoistWhitespace(nodes) {
73
+ const out = [];
74
+ for (const node of nodes) {
75
+ if (!WRAPPER_TYPES.has(node.type) || !node.children) {
76
+ out.push(node);
77
+ continue;
78
+ }
79
+ const children = hoistWhitespace(node.children);
80
+ let lead = '';
81
+ let trail = '';
82
+ const first = children[0];
83
+ if (first?.type === 'text') {
84
+ lead = /^\s*/.exec(first.value ?? '')[0];
85
+ first.value = (first.value ?? '').slice(lead.length);
86
+ }
87
+ const last = children[children.length - 1];
88
+ if (last?.type === 'text') {
89
+ trail = /\s*$/.exec(last.value ?? '')[0];
90
+ last.value = (last.value ?? '').slice(0, (last.value ?? '').length - trail.length);
91
+ }
92
+ const kept = children.filter((c) => c.type !== 'text' || c.value !== '');
93
+ if (lead)
94
+ out.push({ type: 'text', value: lead });
95
+ if (kept.length > 0)
96
+ out.push({ ...node, children: kept });
97
+ if (trail)
98
+ out.push({ type: 'text', value: trail });
99
+ }
100
+ return out;
101
+ }
102
+ /** Nests consecutive runs sharing outer wrappers: `**a *b***`, not `**a****b**`. */
103
+ function buildInline(runs, depth = 0) {
104
+ const out = [];
105
+ let i = 0;
106
+ while (i < runs.length) {
107
+ const wrapper = runs[i].wrappers[depth];
108
+ if (!wrapper) {
109
+ out.push(runs[i].leaf);
110
+ i += 1;
111
+ continue;
112
+ }
113
+ let j = i;
114
+ while (j < runs.length && runs[j].wrappers[depth]?.key === wrapper.key)
115
+ j += 1;
116
+ out.push(wrapper.wrap(buildInline(runs.slice(i, j), depth + 1)));
117
+ i = j;
118
+ }
119
+ return out;
120
+ }
121
+ function mergeText(nodes) {
122
+ const out = [];
123
+ for (const node of nodes) {
124
+ const prev = out[out.length - 1];
125
+ if (node.type === 'text' && prev?.type === 'text')
126
+ prev.value = (prev.value ?? '') + (node.value ?? '');
127
+ else
128
+ out.push(node);
129
+ }
130
+ return out;
131
+ }
132
+ function inlineNodes(children, ctx) {
133
+ const out = [];
134
+ let pending = [];
135
+ const flush = () => {
136
+ if (pending.length > 0)
137
+ out.push(...mergeText(hoistWhitespace(buildInline(pending))));
138
+ pending = [];
139
+ };
140
+ for (const child of children) {
141
+ if (child.type === 'text') {
142
+ pending.push(runFor(child));
143
+ }
144
+ else if (child.type === 'tab') {
145
+ pending.push({ wrappers: [], leaf: { type: 'text', value: '\t' } });
146
+ }
147
+ else if (child.type === 'linebreak') {
148
+ flush();
149
+ out.push({ type: 'break' });
150
+ }
151
+ else if (child.type === 'link' || child.type === 'autolink') {
152
+ flush();
153
+ out.push({ type: 'link', url: String(child.url ?? ''), title: child.title ? String(child.title) : null, children: inlineNodes(child.children ?? [], ctx) });
154
+ }
155
+ else if (child.children) {
156
+ flush();
157
+ out.push(...inlineNodes(child.children, ctx));
158
+ }
159
+ }
160
+ flush();
161
+ return out;
162
+ }
163
+ // ---------------------------------------------------------------- blocks
164
+ /** First strong character decides the direction a reader would infer without a hint. */
165
+ export function autoDirection(text) {
166
+ for (const ch of text) {
167
+ if (/[֐-ࣿיִ-﷿ﹰ-]/.test(ch))
168
+ return 'rtl';
169
+ if (/[A-Za-zÀ-ɏ]/.test(ch))
170
+ return 'ltr';
171
+ }
172
+ return null;
173
+ }
174
+ function plainText(node) {
175
+ if (typeof node.text === 'string')
176
+ return node.text;
177
+ return (node.children ?? []).map(plainText).join('');
178
+ }
179
+ /** `:::para{align=… dir=… indent=…}` — only emitted for what the reader could not infer. */
180
+ function withBlockAttrs(node, block) {
181
+ const attrs = {};
182
+ const format = String(node.format ?? '');
183
+ if (format)
184
+ attrs.align = format;
185
+ const indent = Number(node.indent ?? 0);
186
+ if (indent > 0)
187
+ attrs.indent = String(indent);
188
+ const dir = node.direction === 'rtl' || node.direction === 'ltr' ? node.direction : null;
189
+ if (dir && dir !== (autoDirection(plainText(node)) ?? dir))
190
+ attrs.dir = dir;
191
+ if (Object.keys(attrs).length === 0)
192
+ return block;
193
+ return directive('containerDirective', 'para', attrs, [block]);
194
+ }
195
+ function paragraphOf(children, ctx) {
196
+ return { type: 'paragraph', children: inlineNodes(children, ctx) };
197
+ }
198
+ function listToMd(node, ctx) {
199
+ const listType = String(node.listType ?? 'bullet');
200
+ const items = [];
201
+ for (const item of node.children ?? []) {
202
+ const kids = item.children ?? [];
203
+ const nested = kids.length === 1 && kids[0].type === 'list';
204
+ if (nested) {
205
+ const sub = listToMd(kids[0], ctx);
206
+ const prev = items[items.length - 1];
207
+ if (prev)
208
+ prev.children.push(sub);
209
+ else
210
+ items.push({ type: 'listItem', spread: false, checked: null, children: [sub] });
211
+ continue;
212
+ }
213
+ items.push({
214
+ type: 'listItem',
215
+ spread: false,
216
+ checked: listType === 'check' ? !!item.checked : null,
217
+ children: [paragraphOf(kids, ctx)],
218
+ });
219
+ }
220
+ return { type: 'list', ordered: listType === 'number', start: listType === 'number' ? Number(node.start ?? 1) : null, spread: false, children: items };
221
+ }
222
+ function imageToMd(node, ctx) {
223
+ const raw = String(node.src ?? '');
224
+ const url = ctx?.resolveImageUrl ? ctx.resolveImageUrl(raw) : raw;
225
+ const alt = String(node.altText ?? '');
226
+ const caption = typeof node.caption === 'string' && node.caption !== '' ? node.caption : null;
227
+ if (caption === null && !node.width && !node.height) {
228
+ return { type: 'paragraph', children: [{ type: 'image', url, alt, title: null }] };
229
+ }
230
+ const attrs = { src: url };
231
+ if (alt)
232
+ attrs.alt = alt;
233
+ if (node.width)
234
+ attrs.width = String(node.width);
235
+ if (node.height)
236
+ attrs.height = String(node.height);
237
+ return directive('leafDirective', 'figure', attrs, caption ? [{ type: 'text', value: caption }] : []);
238
+ }
239
+ function tableToMd(node, ctx) {
240
+ const rows = (node.children ?? []).map((row) => ({
241
+ type: 'tableRow',
242
+ children: (row.children ?? []).map((cell) => {
243
+ // A cell's blocks are flattened into one inline run separated by line breaks.
244
+ const parts = [];
245
+ (cell.children ?? []).forEach((block, i) => {
246
+ if (i > 0)
247
+ parts.push({ type: 'html', value: '<br>' });
248
+ parts.push(...inlineNodes(block.children ?? [], ctx));
249
+ });
250
+ return { type: 'tableCell', children: parts };
251
+ }),
252
+ }));
253
+ const columns = Math.max(0, ...rows.map((r) => r.children.length));
254
+ return { type: 'table', align: Array.from({ length: columns }, () => null), children: rows };
255
+ }
256
+ function blockToMd(node, ctx) {
257
+ switch (node.type) {
258
+ case 'paragraph': {
259
+ const children = node.children ?? [];
260
+ if (children.length === 0)
261
+ return [];
262
+ return [withBlockAttrs(node, paragraphOf(children, ctx))];
263
+ }
264
+ case 'heading': {
265
+ const depth = Math.min(6, Math.max(1, Number(String(node.tag).slice(1)) || 2));
266
+ return [withBlockAttrs(node, { type: 'heading', depth, children: inlineNodes(node.children ?? [], ctx) })];
267
+ }
268
+ case 'quote':
269
+ return [withBlockAttrs(node, { type: 'blockquote', children: [paragraphOf(node.children ?? [], ctx)] })];
270
+ case 'list':
271
+ return [withBlockAttrs(node, listToMd(node, ctx))];
272
+ case 'horizontalrule':
273
+ return [{ type: 'thematicBreak' }];
274
+ case 'page-break':
275
+ return [directive('leafDirective', 'pagebreak', {})];
276
+ case 'image':
277
+ return [imageToMd(node, ctx)];
278
+ case 'table':
279
+ return [tableToMd(node, ctx)];
280
+ default:
281
+ // Fallback rule (dialect spec §6): unknown blocks keep their text as paragraphs.
282
+ return (node.children ?? []).flatMap((child) => blockToMd(child, ctx));
283
+ }
284
+ }
285
+ export function serializeMarkdown(state, ctx) {
286
+ const tree = { type: 'root', children: rootChildren(state).flatMap((n) => blockToMd(n, ctx)) };
287
+ // remark-stringify consumes mdast; our MdNode is a structural subset of it.
288
+ return String(processor.stringify(tree)).replace(/\n+$/, '\n').replace(/^\n+$/, '');
289
+ }
@@ -0,0 +1,31 @@
1
+ import type { SerializedEditorState } from 'lexical';
2
+ /**
3
+ * Loose structural view of Lexical's serialized nodes. Converters work on
4
+ * plain JSON so they stay free of the editor, the DOM-bound node classes and
5
+ * React (architecture doc §1).
6
+ */
7
+ export interface SNode {
8
+ type: string;
9
+ version?: number;
10
+ children?: SNode[];
11
+ [key: string]: unknown;
12
+ }
13
+ export declare const FORMAT_BOLD = 1;
14
+ export declare const FORMAT_ITALIC = 2;
15
+ export declare const FORMAT_STRIKETHROUGH = 4;
16
+ export declare const FORMAT_UNDERLINE = 8;
17
+ export declare const FORMAT_CODE = 16;
18
+ export declare const FORMAT_SUBSCRIPT = 32;
19
+ export declare const FORMAT_SUPERSCRIPT = 64;
20
+ export declare const FORMAT_HIGHLIGHT = 128;
21
+ /** Pixels Lexical's own DOM export uses per indent level. */
22
+ export declare const INDENT_PX = 40;
23
+ export type Direction = 'ltr' | 'rtl' | null;
24
+ export declare function rootChildren(state: SerializedEditorState): SNode[];
25
+ export declare function textNode(text: string, format?: number, style?: string): SNode;
26
+ export declare function elementBase(type: string, children: SNode[], extra?: Record<string, unknown>): SNode;
27
+ export declare function paragraph(children?: SNode[], extra?: Record<string, unknown>): SNode;
28
+ export declare function makeState(blocks: SNode[]): SerializedEditorState;
29
+ /** Inline node types: everything else is a block. */
30
+ export declare function isInlineNode(node: SNode): boolean;
31
+ //# sourceMappingURL=serialized.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"serialized.d.ts","sourceRoot":"","sources":["../../src/shared/serialized.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,SAAS,CAAC;AAErD;;;;GAIG;AACH,MAAM,WAAW,KAAK;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,KAAK,EAAE,CAAC;IACnB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED,eAAO,MAAM,WAAW,IAAI,CAAC;AAC7B,eAAO,MAAM,aAAa,IAAI,CAAC;AAC/B,eAAO,MAAM,oBAAoB,IAAI,CAAC;AACtC,eAAO,MAAM,gBAAgB,IAAI,CAAC;AAClC,eAAO,MAAM,WAAW,KAAK,CAAC;AAC9B,eAAO,MAAM,gBAAgB,KAAK,CAAC;AACnC,eAAO,MAAM,kBAAkB,KAAK,CAAC;AACrC,eAAO,MAAM,gBAAgB,MAAM,CAAC;AAEpC,6DAA6D;AAC7D,eAAO,MAAM,SAAS,KAAK,CAAC;AAE5B,MAAM,MAAM,SAAS,GAAG,KAAK,GAAG,KAAK,GAAG,IAAI,CAAC;AAE7C,wBAAgB,YAAY,CAAC,KAAK,EAAE,qBAAqB,GAAG,KAAK,EAAE,CAElE;AAED,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,SAAI,EAAE,KAAK,SAAK,GAAG,KAAK,CAEpE;AAED,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,KAAK,EAAE,EAAE,KAAK,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM,GAAG,KAAK,CAEvG;AAED,wBAAgB,SAAS,CAAC,QAAQ,GAAE,KAAK,EAAO,EAAE,KAAK,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM,GAAG,KAAK,CAE5F;AAED,wBAAgB,SAAS,CAAC,MAAM,EAAE,KAAK,EAAE,GAAG,qBAAqB,CAKhE;AAED,qDAAqD;AACrD,wBAAgB,YAAY,CAAC,IAAI,EAAE,KAAK,GAAG,OAAO,CAEjD"}
@@ -0,0 +1,32 @@
1
+ export const FORMAT_BOLD = 1;
2
+ export const FORMAT_ITALIC = 2;
3
+ export const FORMAT_STRIKETHROUGH = 4;
4
+ export const FORMAT_UNDERLINE = 8;
5
+ export const FORMAT_CODE = 16;
6
+ export const FORMAT_SUBSCRIPT = 32;
7
+ export const FORMAT_SUPERSCRIPT = 64;
8
+ export const FORMAT_HIGHLIGHT = 128;
9
+ /** Pixels Lexical's own DOM export uses per indent level. */
10
+ export const INDENT_PX = 40;
11
+ export function rootChildren(state) {
12
+ return (state.root.children ?? []);
13
+ }
14
+ export function textNode(text, format = 0, style = '') {
15
+ return { type: 'text', text, detail: 0, format, mode: 'normal', style, version: 1 };
16
+ }
17
+ export function elementBase(type, children, extra = {}) {
18
+ return { type, children, direction: null, format: '', indent: 0, version: 1, ...extra };
19
+ }
20
+ export function paragraph(children = [], extra = {}) {
21
+ return elementBase('paragraph', children, { textFormat: 0, textStyle: '', ...extra });
22
+ }
23
+ export function makeState(blocks) {
24
+ const children = blocks.length > 0 ? blocks : [paragraph()];
25
+ return {
26
+ root: { type: 'root', children, direction: null, format: '', indent: 0, version: 1 },
27
+ };
28
+ }
29
+ /** Inline node types: everything else is a block. */
30
+ export function isInlineNode(node) {
31
+ return node.type === 'text' || node.type === 'linebreak' || node.type === 'tab' || node.type === 'link' || node.type === 'autolink';
32
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inshapardaz/likhari-converters",
3
- "version": "0.1.10",
3
+ "version": "0.1.11-dev.105",
4
4
  "description": "Format transformers (Markdown/HTML/plain-text/Lexical JSON) against the canonical Lexical EditorState, isolated from UI.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -30,6 +30,11 @@
30
30
  "prepublishOnly": "npm run build"
31
31
  },
32
32
  "dependencies": {
33
- "lexical": "^0.20.0"
33
+ "lexical": "^0.20.0",
34
+ "remark-directive": "^4.0.0",
35
+ "remark-gfm": "^4.0.1",
36
+ "remark-parse": "^11.0.0",
37
+ "remark-stringify": "^11.0.0",
38
+ "unified": "^11.0.5"
34
39
  }
35
40
  }