@byline/richtext-lexical 3.6.0 → 3.8.0
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/field/markdown/lexical-to-markdown.d.ts +35 -0
- package/dist/field/markdown/lexical-to-markdown.js +270 -0
- package/dist/server.d.ts +12 -1
- package/dist/server.js +5 -1
- package/package.json +5 -5
- package/src/field/markdown/lexical-to-markdown.test.node.ts +333 -0
- package/src/field/markdown/lexical-to-markdown.ts +446 -0
- package/src/server.ts +27 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
export interface LexicalToMarkdownWarning {
|
|
9
|
+
kind: 'unknown-node' | 'unresolved-link' | 'empty-table';
|
|
10
|
+
detail: string;
|
|
11
|
+
}
|
|
12
|
+
export interface LexicalToMarkdownOptions {
|
|
13
|
+
/**
|
|
14
|
+
* Resolve an internal-link / inline-image relation to a public URL.
|
|
15
|
+
* Receives the node's flattened relation attributes (`targetCollectionPath`,
|
|
16
|
+
* `document.path`, …). Return `undefined` to fall back to the default
|
|
17
|
+
* `/${targetCollectionPath}/${document.path}` composition; the link is
|
|
18
|
+
* dropped (children kept) when no URL can be derived at all.
|
|
19
|
+
*/
|
|
20
|
+
resolveInternalUrl?: (attrs: {
|
|
21
|
+
targetDocumentId?: string;
|
|
22
|
+
targetCollectionPath?: string;
|
|
23
|
+
documentPath?: string;
|
|
24
|
+
}) => string | undefined;
|
|
25
|
+
}
|
|
26
|
+
export interface LexicalToMarkdownResult {
|
|
27
|
+
markdown: string;
|
|
28
|
+
warnings: LexicalToMarkdownWarning[];
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Serialize a stored Lexical `SerializedEditorState` (or its `root`) to a
|
|
32
|
+
* markdown string. Accepts the value as `unknown` because richtext leaves
|
|
33
|
+
* arrive untyped from storage; non-richtext shapes return an empty string.
|
|
34
|
+
*/
|
|
35
|
+
export declare function lexicalToMarkdown(state: unknown, options?: LexicalToMarkdownOptions): LexicalToMarkdownResult;
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
const IS_BOLD = 1;
|
|
2
|
+
const IS_ITALIC = 2;
|
|
3
|
+
const IS_STRIKETHROUGH = 4;
|
|
4
|
+
const IS_CODE = 16;
|
|
5
|
+
const ADMONITION_TO_GFM = {
|
|
6
|
+
note: 'NOTE',
|
|
7
|
+
tip: 'TIP',
|
|
8
|
+
warning: 'WARNING',
|
|
9
|
+
danger: 'CAUTION'
|
|
10
|
+
};
|
|
11
|
+
function lexicalToMarkdown(state, options = {}) {
|
|
12
|
+
const warnings = [];
|
|
13
|
+
const root = resolveRoot(state);
|
|
14
|
+
if (null == root) return {
|
|
15
|
+
markdown: '',
|
|
16
|
+
warnings
|
|
17
|
+
};
|
|
18
|
+
const blocks = serializeBlocks(root.children ?? [], {
|
|
19
|
+
options,
|
|
20
|
+
warnings
|
|
21
|
+
});
|
|
22
|
+
return {
|
|
23
|
+
markdown: joinBlocks(blocks),
|
|
24
|
+
warnings
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function resolveRoot(state) {
|
|
28
|
+
if (null == state) return null;
|
|
29
|
+
let value = state;
|
|
30
|
+
if ('string' == typeof value) try {
|
|
31
|
+
value = JSON.parse(value);
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
if ('object' != typeof value || null === value) return null;
|
|
36
|
+
const obj = value;
|
|
37
|
+
const root = obj.root ?? obj;
|
|
38
|
+
return 'root' === root.type ? root : null;
|
|
39
|
+
}
|
|
40
|
+
function serializeBlocks(nodes, ctx) {
|
|
41
|
+
const out = [];
|
|
42
|
+
for (const node of nodes){
|
|
43
|
+
const block = serializeBlock(node, ctx);
|
|
44
|
+
if (null != block && block.length > 0) out.push(block);
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
function joinBlocks(blocks) {
|
|
49
|
+
return blocks.join('\n\n');
|
|
50
|
+
}
|
|
51
|
+
function serializeBlock(node, ctx) {
|
|
52
|
+
switch(node.type){
|
|
53
|
+
case 'paragraph':
|
|
54
|
+
{
|
|
55
|
+
const text = serializeInline(node.children ?? [], ctx);
|
|
56
|
+
return text.trim().length > 0 ? text : null;
|
|
57
|
+
}
|
|
58
|
+
case 'heading':
|
|
59
|
+
{
|
|
60
|
+
const tag = 'string' == typeof node.tag ? node.tag : 'h2';
|
|
61
|
+
const level = Math.min(Math.max(Number(tag.slice(1)) || 2, 1), 6);
|
|
62
|
+
return `${'#'.repeat(level)} ${serializeInline(node.children ?? [], ctx)}`;
|
|
63
|
+
}
|
|
64
|
+
case 'list':
|
|
65
|
+
return serializeList(node, ctx, 0);
|
|
66
|
+
case 'quote':
|
|
67
|
+
{
|
|
68
|
+
const inner = serializeInline(node.children ?? [], ctx);
|
|
69
|
+
return prefixLines(inner, '> ');
|
|
70
|
+
}
|
|
71
|
+
case 'code':
|
|
72
|
+
return serializeCode(node);
|
|
73
|
+
case 'table':
|
|
74
|
+
return serializeTable(node, ctx);
|
|
75
|
+
case 'horizontalrule':
|
|
76
|
+
return '---';
|
|
77
|
+
case 'admonition':
|
|
78
|
+
return serializeAdmonition(node, ctx);
|
|
79
|
+
case 'youtube':
|
|
80
|
+
return 'string' == typeof node.videoID && node.videoID.length > 0 ? `[YouTube video](https://www.youtube.com/watch?v=${node.videoID})` : null;
|
|
81
|
+
case 'vimeo':
|
|
82
|
+
return 'string' == typeof node.videoID && node.videoID.length > 0 ? `[Vimeo video](https://vimeo.com/${node.videoID})` : null;
|
|
83
|
+
case 'layout-container':
|
|
84
|
+
return joinBlocks(serializeBlocks(node.children ?? [], ctx));
|
|
85
|
+
case 'layout-item':
|
|
86
|
+
return joinBlocks(serializeBlocks(node.children ?? [], ctx));
|
|
87
|
+
case 'inline-image':
|
|
88
|
+
return serializeImage(node, ctx);
|
|
89
|
+
default:
|
|
90
|
+
if (node.children && node.children.length > 0) {
|
|
91
|
+
ctx.warnings.push({
|
|
92
|
+
kind: 'unknown-node',
|
|
93
|
+
detail: `unknown block node '${node.type ?? '?'}' — serialized children only`
|
|
94
|
+
});
|
|
95
|
+
return joinBlocks(serializeBlocks(node.children, ctx));
|
|
96
|
+
}
|
|
97
|
+
ctx.warnings.push({
|
|
98
|
+
kind: 'unknown-node',
|
|
99
|
+
detail: `unknown leaf node '${node.type ?? '?'}' — dropped`
|
|
100
|
+
});
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function serializeList(node, ctx, depth) {
|
|
105
|
+
const listType = 'number' === node.listType ? 'number' : node.listType;
|
|
106
|
+
const lines = [];
|
|
107
|
+
let ordinal = 1;
|
|
108
|
+
for (const item of node.children ?? []){
|
|
109
|
+
if ('listitem' !== item.type) continue;
|
|
110
|
+
const inlineChildren = (item.children ?? []).filter((c)=>'list' !== c.type);
|
|
111
|
+
const nestedLists = (item.children ?? []).filter((c)=>'list' === c.type);
|
|
112
|
+
const marker = 'number' === listType ? `${ordinal}. ` : 'check' === listType ? `- [${true === item.checked ? 'x' : ' '}] ` : '- ';
|
|
113
|
+
ordinal += 1;
|
|
114
|
+
const text = serializeInline(inlineChildren, ctx);
|
|
115
|
+
const indent = ' '.repeat(depth);
|
|
116
|
+
if (text.trim().length > 0 || 0 === nestedLists.length) lines.push(`${indent}${marker}${text}`);
|
|
117
|
+
for (const nested of nestedLists)lines.push(serializeList(nested, ctx, depth + 1));
|
|
118
|
+
}
|
|
119
|
+
return lines.join('\n');
|
|
120
|
+
}
|
|
121
|
+
function serializeCode(node) {
|
|
122
|
+
const language = 'string' == typeof node.language ? node.language : '';
|
|
123
|
+
const parts = [];
|
|
124
|
+
for (const child of node.children ?? [])if ('linebreak' === child.type) parts.push('\n');
|
|
125
|
+
else if ('string' == typeof child.text) parts.push(child.text);
|
|
126
|
+
const body = parts.join('');
|
|
127
|
+
const longestRun = body.match(/`+/g)?.reduce((m, r)=>Math.max(m, r.length), 0) ?? 0;
|
|
128
|
+
const fence = '`'.repeat(Math.max(3, longestRun + 1));
|
|
129
|
+
return `${fence}${language}\n${body}\n${fence}`;
|
|
130
|
+
}
|
|
131
|
+
function serializeTable(node, ctx) {
|
|
132
|
+
const rows = (node.children ?? []).filter((c)=>'tablerow' === c.type);
|
|
133
|
+
if (0 === rows.length) {
|
|
134
|
+
ctx.warnings.push({
|
|
135
|
+
kind: 'empty-table',
|
|
136
|
+
detail: 'table with no rows dropped'
|
|
137
|
+
});
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
const toCells = (row)=>(row.children ?? []).filter((c)=>'tablecell' === c.type).map((cell)=>serializeInline(cell.children ?? [], ctx).replace(/\|/g, '\\|').trim());
|
|
141
|
+
const [first, ...rest] = rows;
|
|
142
|
+
const header = toCells(first);
|
|
143
|
+
const lines = [
|
|
144
|
+
`| ${header.join(' | ')} |`,
|
|
145
|
+
`| ${header.map(()=>'---').join(' | ')} |`,
|
|
146
|
+
...rest.map((row)=>`| ${toCells(row).join(' | ')} |`)
|
|
147
|
+
];
|
|
148
|
+
return lines.join('\n');
|
|
149
|
+
}
|
|
150
|
+
function serializeAdmonition(node, ctx) {
|
|
151
|
+
const gfmType = ADMONITION_TO_GFM[String(node.admonitionType ?? 'note')] ?? 'NOTE';
|
|
152
|
+
const title = 'string' == typeof node.title && node.title.length > 0 ? node.title : null;
|
|
153
|
+
const body = joinBlocks(serializeBlocks(node.children ?? [], ctx));
|
|
154
|
+
const parts = [
|
|
155
|
+
`[!${gfmType}]`
|
|
156
|
+
];
|
|
157
|
+
if (title) parts.push(`**${title}**`);
|
|
158
|
+
if (body.length > 0) parts.push(body);
|
|
159
|
+
return prefixLines(parts.join('\n\n'), '> ');
|
|
160
|
+
}
|
|
161
|
+
function serializeInline(nodes, ctx) {
|
|
162
|
+
const parts = [];
|
|
163
|
+
let i = 0;
|
|
164
|
+
while(i < nodes.length){
|
|
165
|
+
const node = nodes[i];
|
|
166
|
+
switch(node.type){
|
|
167
|
+
case 'text':
|
|
168
|
+
case 'code-highlight':
|
|
169
|
+
{
|
|
170
|
+
const format = Number(node.format ?? 0);
|
|
171
|
+
let text = String(node.text ?? '');
|
|
172
|
+
while(i + 1 < nodes.length && nodes[i + 1].type === node.type && Number(nodes[i + 1].format ?? 0) === format){
|
|
173
|
+
i += 1;
|
|
174
|
+
text += String(nodes[i].text ?? '');
|
|
175
|
+
}
|
|
176
|
+
parts.push(wrapFormats(text, format));
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
case 'linebreak':
|
|
180
|
+
parts.push('\\\n');
|
|
181
|
+
break;
|
|
182
|
+
case 'link':
|
|
183
|
+
case 'autolink':
|
|
184
|
+
parts.push(serializeLink(node, ctx));
|
|
185
|
+
break;
|
|
186
|
+
case 'inline-image':
|
|
187
|
+
parts.push(serializeImage(node, ctx));
|
|
188
|
+
break;
|
|
189
|
+
default:
|
|
190
|
+
if (node.children && node.children.length > 0) parts.push(serializeInline(node.children, ctx));
|
|
191
|
+
else if ('string' == typeof node.text) parts.push(escapeText(node.text));
|
|
192
|
+
else ctx.warnings.push({
|
|
193
|
+
kind: 'unknown-node',
|
|
194
|
+
detail: `unknown inline node '${node.type ?? '?'}' — dropped`
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
i += 1;
|
|
198
|
+
}
|
|
199
|
+
return parts.join('');
|
|
200
|
+
}
|
|
201
|
+
function wrapFormats(rawText, format) {
|
|
202
|
+
if (0 === rawText.length) return '';
|
|
203
|
+
if (format & IS_CODE) {
|
|
204
|
+
const longestRun = rawText.match(/`+/g)?.reduce((m, r)=>Math.max(m, r.length), 0) ?? 0;
|
|
205
|
+
const fence = '`'.repeat(longestRun + 1);
|
|
206
|
+
return `${fence}${rawText}${fence}`;
|
|
207
|
+
}
|
|
208
|
+
const leading = rawText.match(/^\s*/)?.[0] ?? '';
|
|
209
|
+
const trailing = rawText.match(/\s*$/)?.[0] ?? '';
|
|
210
|
+
const core = rawText.slice(leading.length, rawText.length - trailing.length);
|
|
211
|
+
if (0 === core.length) return rawText;
|
|
212
|
+
let text = escapeText(core);
|
|
213
|
+
if (format & IS_BOLD) text = `**${text}**`;
|
|
214
|
+
if (format & IS_ITALIC) text = `*${text}*`;
|
|
215
|
+
if (format & IS_STRIKETHROUGH) text = `~~${text}~~`;
|
|
216
|
+
return `${leading}${text}${trailing}`;
|
|
217
|
+
}
|
|
218
|
+
function escapeText(text) {
|
|
219
|
+
return text.replace(/([\\`*_[\]])/g, '\\$1');
|
|
220
|
+
}
|
|
221
|
+
function serializeLink(node, ctx) {
|
|
222
|
+
const attrs = node.attributes ?? {};
|
|
223
|
+
const text = serializeInline(node.children ?? [], ctx);
|
|
224
|
+
const url = resolveLinkUrl(attrs, ctx);
|
|
225
|
+
if (null == url) return text;
|
|
226
|
+
return `[${text}](${url})`;
|
|
227
|
+
}
|
|
228
|
+
function resolveLinkUrl(attrs, ctx) {
|
|
229
|
+
if ('internal' === attrs.linkType) {
|
|
230
|
+
const document = attrs.document ?? {};
|
|
231
|
+
if (false === document._resolved) {
|
|
232
|
+
ctx.warnings.push({
|
|
233
|
+
kind: 'unresolved-link',
|
|
234
|
+
detail: `internal link to ${String(attrs.targetDocumentId ?? '?')} unresolved`
|
|
235
|
+
});
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
const documentPath = 'string' == typeof document.path ? document.path : void 0;
|
|
239
|
+
const custom = ctx.options.resolveInternalUrl?.({
|
|
240
|
+
targetDocumentId: 'string' == typeof attrs.targetDocumentId ? attrs.targetDocumentId : void 0,
|
|
241
|
+
targetCollectionPath: 'string' == typeof attrs.targetCollectionPath ? attrs.targetCollectionPath : void 0,
|
|
242
|
+
documentPath
|
|
243
|
+
});
|
|
244
|
+
if (null != custom) return custom;
|
|
245
|
+
if (null == documentPath) return null;
|
|
246
|
+
if (documentPath.startsWith('/')) return documentPath;
|
|
247
|
+
if ('string' == typeof attrs.targetCollectionPath && attrs.targetCollectionPath.length > 0) return `/${attrs.targetCollectionPath}/${documentPath}`;
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
return 'string' == typeof attrs.url && attrs.url.length > 0 ? attrs.url : null;
|
|
251
|
+
}
|
|
252
|
+
function serializeImage(node, ctx) {
|
|
253
|
+
const src = 'string' == typeof node.src ? node.src : '';
|
|
254
|
+
if (0 === src.length) return '';
|
|
255
|
+
const alt = 'string' == typeof node.altText ? node.altText : '';
|
|
256
|
+
const image = `![${alt.replace(/[[\]]/g, '')}](${src})`;
|
|
257
|
+
const caption = node.caption;
|
|
258
|
+
if (true === node.showCaption && caption?.editorState != null) {
|
|
259
|
+
const captionRoot = resolveRoot(caption.editorState);
|
|
260
|
+
if (null != captionRoot) {
|
|
261
|
+
const captionText = serializeBlocks(captionRoot.children ?? [], ctx).join(' ').trim();
|
|
262
|
+
if (captionText.length > 0) return `${image}\n*${captionText}*`;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return image;
|
|
266
|
+
}
|
|
267
|
+
function prefixLines(text, prefix) {
|
|
268
|
+
return text.split('\n').map((line)=>line.length > 0 ? `${prefix}${line}` : prefix.trimEnd()).join('\n');
|
|
269
|
+
}
|
|
270
|
+
export { lexicalToMarkdown };
|
package/dist/server.d.ts
CHANGED
|
@@ -36,9 +36,11 @@
|
|
|
36
36
|
* ```
|
|
37
37
|
*/
|
|
38
38
|
import type { BylineClient } from '@byline/client';
|
|
39
|
-
import type { RichTextEmbedFn, RichTextPopulateFn } from '@byline/core';
|
|
39
|
+
import type { RichTextEmbedFn, RichTextPopulateFn, RichTextToMarkdownFn } from '@byline/core';
|
|
40
40
|
import { type LexicalNodeVisitor } from './field/lexical-populate-shared';
|
|
41
41
|
export { defaultEditorConfig } from './field/config/default';
|
|
42
|
+
export { type LexicalToMarkdownOptions, type LexicalToMarkdownResult, type LexicalToMarkdownWarning, lexicalToMarkdown, } from './field/markdown/lexical-to-markdown';
|
|
43
|
+
import { type LexicalToMarkdownOptions } from './field/markdown/lexical-to-markdown';
|
|
42
44
|
export { inlineImageVisitor } from './field/extensions/inline-image/populate';
|
|
43
45
|
export { linkVisitor } from './field/extensions/link/populate';
|
|
44
46
|
export type { EditorConfig, EditorSettings, EditorSettingsOverride } from './field/config/types';
|
|
@@ -86,4 +88,13 @@ export declare function lexicalEditorPopulateServer(options: LexicalServerOption
|
|
|
86
88
|
* per-leaf errors and leaves the leaf untouched on hard failure (branch
|
|
87
89
|
* C of docs/RICHTEXT-LINK-REFACTOR-STRATEGY.md § 3.3).
|
|
88
90
|
*/
|
|
91
|
+
/**
|
|
92
|
+
* One-way markdown serializer for the agent-readable export surface,
|
|
93
|
+
* shaped for `ServerConfig.fields.richText.toMarkdown`. The sibling of
|
|
94
|
+
* `lexicalEditorPopulateServer` / `lexicalEditorEmbedServer` — but pure
|
|
95
|
+
* and synchronous: it walks the stored editor JSON with
|
|
96
|
+
* `lexicalToMarkdown` and performs no reads. See that function's header
|
|
97
|
+
* for the dialect contract (GFM alerts, lossy-OK).
|
|
98
|
+
*/
|
|
99
|
+
export declare function lexicalEditorToMarkdownServer(options?: LexicalToMarkdownOptions): RichTextToMarkdownFn;
|
|
89
100
|
export declare function lexicalEditorEmbedServer(options: LexicalServerOptions): RichTextEmbedFn;
|
package/dist/server.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { inlineImageVisitor } from "./field/extensions/inline-image/populate.js";
|
|
2
2
|
import { linkVisitor } from "./field/extensions/link/populate.js";
|
|
3
3
|
import { runLexicalPopulate } from "./field/lexical-populate-shared.js";
|
|
4
|
+
import { lexicalToMarkdown } from "./field/markdown/lexical-to-markdown.js";
|
|
4
5
|
function lexicalEditorPopulateServer(options) {
|
|
5
6
|
const visitors = options.visitors ?? [
|
|
6
7
|
inlineImageVisitor,
|
|
@@ -17,6 +18,9 @@ function lexicalEditorPopulateServer(options) {
|
|
|
17
18
|
});
|
|
18
19
|
};
|
|
19
20
|
}
|
|
21
|
+
function lexicalEditorToMarkdownServer(options = {}) {
|
|
22
|
+
return (ctx)=>lexicalToMarkdown(ctx.value, options).markdown;
|
|
23
|
+
}
|
|
20
24
|
function lexicalEditorEmbedServer(options) {
|
|
21
25
|
const visitors = options.visitors ?? [
|
|
22
26
|
inlineImageVisitor,
|
|
@@ -34,4 +38,4 @@ function lexicalEditorEmbedServer(options) {
|
|
|
34
38
|
};
|
|
35
39
|
}
|
|
36
40
|
export { defaultEditorConfig } from "./field/config/default.js";
|
|
37
|
-
export { inlineImageVisitor, lexicalEditorEmbedServer, lexicalEditorPopulateServer, linkVisitor };
|
|
41
|
+
export { inlineImageVisitor, lexicalEditorEmbedServer, lexicalEditorPopulateServer, lexicalEditorToMarkdownServer, lexicalToMarkdown, linkVisitor };
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"private": false,
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MPL-2.0",
|
|
6
|
-
"version": "3.
|
|
6
|
+
"version": "3.8.0",
|
|
7
7
|
"engines": {
|
|
8
8
|
"node": ">=20.9.0"
|
|
9
9
|
},
|
|
@@ -77,10 +77,10 @@
|
|
|
77
77
|
"npm-run-all": "^4.1.5",
|
|
78
78
|
"prism-react-renderer": "^2.4.1",
|
|
79
79
|
"react-error-boundary": "^6.1.1",
|
|
80
|
-
"@byline/admin": "3.
|
|
81
|
-
"@byline/
|
|
82
|
-
"@byline/
|
|
83
|
-
"@byline/
|
|
80
|
+
"@byline/admin": "3.8.0",
|
|
81
|
+
"@byline/client": "3.8.0",
|
|
82
|
+
"@byline/ui": "3.8.0",
|
|
83
|
+
"@byline/core": "3.8.0"
|
|
84
84
|
},
|
|
85
85
|
"peerDependencies": {
|
|
86
86
|
"react": "^19.0.0",
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Contract tests for the one-way Lexical → markdown export serializer.
|
|
11
|
+
* The expected strings below ARE the format contract — agents build on
|
|
12
|
+
* this shape, so a change here is a consumer-visible format change and
|
|
13
|
+
* should be deliberate (see docs/TODO.md → "The output is a contract
|
|
14
|
+
* surface").
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, expect, it } from 'vitest'
|
|
18
|
+
|
|
19
|
+
import { lexicalToMarkdown } from './lexical-to-markdown'
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Fixture helpers — minimal stored-JSON shapes
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
const text = (t: string, format = 0) => ({
|
|
26
|
+
type: 'text',
|
|
27
|
+
text: t,
|
|
28
|
+
format,
|
|
29
|
+
detail: 0,
|
|
30
|
+
mode: 'normal',
|
|
31
|
+
style: '',
|
|
32
|
+
version: 1,
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
const paragraph = (...children: unknown[]) => ({
|
|
36
|
+
type: 'paragraph',
|
|
37
|
+
children,
|
|
38
|
+
direction: 'ltr',
|
|
39
|
+
format: '',
|
|
40
|
+
indent: 0,
|
|
41
|
+
version: 1,
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
const root = (...children: unknown[]) => ({
|
|
45
|
+
root: { type: 'root', children, direction: 'ltr', format: '', indent: 0, version: 1 },
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
const md = (state: unknown) => lexicalToMarkdown(state).markdown
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
describe('lexicalToMarkdown — blocks', () => {
|
|
53
|
+
it('serializes paragraphs separated by blank lines', () => {
|
|
54
|
+
expect(md(root(paragraph(text('First.')), paragraph(text('Second.'))))).toBe(
|
|
55
|
+
'First.\n\nSecond.'
|
|
56
|
+
)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('serializes headings h1–h6 from the tag property', () => {
|
|
60
|
+
const state = root(
|
|
61
|
+
{ type: 'heading', tag: 'h2', children: [text('Title')], version: 1 },
|
|
62
|
+
{ type: 'heading', tag: 'h4', children: [text('Sub')], version: 1 }
|
|
63
|
+
)
|
|
64
|
+
expect(md(state)).toBe('## Title\n\n#### Sub')
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('serializes bullet, numbered, and check lists', () => {
|
|
68
|
+
const item = (t: string, extra: Record<string, unknown> = {}) => ({
|
|
69
|
+
type: 'listitem',
|
|
70
|
+
children: [text(t)],
|
|
71
|
+
version: 1,
|
|
72
|
+
...extra,
|
|
73
|
+
})
|
|
74
|
+
expect(
|
|
75
|
+
md(root({ type: 'list', listType: 'bullet', children: [item('a'), item('b')], version: 1 }))
|
|
76
|
+
).toBe('- a\n- b')
|
|
77
|
+
expect(
|
|
78
|
+
md(root({ type: 'list', listType: 'number', children: [item('a'), item('b')], version: 1 }))
|
|
79
|
+
).toBe('1. a\n2. b')
|
|
80
|
+
expect(
|
|
81
|
+
md(
|
|
82
|
+
root({
|
|
83
|
+
type: 'list',
|
|
84
|
+
listType: 'check',
|
|
85
|
+
children: [item('done', { checked: true }), item('todo')],
|
|
86
|
+
version: 1,
|
|
87
|
+
})
|
|
88
|
+
)
|
|
89
|
+
).toBe('- [x] done\n- [ ] todo')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('serializes nested lists with indentation', () => {
|
|
93
|
+
const state = root({
|
|
94
|
+
type: 'list',
|
|
95
|
+
listType: 'bullet',
|
|
96
|
+
version: 1,
|
|
97
|
+
children: [
|
|
98
|
+
{
|
|
99
|
+
type: 'listitem',
|
|
100
|
+
version: 1,
|
|
101
|
+
children: [
|
|
102
|
+
text('outer'),
|
|
103
|
+
{
|
|
104
|
+
type: 'list',
|
|
105
|
+
listType: 'bullet',
|
|
106
|
+
version: 1,
|
|
107
|
+
children: [{ type: 'listitem', children: [text('inner')], version: 1 }],
|
|
108
|
+
},
|
|
109
|
+
],
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
})
|
|
113
|
+
expect(md(state)).toBe('- outer\n - inner')
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('serializes blockquotes', () => {
|
|
117
|
+
expect(md(root({ type: 'quote', children: [text('wise words')], version: 1 }))).toBe(
|
|
118
|
+
'> wise words'
|
|
119
|
+
)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('serializes code blocks with language and code-highlight children', () => {
|
|
123
|
+
const state = root({
|
|
124
|
+
type: 'code',
|
|
125
|
+
language: 'ts',
|
|
126
|
+
version: 1,
|
|
127
|
+
children: [
|
|
128
|
+
{ type: 'code-highlight', text: 'const a = 1', version: 1 },
|
|
129
|
+
{ type: 'linebreak', version: 1 },
|
|
130
|
+
{ type: 'code-highlight', text: 'const b = 2', version: 1 },
|
|
131
|
+
],
|
|
132
|
+
})
|
|
133
|
+
expect(md(state)).toBe('```ts\nconst a = 1\nconst b = 2\n```')
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('serializes tables as GFM pipes with the first row as header', () => {
|
|
137
|
+
const cell = (t: string, headerState = 0) => ({
|
|
138
|
+
type: 'tablecell',
|
|
139
|
+
headerState,
|
|
140
|
+
children: [paragraph(text(t))],
|
|
141
|
+
version: 1,
|
|
142
|
+
})
|
|
143
|
+
const row = (...cells: unknown[]) => ({ type: 'tablerow', children: cells, version: 1 })
|
|
144
|
+
const state = root({
|
|
145
|
+
type: 'table',
|
|
146
|
+
version: 1,
|
|
147
|
+
children: [row(cell('Name', 1), cell('Age', 1)), row(cell('Ada'), cell('36'))],
|
|
148
|
+
})
|
|
149
|
+
expect(md(state)).toBe('| Name | Age |\n| --- | --- |\n| Ada | 36 |')
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('serializes horizontal rules', () => {
|
|
153
|
+
expect(md(root(paragraph(text('a')), { type: 'horizontalrule', version: 1 }))).toBe('a\n\n---')
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('serializes admonitions as GFM alerts with bold title', () => {
|
|
157
|
+
const state = root({
|
|
158
|
+
type: 'admonition',
|
|
159
|
+
admonitionType: 'warning',
|
|
160
|
+
title: 'Careful',
|
|
161
|
+
version: 1,
|
|
162
|
+
children: [paragraph(text('Hot surface.'))],
|
|
163
|
+
})
|
|
164
|
+
expect(md(state)).toBe('> [!WARNING]\n>\n> **Careful**\n>\n> Hot surface.')
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('maps danger to CAUTION and omits an absent title', () => {
|
|
168
|
+
const state = root({
|
|
169
|
+
type: 'admonition',
|
|
170
|
+
admonitionType: 'danger',
|
|
171
|
+
title: '',
|
|
172
|
+
version: 1,
|
|
173
|
+
children: [paragraph(text('Boom.'))],
|
|
174
|
+
})
|
|
175
|
+
expect(md(state)).toBe('> [!CAUTION]\n>\n> Boom.')
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('serializes video embeds as links', () => {
|
|
179
|
+
expect(md(root({ type: 'youtube', videoID: 'abc123', version: 1 }))).toBe(
|
|
180
|
+
'[YouTube video](https://www.youtube.com/watch?v=abc123)'
|
|
181
|
+
)
|
|
182
|
+
expect(md(root({ type: 'vimeo', videoID: '987', version: 1 }))).toBe(
|
|
183
|
+
'[Vimeo video](https://vimeo.com/987)'
|
|
184
|
+
)
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('flattens layout columns to stacked sections', () => {
|
|
188
|
+
const state = root({
|
|
189
|
+
type: 'layout-container',
|
|
190
|
+
templateColumns: '1fr 1fr',
|
|
191
|
+
version: 1,
|
|
192
|
+
children: [
|
|
193
|
+
{ type: 'layout-item', children: [paragraph(text('left'))], version: 1 },
|
|
194
|
+
{ type: 'layout-item', children: [paragraph(text('right'))], version: 1 },
|
|
195
|
+
],
|
|
196
|
+
})
|
|
197
|
+
expect(md(state)).toBe('left\n\nright')
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('serializes unknown block nodes via their children with a warning', () => {
|
|
201
|
+
const result = lexicalToMarkdown(
|
|
202
|
+
root({ type: 'future-node', children: [paragraph(text('still here'))], version: 1 })
|
|
203
|
+
)
|
|
204
|
+
expect(result.markdown).toBe('still here')
|
|
205
|
+
expect(result.warnings).toContainEqual(expect.objectContaining({ kind: 'unknown-node' }))
|
|
206
|
+
})
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
describe('lexicalToMarkdown — inline', () => {
|
|
210
|
+
it('decodes the format bitmask', () => {
|
|
211
|
+
expect(md(root(paragraph(text('bold', 1))))).toBe('**bold**')
|
|
212
|
+
expect(md(root(paragraph(text('italic', 2))))).toBe('*italic*')
|
|
213
|
+
expect(md(root(paragraph(text('both', 3))))).toBe('***both***')
|
|
214
|
+
expect(md(root(paragraph(text('struck', 4))))).toBe('~~struck~~')
|
|
215
|
+
expect(md(root(paragraph(text('code()', 16))))).toBe('`code()`')
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('drops underline/highlight wrappers but keeps the text (lossy-OK)', () => {
|
|
219
|
+
expect(md(root(paragraph(text('plain', 8))))).toBe('plain')
|
|
220
|
+
expect(md(root(paragraph(text('plain', 128))))).toBe('plain')
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('merges consecutive text nodes sharing a format', () => {
|
|
224
|
+
expect(md(root(paragraph(text('bo', 1), text('ld', 1), text(' plain'))))).toBe('**bold** plain')
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('hoists whitespace outside emphasis markers', () => {
|
|
228
|
+
expect(md(root(paragraph(text('a'), text(' spaced ', 1), text('b'))))).toBe('a **spaced** b')
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('escapes markdown-significant characters in plain text', () => {
|
|
232
|
+
expect(md(root(paragraph(text('2 * 3 [not a link]'))))).toBe('2 \\* 3 \\[not a link\\]')
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('does not escape inside inline code', () => {
|
|
236
|
+
expect(md(root(paragraph(text('a * b', 16))))).toBe('`a * b`')
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('serializes custom links', () => {
|
|
240
|
+
const link = {
|
|
241
|
+
type: 'link',
|
|
242
|
+
attributes: { linkType: 'custom', url: 'https://example.com' },
|
|
243
|
+
children: [text('Example')],
|
|
244
|
+
version: 1,
|
|
245
|
+
}
|
|
246
|
+
expect(md(root(paragraph(link)))).toBe('[Example](https://example.com)')
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it('composes internal link URLs from the embedded document envelope', () => {
|
|
250
|
+
const link = {
|
|
251
|
+
type: 'link',
|
|
252
|
+
attributes: {
|
|
253
|
+
linkType: 'internal',
|
|
254
|
+
targetDocumentId: 'doc-1',
|
|
255
|
+
targetCollectionPath: 'news',
|
|
256
|
+
document: { title: 'A Post', path: 'a-post', _resolved: true },
|
|
257
|
+
},
|
|
258
|
+
children: [text('A Post')],
|
|
259
|
+
version: 1,
|
|
260
|
+
}
|
|
261
|
+
expect(md(root(paragraph(link)))).toBe('[A Post](/news/a-post)')
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
it('prefers the resolveInternalUrl callback for internal links', () => {
|
|
265
|
+
const link = {
|
|
266
|
+
type: 'link',
|
|
267
|
+
attributes: {
|
|
268
|
+
linkType: 'internal',
|
|
269
|
+
targetCollectionPath: 'news',
|
|
270
|
+
document: { path: 'a-post', _resolved: true },
|
|
271
|
+
},
|
|
272
|
+
children: [text('A Post')],
|
|
273
|
+
version: 1,
|
|
274
|
+
}
|
|
275
|
+
const result = lexicalToMarkdown(root(paragraph(link)), {
|
|
276
|
+
resolveInternalUrl: ({ targetCollectionPath, documentPath }) =>
|
|
277
|
+
`https://example.org/${targetCollectionPath}/${documentPath}.md`,
|
|
278
|
+
})
|
|
279
|
+
expect(result.markdown).toBe('[A Post](https://example.org/news/a-post.md)')
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
it('keeps the text and drops the link when an internal target is unresolved', () => {
|
|
283
|
+
const link = {
|
|
284
|
+
type: 'link',
|
|
285
|
+
attributes: {
|
|
286
|
+
linkType: 'internal',
|
|
287
|
+
targetDocumentId: 'gone',
|
|
288
|
+
document: { _resolved: false },
|
|
289
|
+
},
|
|
290
|
+
children: [text('Missing')],
|
|
291
|
+
version: 1,
|
|
292
|
+
}
|
|
293
|
+
const result = lexicalToMarkdown(root(paragraph(link)))
|
|
294
|
+
expect(result.markdown).toBe('Missing')
|
|
295
|
+
expect(result.warnings).toContainEqual(expect.objectContaining({ kind: 'unresolved-link' }))
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
it('serializes inline images with alt text and flattened caption', () => {
|
|
299
|
+
const image = {
|
|
300
|
+
type: 'inline-image',
|
|
301
|
+
src: '/uploads/media/photo.avif',
|
|
302
|
+
altText: 'A photo',
|
|
303
|
+
showCaption: true,
|
|
304
|
+
caption: { editorState: root(paragraph(text('Taken in 2026.'))) },
|
|
305
|
+
version: 1,
|
|
306
|
+
}
|
|
307
|
+
expect(md(root(paragraph(image)))).toBe(
|
|
308
|
+
'\n*Taken in 2026.*'
|
|
309
|
+
)
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
it('serializes hard line breaks', () => {
|
|
313
|
+
expect(
|
|
314
|
+
md(root(paragraph(text('line one'), { type: 'linebreak', version: 1 }, text('line two'))))
|
|
315
|
+
).toBe('line one\\\nline two')
|
|
316
|
+
})
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
describe('lexicalToMarkdown — robustness', () => {
|
|
320
|
+
it('returns empty for null, non-richtext shapes, and bad JSON strings', () => {
|
|
321
|
+
expect(md(null)).toBe('')
|
|
322
|
+
expect(md({ not: 'lexical' })).toBe('')
|
|
323
|
+
expect(md('not json')).toBe('')
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
it('accepts a stringified editor state', () => {
|
|
327
|
+
expect(md(JSON.stringify(root(paragraph(text('parsed')))))).toBe('parsed')
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
it('skips empty paragraphs', () => {
|
|
331
|
+
expect(md(root(paragraph(), paragraph(text('only')), paragraph()))).toBe('only')
|
|
332
|
+
})
|
|
333
|
+
})
|
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* One-way Lexical → markdown serializer for the agent-readable export
|
|
11
|
+
* surface (`.md` routes, `llms.txt` — see docs/TODO.md → markdown export).
|
|
12
|
+
*
|
|
13
|
+
* Walks the **stored** `SerializedEditorState` JSON directly — no
|
|
14
|
+
* `@lexical/headless`, no DOM, no node registration. Output is read-only
|
|
15
|
+
* and never re-imported, so *lossy is acceptable* by design: layout
|
|
16
|
+
* columns flatten to stacked sections, video embeds become links,
|
|
17
|
+
* underline/highlight/sub/superscript inline formats are dropped.
|
|
18
|
+
*
|
|
19
|
+
* This is deliberately NOT the editor's markdown source toggle
|
|
20
|
+
* (`./transformers.ts` / `BYLINE_TRANSFORMERS`), which needs bidirectional,
|
|
21
|
+
* lossless `:::`-dialect transformers running inside a Lexical editor.
|
|
22
|
+
* One asymmetry to know about: admonitions export as GFM alerts
|
|
23
|
+
* (`> [!NOTE]`), while the editor toggle and the docs importer
|
|
24
|
+
* (`apps/webapp/byline/scripts/lib/parse-markdown.ts`) speak the
|
|
25
|
+
* Docusaurus `:::type[Title]` dialect. GFM alerts are what GitHub,
|
|
26
|
+
* agents, and most renderers understand — the export optimises for them.
|
|
27
|
+
*
|
|
28
|
+
* Node coverage mirrors the render serializer
|
|
29
|
+
* (`apps/webapp/src/ui/byline/components/richtext-lexical/serialize/`):
|
|
30
|
+
* paragraph, heading, list (bullet/number/check, nested), quote, code
|
|
31
|
+
* (+ code-highlight/linebreak children), table (GFM pipes), link/autolink
|
|
32
|
+
* (custom + internal), horizontalrule, linebreak, text (format bitmask),
|
|
33
|
+
* admonition, inline-image (+ nested caption editor), youtube, vimeo,
|
|
34
|
+
* layout-container/layout-item (flattened). Unknown node types emit a
|
|
35
|
+
* warning and serialize their children, so new nodes degrade gracefully
|
|
36
|
+
* instead of disappearing.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
// Text format bitmask — Lexical convention, kept in sync with the render
|
|
40
|
+
// serializer's richtext-node-formats.ts and the importer's mdast mapper.
|
|
41
|
+
const IS_BOLD = 1
|
|
42
|
+
const IS_ITALIC = 1 << 1
|
|
43
|
+
const IS_STRIKETHROUGH = 1 << 2
|
|
44
|
+
// IS_UNDERLINE (1 << 3), IS_SUBSCRIPT (1 << 5), IS_SUPERSCRIPT (1 << 6),
|
|
45
|
+
// IS_HIGHLIGHT (1 << 7) have no portable markdown form — dropped (lossy-OK).
|
|
46
|
+
const IS_CODE = 1 << 4
|
|
47
|
+
|
|
48
|
+
type AnyNode = {
|
|
49
|
+
type?: string
|
|
50
|
+
children?: AnyNode[]
|
|
51
|
+
[k: string]: unknown
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface LexicalToMarkdownWarning {
|
|
55
|
+
kind: 'unknown-node' | 'unresolved-link' | 'empty-table'
|
|
56
|
+
detail: string
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface LexicalToMarkdownOptions {
|
|
60
|
+
/**
|
|
61
|
+
* Resolve an internal-link / inline-image relation to a public URL.
|
|
62
|
+
* Receives the node's flattened relation attributes (`targetCollectionPath`,
|
|
63
|
+
* `document.path`, …). Return `undefined` to fall back to the default
|
|
64
|
+
* `/${targetCollectionPath}/${document.path}` composition; the link is
|
|
65
|
+
* dropped (children kept) when no URL can be derived at all.
|
|
66
|
+
*/
|
|
67
|
+
resolveInternalUrl?: (attrs: {
|
|
68
|
+
targetDocumentId?: string
|
|
69
|
+
targetCollectionPath?: string
|
|
70
|
+
documentPath?: string
|
|
71
|
+
}) => string | undefined
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface LexicalToMarkdownResult {
|
|
75
|
+
markdown: string
|
|
76
|
+
warnings: LexicalToMarkdownWarning[]
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** GFM alert labels per Byline admonition type. */
|
|
80
|
+
const ADMONITION_TO_GFM: Record<string, string> = {
|
|
81
|
+
note: 'NOTE',
|
|
82
|
+
tip: 'TIP',
|
|
83
|
+
warning: 'WARNING',
|
|
84
|
+
danger: 'CAUTION',
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Serialize a stored Lexical `SerializedEditorState` (or its `root`) to a
|
|
89
|
+
* markdown string. Accepts the value as `unknown` because richtext leaves
|
|
90
|
+
* arrive untyped from storage; non-richtext shapes return an empty string.
|
|
91
|
+
*/
|
|
92
|
+
export function lexicalToMarkdown(
|
|
93
|
+
state: unknown,
|
|
94
|
+
options: LexicalToMarkdownOptions = {}
|
|
95
|
+
): LexicalToMarkdownResult {
|
|
96
|
+
const warnings: LexicalToMarkdownWarning[] = []
|
|
97
|
+
const root = resolveRoot(state)
|
|
98
|
+
if (root == null) return { markdown: '', warnings }
|
|
99
|
+
const blocks = serializeBlocks(root.children ?? [], { options, warnings })
|
|
100
|
+
return { markdown: joinBlocks(blocks), warnings }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
interface Ctx {
|
|
104
|
+
options: LexicalToMarkdownOptions
|
|
105
|
+
warnings: LexicalToMarkdownWarning[]
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function resolveRoot(state: unknown): AnyNode | null {
|
|
109
|
+
if (state == null) return null
|
|
110
|
+
let value: unknown = state
|
|
111
|
+
// Tolerate stringified editor state (older rows / defensive).
|
|
112
|
+
if (typeof value === 'string') {
|
|
113
|
+
try {
|
|
114
|
+
value = JSON.parse(value)
|
|
115
|
+
} catch {
|
|
116
|
+
return null
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (typeof value !== 'object' || value === null) return null
|
|
120
|
+
const obj = value as Record<string, unknown>
|
|
121
|
+
const root = (obj.root ?? obj) as AnyNode
|
|
122
|
+
return root.type === 'root' ? root : null
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Serialize a list of block-level nodes; empty results are dropped. */
|
|
126
|
+
function serializeBlocks(nodes: AnyNode[], ctx: Ctx): string[] {
|
|
127
|
+
const out: string[] = []
|
|
128
|
+
for (const node of nodes) {
|
|
129
|
+
const block = serializeBlock(node, ctx)
|
|
130
|
+
if (block != null && block.length > 0) out.push(block)
|
|
131
|
+
}
|
|
132
|
+
return out
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function joinBlocks(blocks: string[]): string {
|
|
136
|
+
return blocks.join('\n\n')
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function serializeBlock(node: AnyNode, ctx: Ctx): string | null {
|
|
140
|
+
switch (node.type) {
|
|
141
|
+
case 'paragraph': {
|
|
142
|
+
const text = serializeInline(node.children ?? [], ctx)
|
|
143
|
+
return text.trim().length > 0 ? text : null
|
|
144
|
+
}
|
|
145
|
+
case 'heading': {
|
|
146
|
+
const tag = typeof node.tag === 'string' ? node.tag : 'h2'
|
|
147
|
+
const level = Math.min(Math.max(Number(tag.slice(1)) || 2, 1), 6)
|
|
148
|
+
return `${'#'.repeat(level)} ${serializeInline(node.children ?? [], ctx)}`
|
|
149
|
+
}
|
|
150
|
+
case 'list':
|
|
151
|
+
return serializeList(node, ctx, 0)
|
|
152
|
+
case 'quote': {
|
|
153
|
+
const inner = serializeInline(node.children ?? [], ctx)
|
|
154
|
+
return prefixLines(inner, '> ')
|
|
155
|
+
}
|
|
156
|
+
case 'code':
|
|
157
|
+
return serializeCode(node)
|
|
158
|
+
case 'table':
|
|
159
|
+
return serializeTable(node, ctx)
|
|
160
|
+
case 'horizontalrule':
|
|
161
|
+
return '---'
|
|
162
|
+
case 'admonition':
|
|
163
|
+
return serializeAdmonition(node, ctx)
|
|
164
|
+
case 'youtube':
|
|
165
|
+
return typeof node.videoID === 'string' && node.videoID.length > 0
|
|
166
|
+
? `[YouTube video](https://www.youtube.com/watch?v=${node.videoID})`
|
|
167
|
+
: null
|
|
168
|
+
case 'vimeo':
|
|
169
|
+
return typeof node.videoID === 'string' && node.videoID.length > 0
|
|
170
|
+
? `[Vimeo video](https://vimeo.com/${node.videoID})`
|
|
171
|
+
: null
|
|
172
|
+
case 'layout-container':
|
|
173
|
+
// Columns flatten to stacked sections (lossy-OK by design).
|
|
174
|
+
return joinBlocks(serializeBlocks(node.children ?? [], ctx))
|
|
175
|
+
case 'layout-item':
|
|
176
|
+
return joinBlocks(serializeBlocks(node.children ?? [], ctx))
|
|
177
|
+
case 'inline-image':
|
|
178
|
+
// An image can appear at block position (sole child of the root).
|
|
179
|
+
return serializeImage(node, ctx)
|
|
180
|
+
default: {
|
|
181
|
+
if (node.children && node.children.length > 0) {
|
|
182
|
+
ctx.warnings.push({
|
|
183
|
+
kind: 'unknown-node',
|
|
184
|
+
detail: `unknown block node '${node.type ?? '?'}' — serialized children only`,
|
|
185
|
+
})
|
|
186
|
+
return joinBlocks(serializeBlocks(node.children, ctx))
|
|
187
|
+
}
|
|
188
|
+
ctx.warnings.push({
|
|
189
|
+
kind: 'unknown-node',
|
|
190
|
+
detail: `unknown leaf node '${node.type ?? '?'}' — dropped`,
|
|
191
|
+
})
|
|
192
|
+
return null
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
// Lists
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
function serializeList(node: AnyNode, ctx: Ctx, depth: number): string {
|
|
202
|
+
const listType = node.listType === 'number' ? 'number' : node.listType
|
|
203
|
+
const lines: string[] = []
|
|
204
|
+
let ordinal = 1
|
|
205
|
+
for (const item of node.children ?? []) {
|
|
206
|
+
if (item.type !== 'listitem') continue
|
|
207
|
+
// A list item whose children include a nested list renders the nested
|
|
208
|
+
// list on subsequent indented lines.
|
|
209
|
+
const inlineChildren = (item.children ?? []).filter((c) => c.type !== 'list')
|
|
210
|
+
const nestedLists = (item.children ?? []).filter((c) => c.type === 'list')
|
|
211
|
+
|
|
212
|
+
const marker =
|
|
213
|
+
listType === 'number'
|
|
214
|
+
? `${ordinal}. `
|
|
215
|
+
: listType === 'check'
|
|
216
|
+
? `- [${item.checked === true ? 'x' : ' '}] `
|
|
217
|
+
: '- '
|
|
218
|
+
ordinal += 1
|
|
219
|
+
|
|
220
|
+
const text = serializeInline(inlineChildren, ctx)
|
|
221
|
+
const indent = ' '.repeat(depth)
|
|
222
|
+
if (text.trim().length > 0 || nestedLists.length === 0) {
|
|
223
|
+
lines.push(`${indent}${marker}${text}`)
|
|
224
|
+
}
|
|
225
|
+
for (const nested of nestedLists) {
|
|
226
|
+
lines.push(serializeList(nested, ctx, depth + 1))
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return lines.join('\n')
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ---------------------------------------------------------------------------
|
|
233
|
+
// Code blocks
|
|
234
|
+
// ---------------------------------------------------------------------------
|
|
235
|
+
|
|
236
|
+
function serializeCode(node: AnyNode): string {
|
|
237
|
+
const language = typeof node.language === 'string' ? node.language : ''
|
|
238
|
+
const parts: string[] = []
|
|
239
|
+
for (const child of node.children ?? []) {
|
|
240
|
+
if (child.type === 'linebreak') {
|
|
241
|
+
parts.push('\n')
|
|
242
|
+
} else if (typeof child.text === 'string') {
|
|
243
|
+
// code-highlight and plain text children both carry `.text`.
|
|
244
|
+
parts.push(child.text)
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
const body = parts.join('')
|
|
248
|
+
// Grow the fence beyond any backtick run inside the code itself.
|
|
249
|
+
const longestRun = body.match(/`+/g)?.reduce((m, r) => Math.max(m, r.length), 0) ?? 0
|
|
250
|
+
const fence = '`'.repeat(Math.max(3, longestRun + 1))
|
|
251
|
+
return `${fence}${language}\n${body}\n${fence}`
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
// Tables (GFM pipes)
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
function serializeTable(node: AnyNode, ctx: Ctx): string | null {
|
|
259
|
+
const rows = (node.children ?? []).filter((c) => c.type === 'tablerow')
|
|
260
|
+
if (rows.length === 0) {
|
|
261
|
+
ctx.warnings.push({ kind: 'empty-table', detail: 'table with no rows dropped' })
|
|
262
|
+
return null
|
|
263
|
+
}
|
|
264
|
+
const toCells = (row: AnyNode): string[] =>
|
|
265
|
+
(row.children ?? [])
|
|
266
|
+
.filter((c) => c.type === 'tablecell')
|
|
267
|
+
.map((cell) =>
|
|
268
|
+
serializeInline(cell.children ?? [], ctx)
|
|
269
|
+
.replace(/\|/g, '\\|')
|
|
270
|
+
.trim()
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
const [first, ...rest] = rows
|
|
274
|
+
const header = toCells(first as AnyNode)
|
|
275
|
+
const lines = [
|
|
276
|
+
`| ${header.join(' | ')} |`,
|
|
277
|
+
`| ${header.map(() => '---').join(' | ')} |`,
|
|
278
|
+
...rest.map((row) => `| ${toCells(row).join(' | ')} |`),
|
|
279
|
+
]
|
|
280
|
+
return lines.join('\n')
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ---------------------------------------------------------------------------
|
|
284
|
+
// Admonitions → GFM alerts
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
function serializeAdmonition(node: AnyNode, ctx: Ctx): string {
|
|
288
|
+
const gfmType = ADMONITION_TO_GFM[String(node.admonitionType ?? 'note')] ?? 'NOTE'
|
|
289
|
+
const title = typeof node.title === 'string' && node.title.length > 0 ? node.title : null
|
|
290
|
+
const body = joinBlocks(serializeBlocks(node.children ?? [], ctx))
|
|
291
|
+
const parts = [`[!${gfmType}]`]
|
|
292
|
+
if (title) parts.push(`**${title}**`)
|
|
293
|
+
if (body.length > 0) parts.push(body)
|
|
294
|
+
return prefixLines(parts.join('\n\n'), '> ')
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ---------------------------------------------------------------------------
|
|
298
|
+
// Inline serialization
|
|
299
|
+
// ---------------------------------------------------------------------------
|
|
300
|
+
|
|
301
|
+
function serializeInline(nodes: AnyNode[], ctx: Ctx): string {
|
|
302
|
+
const parts: string[] = []
|
|
303
|
+
let i = 0
|
|
304
|
+
while (i < nodes.length) {
|
|
305
|
+
const node = nodes[i] as AnyNode
|
|
306
|
+
switch (node.type) {
|
|
307
|
+
case 'text':
|
|
308
|
+
case 'code-highlight': {
|
|
309
|
+
// Group consecutive text nodes sharing one format so `**bold**`
|
|
310
|
+
// doesn't fragment into `**bo****ld**`.
|
|
311
|
+
const format = Number(node.format ?? 0)
|
|
312
|
+
let text = String(node.text ?? '')
|
|
313
|
+
while (
|
|
314
|
+
i + 1 < nodes.length &&
|
|
315
|
+
(nodes[i + 1] as AnyNode).type === node.type &&
|
|
316
|
+
Number((nodes[i + 1] as AnyNode).format ?? 0) === format
|
|
317
|
+
) {
|
|
318
|
+
i += 1
|
|
319
|
+
text += String((nodes[i] as AnyNode).text ?? '')
|
|
320
|
+
}
|
|
321
|
+
parts.push(wrapFormats(text, format))
|
|
322
|
+
break
|
|
323
|
+
}
|
|
324
|
+
case 'linebreak':
|
|
325
|
+
parts.push('\\\n')
|
|
326
|
+
break
|
|
327
|
+
case 'link':
|
|
328
|
+
case 'autolink':
|
|
329
|
+
parts.push(serializeLink(node, ctx))
|
|
330
|
+
break
|
|
331
|
+
case 'inline-image':
|
|
332
|
+
parts.push(serializeImage(node, ctx))
|
|
333
|
+
break
|
|
334
|
+
default: {
|
|
335
|
+
if (node.children && node.children.length > 0) {
|
|
336
|
+
parts.push(serializeInline(node.children, ctx))
|
|
337
|
+
} else if (typeof node.text === 'string') {
|
|
338
|
+
parts.push(escapeText(node.text))
|
|
339
|
+
} else {
|
|
340
|
+
ctx.warnings.push({
|
|
341
|
+
kind: 'unknown-node',
|
|
342
|
+
detail: `unknown inline node '${node.type ?? '?'}' — dropped`,
|
|
343
|
+
})
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
i += 1
|
|
348
|
+
}
|
|
349
|
+
return parts.join('')
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function wrapFormats(rawText: string, format: number): string {
|
|
353
|
+
if (rawText.length === 0) return ''
|
|
354
|
+
// Inline code suppresses every other wrapper and is not escaped.
|
|
355
|
+
if (format & IS_CODE) {
|
|
356
|
+
const longestRun = rawText.match(/`+/g)?.reduce((m, r) => Math.max(m, r.length), 0) ?? 0
|
|
357
|
+
const fence = '`'.repeat(longestRun + 1)
|
|
358
|
+
return `${fence}${rawText}${fence}`
|
|
359
|
+
}
|
|
360
|
+
// Markdown emphasis does not survive leading/trailing whitespace inside
|
|
361
|
+
// the markers — hoist it outside.
|
|
362
|
+
const leading = rawText.match(/^\s*/)?.[0] ?? ''
|
|
363
|
+
const trailing = rawText.match(/\s*$/)?.[0] ?? ''
|
|
364
|
+
const core = rawText.slice(leading.length, rawText.length - trailing.length)
|
|
365
|
+
if (core.length === 0) return rawText
|
|
366
|
+
let text = escapeText(core)
|
|
367
|
+
if (format & IS_BOLD) text = `**${text}**`
|
|
368
|
+
if (format & IS_ITALIC) text = `*${text}*`
|
|
369
|
+
if (format & IS_STRIKETHROUGH) text = `~~${text}~~`
|
|
370
|
+
return `${leading}${text}${trailing}`
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Conservative escaping of characters that would otherwise activate
|
|
375
|
+
* markdown syntax mid-sentence. Deliberately light — over-escaping makes
|
|
376
|
+
* the output unreadable, and the export is lossy-by-contract.
|
|
377
|
+
*/
|
|
378
|
+
function escapeText(text: string): string {
|
|
379
|
+
return text.replace(/([\\`*_[\]])/g, '\\$1')
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function serializeLink(node: AnyNode, ctx: Ctx): string {
|
|
383
|
+
const attrs = (node.attributes ?? {}) as Record<string, unknown>
|
|
384
|
+
const text = serializeInline(node.children ?? [], ctx)
|
|
385
|
+
const url = resolveLinkUrl(attrs, ctx)
|
|
386
|
+
if (url == null) {
|
|
387
|
+
// Unresolved / unresolvable internal link: keep the text, drop the link.
|
|
388
|
+
return text
|
|
389
|
+
}
|
|
390
|
+
return `[${text}](${url})`
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function resolveLinkUrl(attrs: Record<string, unknown>, ctx: Ctx): string | null {
|
|
394
|
+
if (attrs.linkType === 'internal') {
|
|
395
|
+
const document = (attrs.document ?? {}) as Record<string, unknown>
|
|
396
|
+
if (document._resolved === false) {
|
|
397
|
+
ctx.warnings.push({
|
|
398
|
+
kind: 'unresolved-link',
|
|
399
|
+
detail: `internal link to ${String(attrs.targetDocumentId ?? '?')} unresolved`,
|
|
400
|
+
})
|
|
401
|
+
return null
|
|
402
|
+
}
|
|
403
|
+
const documentPath = typeof document.path === 'string' ? document.path : undefined
|
|
404
|
+
const custom = ctx.options.resolveInternalUrl?.({
|
|
405
|
+
targetDocumentId:
|
|
406
|
+
typeof attrs.targetDocumentId === 'string' ? attrs.targetDocumentId : undefined,
|
|
407
|
+
targetCollectionPath:
|
|
408
|
+
typeof attrs.targetCollectionPath === 'string' ? attrs.targetCollectionPath : undefined,
|
|
409
|
+
documentPath,
|
|
410
|
+
})
|
|
411
|
+
if (custom != null) return custom
|
|
412
|
+
if (documentPath == null) return null
|
|
413
|
+
if (documentPath.startsWith('/')) return documentPath
|
|
414
|
+
if (typeof attrs.targetCollectionPath === 'string' && attrs.targetCollectionPath.length > 0) {
|
|
415
|
+
return `/${attrs.targetCollectionPath}/${documentPath}`
|
|
416
|
+
}
|
|
417
|
+
return null
|
|
418
|
+
}
|
|
419
|
+
return typeof attrs.url === 'string' && attrs.url.length > 0 ? attrs.url : null
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function serializeImage(node: AnyNode, ctx: Ctx): string {
|
|
423
|
+
const src = typeof node.src === 'string' ? node.src : ''
|
|
424
|
+
if (src.length === 0) return ''
|
|
425
|
+
const alt = typeof node.altText === 'string' ? node.altText : ''
|
|
426
|
+
const image = `![${alt.replace(/[[\]]/g, '')}](${src})`
|
|
427
|
+
// The caption is a nested SerializedEditor — flatten to emphasized text.
|
|
428
|
+
const caption = node.caption as { editorState?: unknown } | undefined
|
|
429
|
+
if (node.showCaption === true && caption?.editorState != null) {
|
|
430
|
+
const captionRoot = resolveRoot(caption.editorState)
|
|
431
|
+
if (captionRoot != null) {
|
|
432
|
+
const captionText = serializeBlocks(captionRoot.children ?? [], ctx)
|
|
433
|
+
.join(' ')
|
|
434
|
+
.trim()
|
|
435
|
+
if (captionText.length > 0) return `${image}\n*${captionText}*`
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
return image
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function prefixLines(text: string, prefix: string): string {
|
|
442
|
+
return text
|
|
443
|
+
.split('\n')
|
|
444
|
+
.map((line) => (line.length > 0 ? `${prefix}${line}` : prefix.trimEnd()))
|
|
445
|
+
.join('\n')
|
|
446
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -43,6 +43,7 @@ import type {
|
|
|
43
43
|
RichTextEmbedFn,
|
|
44
44
|
RichTextPopulateContext,
|
|
45
45
|
RichTextPopulateFn,
|
|
46
|
+
RichTextToMarkdownFn,
|
|
46
47
|
} from '@byline/core'
|
|
47
48
|
|
|
48
49
|
import { inlineImageVisitor } from './field/extensions/inline-image/populate'
|
|
@@ -56,6 +57,18 @@ import { type LexicalNodeVisitor, runLexicalPopulate } from './field/lexical-pop
|
|
|
56
57
|
// their CSS imports; the `/server` subpath stays React-free.
|
|
57
58
|
// ---------------------------------------------------------------------------
|
|
58
59
|
export { defaultEditorConfig } from './field/config/default'
|
|
60
|
+
export {
|
|
61
|
+
type LexicalToMarkdownOptions,
|
|
62
|
+
type LexicalToMarkdownResult,
|
|
63
|
+
type LexicalToMarkdownWarning,
|
|
64
|
+
lexicalToMarkdown,
|
|
65
|
+
} from './field/markdown/lexical-to-markdown'
|
|
66
|
+
|
|
67
|
+
import {
|
|
68
|
+
type LexicalToMarkdownOptions,
|
|
69
|
+
lexicalToMarkdown,
|
|
70
|
+
} from './field/markdown/lexical-to-markdown'
|
|
71
|
+
|
|
59
72
|
export { inlineImageVisitor } from './field/extensions/inline-image/populate'
|
|
60
73
|
export { linkVisitor } from './field/extensions/link/populate'
|
|
61
74
|
export type { EditorConfig, EditorSettings, EditorSettingsOverride } from './field/config/types'
|
|
@@ -120,6 +133,20 @@ export function lexicalEditorPopulateServer(options: LexicalServerOptions): Rich
|
|
|
120
133
|
* per-leaf errors and leaves the leaf untouched on hard failure (branch
|
|
121
134
|
* C of docs/RICHTEXT-LINK-REFACTOR-STRATEGY.md § 3.3).
|
|
122
135
|
*/
|
|
136
|
+
/**
|
|
137
|
+
* One-way markdown serializer for the agent-readable export surface,
|
|
138
|
+
* shaped for `ServerConfig.fields.richText.toMarkdown`. The sibling of
|
|
139
|
+
* `lexicalEditorPopulateServer` / `lexicalEditorEmbedServer` — but pure
|
|
140
|
+
* and synchronous: it walks the stored editor JSON with
|
|
141
|
+
* `lexicalToMarkdown` and performs no reads. See that function's header
|
|
142
|
+
* for the dialect contract (GFM alerts, lossy-OK).
|
|
143
|
+
*/
|
|
144
|
+
export function lexicalEditorToMarkdownServer(
|
|
145
|
+
options: LexicalToMarkdownOptions = {}
|
|
146
|
+
): RichTextToMarkdownFn {
|
|
147
|
+
return (ctx) => lexicalToMarkdown(ctx.value, options).markdown
|
|
148
|
+
}
|
|
149
|
+
|
|
123
150
|
export function lexicalEditorEmbedServer(options: LexicalServerOptions): RichTextEmbedFn {
|
|
124
151
|
const visitors = options.visitors ?? [inlineImageVisitor, linkVisitor]
|
|
125
152
|
return async (ctx: RichTextEmbedContext): Promise<void> => {
|