@inshapardaz/likhari-converters 0.1.12 → 0.1.13-dev.109
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/html/parse.d.ts +4 -0
- package/dist/html/parse.d.ts.map +1 -1
- package/dist/html/parse.js +2 -2
- package/dist/markdown/index.d.ts +3 -4
- package/dist/markdown/index.d.ts.map +1 -1
- package/dist/markdown/index.js +7 -10
- package/dist/markdown/mdast.d.ts +22 -0
- package/dist/markdown/mdast.d.ts.map +1 -0
- package/dist/markdown/mdast.js +3 -0
- package/dist/markdown/parse.d.ts +4 -0
- package/dist/markdown/parse.d.ts.map +1 -0
- package/dist/markdown/parse.js +283 -0
- package/dist/markdown/serialize.d.ts +9 -0
- package/dist/markdown/serialize.d.ts.map +1 -0
- package/dist/markdown/serialize.js +289 -0
- package/package.json +7 -2
package/dist/html/parse.d.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import type { SerializedEditorState } from 'lexical';
|
|
2
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;
|
|
3
7
|
export declare function parseHtml(input: string, _ctx?: ConverterContext): SerializedEditorState;
|
|
4
8
|
//# sourceMappingURL=parse.d.ts.map
|
package/dist/html/parse.d.ts.map
CHANGED
|
@@ -1 +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;
|
|
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"}
|
package/dist/html/parse.js
CHANGED
|
@@ -16,14 +16,14 @@ const BLOCK_TAGS = new Set([
|
|
|
16
16
|
'hr', 'pre', 'img', 'figcaption', 'dl', 'dt', 'dd', ...BLOCK_CONTAINERS,
|
|
17
17
|
]);
|
|
18
18
|
/** Link hrefs with an executable scheme are dropped on import. */
|
|
19
|
-
function safeUrl(url) {
|
|
19
|
+
export function safeUrl(url) {
|
|
20
20
|
const trimmed = url.trim();
|
|
21
21
|
if (/^(javascript|vbscript|data:text\/html)/i.test(trimmed.replace(/[\u0000- ]/g, '')))
|
|
22
22
|
return null;
|
|
23
23
|
return trimmed;
|
|
24
24
|
}
|
|
25
25
|
/** Image sources: only http(s), relative and base64 image data URIs. */
|
|
26
|
-
function safeImageSrc(src) {
|
|
26
|
+
export function safeImageSrc(src) {
|
|
27
27
|
const trimmed = src.trim();
|
|
28
28
|
if (!trimmed)
|
|
29
29
|
return null;
|
package/dist/markdown/index.d.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import type { FormatConverter } from '../types.js';
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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;
|
|
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"}
|
package/dist/markdown/index.js
CHANGED
|
@@ -1,15 +1,12 @@
|
|
|
1
|
+
import { serializeMarkdown } from './serialize.js';
|
|
2
|
+
import { parseMarkdown } from './parse.js';
|
|
1
3
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
-
|
|
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 @@
|
|
|
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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inshapardaz/likhari-converters",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.13-dev.109",
|
|
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
|
}
|