@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.
@@ -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.0",
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.6.0",
81
- "@byline/ui": "3.6.0",
82
- "@byline/core": "3.6.0",
83
- "@byline/client": "3.6.0"
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
+ '![A photo](/uploads/media/photo.avif)\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> => {