openwriter 0.1.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,405 @@
1
+ /**
2
+ * Markdown -> TipTap JSON parsing.
3
+ * Parses markdown (with optional YAML frontmatter) into TipTap document JSON.
4
+ */
5
+ import MarkdownIt from 'markdown-it';
6
+ import matter from 'gray-matter';
7
+ import markdownItIns from 'markdown-it-ins';
8
+ import markdownItMark from 'markdown-it-mark';
9
+ import markdownItSub from 'markdown-it-sub';
10
+ import markdownItSup from 'markdown-it-sup';
11
+ import { generateNodeId, LEAF_BLOCK_TYPES } from './helpers.js';
12
+ import { nodeText } from './markdown-serialize.js';
13
+ // ============================================================================
14
+ // Markdown -> TipTap
15
+ // ============================================================================
16
+ const md = new MarkdownIt({ linkify: false });
17
+ md.enable('strikethrough');
18
+ md.use(markdownItIns);
19
+ md.use(markdownItMark);
20
+ md.use(markdownItSub);
21
+ md.use(markdownItSup);
22
+ export function markdownToTiptap(markdown) {
23
+ const { data, content } = matter(markdown);
24
+ const title = data.title || 'Untitled';
25
+ const tokens = md.parse(content, {});
26
+ const docContent = tokensToTiptap(tokens);
27
+ const doc = {
28
+ type: 'doc',
29
+ content: docContent.length > 0 ? docContent : [{ type: 'paragraph', attrs: { id: generateNodeId() }, content: [] }],
30
+ };
31
+ // Rehydrate pending state from frontmatter into node attrs
32
+ if (data.pending) {
33
+ rehydratePendingState(doc, data.pending);
34
+ }
35
+ // Strip pending from returned metadata (consumed into node attrs)
36
+ const metadata = { ...data };
37
+ delete metadata.pending;
38
+ return { title, metadata, document: doc };
39
+ }
40
+ /**
41
+ * Rehydrate pending state from frontmatter into leaf block node attrs.
42
+ * Uses text fingerprint matching to survive position shifts caused by
43
+ * empty paragraphs disappearing during markdown round-trips.
44
+ */
45
+ function rehydratePendingState(doc, pending) {
46
+ // Build ordered list of leaf blocks with text fingerprints
47
+ const leaves = [];
48
+ function collect(nodes) {
49
+ if (!nodes)
50
+ return;
51
+ for (const node of nodes) {
52
+ if (LEAF_BLOCK_TYPES.has(node.type)) {
53
+ leaves.push({ node, text: nodeText(node) });
54
+ }
55
+ else if (node.content) {
56
+ collect(node.content);
57
+ }
58
+ }
59
+ }
60
+ collect(doc.content);
61
+ const used = new Set();
62
+ for (const [posStr, entry] of Object.entries(pending)) {
63
+ const pos = parseInt(posStr, 10);
64
+ let target = null;
65
+ // 1. Try position match (with text verification if fingerprint exists)
66
+ if (pos < leaves.length && !used.has(pos)) {
67
+ if (!entry.t || leaves[pos].text === entry.t) {
68
+ target = leaves[pos].node;
69
+ used.add(pos);
70
+ }
71
+ }
72
+ // 2. Fallback: search by text fingerprint
73
+ if (!target && entry.t) {
74
+ for (let i = 0; i < leaves.length; i++) {
75
+ if (!used.has(i) && leaves[i].text === entry.t) {
76
+ target = leaves[i].node;
77
+ used.add(i);
78
+ break;
79
+ }
80
+ }
81
+ }
82
+ if (target) {
83
+ target.attrs = target.attrs || {};
84
+ target.attrs.pendingStatus = entry.s;
85
+ if (entry.o) {
86
+ target.attrs.pendingOriginalContent = entry.o;
87
+ }
88
+ }
89
+ }
90
+ }
91
+ /** Parse a markdown string into TipTap block nodes (no frontmatter). */
92
+ export function markdownToNodes(markdown) {
93
+ const tokens = md.parse(markdown, {});
94
+ const nodes = tokensToTiptap(tokens);
95
+ return nodes.length > 0 ? nodes : [{ type: 'paragraph', attrs: { id: generateNodeId() }, content: [] }];
96
+ }
97
+ // ---- Token tree walker ----
98
+ function tokensToTiptap(tokens) {
99
+ const nodes = [];
100
+ let i = 0;
101
+ while (i < tokens.length) {
102
+ const token = tokens[i];
103
+ if (token.type === 'heading_open') {
104
+ const level = parseInt(token.tag.slice(1));
105
+ const inlineToken = tokens[i + 1];
106
+ const content = inlineToken?.children ? inlineTokensToTiptap(inlineToken.children) : [];
107
+ nodes.push({ type: 'heading', attrs: { id: generateNodeId(), level }, content });
108
+ i += 3;
109
+ }
110
+ else if (token.type === 'paragraph_open') {
111
+ const inlineToken = tokens[i + 1];
112
+ const content = inlineToken?.children ? inlineTokensToTiptap(inlineToken.children) : [];
113
+ // Check for solo image — promote to block-level image node
114
+ if (content.length === 1 && content[0].type === 'image') {
115
+ nodes.push(content[0]);
116
+ }
117
+ else {
118
+ nodes.push({ type: 'paragraph', attrs: { id: generateNodeId() }, content });
119
+ }
120
+ i += 3;
121
+ }
122
+ else if (token.type === 'bullet_list_open') {
123
+ const end = findClosingToken(tokens, i, 'bullet_list');
124
+ const items = parseListItems(tokens.slice(i + 1, end));
125
+ const listNode = { type: 'bulletList', attrs: { id: generateNodeId() }, content: items };
126
+ // Try converting to taskList if all items start with checkboxes
127
+ const taskNode = tryConvertToTaskList(listNode);
128
+ nodes.push(taskNode || listNode);
129
+ i = end + 1;
130
+ }
131
+ else if (token.type === 'ordered_list_open') {
132
+ const end = findClosingToken(tokens, i, 'ordered_list');
133
+ const items = parseListItems(tokens.slice(i + 1, end));
134
+ nodes.push({ type: 'orderedList', attrs: { id: generateNodeId() }, content: items });
135
+ i = end + 1;
136
+ }
137
+ else if (token.type === 'blockquote_open') {
138
+ const end = findClosingToken(tokens, i, 'blockquote');
139
+ const inner = tokensToTiptap(tokens.slice(i + 1, end));
140
+ nodes.push({ type: 'blockquote', attrs: { id: generateNodeId() }, content: inner });
141
+ i = end + 1;
142
+ }
143
+ else if (token.type === 'fence') {
144
+ const lang = token.info?.trim() || '';
145
+ const text = token.content.replace(/\n$/, '');
146
+ const content = text ? [{ type: 'text', text }] : [];
147
+ const attrs = { id: generateNodeId() };
148
+ if (lang)
149
+ attrs.language = lang;
150
+ nodes.push({ type: 'codeBlock', attrs, content });
151
+ i += 1;
152
+ }
153
+ else if (token.type === 'hr') {
154
+ nodes.push({ type: 'horizontalRule', attrs: { id: generateNodeId() } });
155
+ i += 1;
156
+ }
157
+ else if (token.type === 'table_open') {
158
+ const end = findClosingToken(tokens, i, 'table');
159
+ const tableNode = parseTableTokens(tokens.slice(i + 1, end));
160
+ nodes.push(tableNode);
161
+ i = end + 1;
162
+ }
163
+ else {
164
+ i += 1;
165
+ }
166
+ }
167
+ return nodes;
168
+ }
169
+ function findClosingToken(tokens, startIndex, type) {
170
+ let depth = 0;
171
+ for (let i = startIndex; i < tokens.length; i++) {
172
+ if (tokens[i].type === `${type}_open`)
173
+ depth++;
174
+ if (tokens[i].type === `${type}_close`) {
175
+ depth--;
176
+ if (depth === 0)
177
+ return i;
178
+ }
179
+ }
180
+ return tokens.length - 1;
181
+ }
182
+ function parseListItems(tokens) {
183
+ const items = [];
184
+ let i = 0;
185
+ while (i < tokens.length) {
186
+ if (tokens[i].type === 'list_item_open') {
187
+ const end = findClosingToken(tokens, i, 'list_item');
188
+ const inner = tokensToTiptap(tokens.slice(i + 1, end));
189
+ items.push({ type: 'listItem', attrs: { id: generateNodeId() }, content: inner });
190
+ i = end + 1;
191
+ }
192
+ else {
193
+ i++;
194
+ }
195
+ }
196
+ return items;
197
+ }
198
+ /**
199
+ * Post-process a bulletList: if every listItem starts with [ ] or [x],
200
+ * convert to taskList/taskItem nodes and strip the checkbox prefix.
201
+ */
202
+ function tryConvertToTaskList(bulletList) {
203
+ const items = bulletList.content;
204
+ if (!items || items.length === 0)
205
+ return null;
206
+ const checkboxRe = /^\[([ xX])\]\s?/;
207
+ const taskItems = [];
208
+ for (const item of items) {
209
+ const firstChild = item.content?.[0];
210
+ if (!firstChild || firstChild.type !== 'paragraph')
211
+ return null;
212
+ const firstText = firstChild.content?.[0];
213
+ if (!firstText || firstText.type !== 'text')
214
+ return null;
215
+ const match = checkboxRe.exec(firstText.text);
216
+ if (!match)
217
+ return null;
218
+ const checked = match[1] !== ' ';
219
+ // Strip checkbox prefix from text
220
+ const remaining = firstText.text.slice(match[0].length);
221
+ const newContent = [...firstChild.content];
222
+ if (remaining) {
223
+ newContent[0] = { ...firstText, text: remaining };
224
+ }
225
+ else {
226
+ newContent.shift();
227
+ }
228
+ const newParagraph = { ...firstChild, content: newContent };
229
+ const restChildren = item.content.slice(1);
230
+ taskItems.push({
231
+ type: 'taskItem',
232
+ attrs: { id: generateNodeId(), checked },
233
+ content: [newParagraph, ...restChildren],
234
+ });
235
+ }
236
+ return {
237
+ type: 'taskList',
238
+ attrs: { id: generateNodeId() },
239
+ content: taskItems,
240
+ };
241
+ }
242
+ function parseTableTokens(tokens) {
243
+ const rows = [];
244
+ let i = 0;
245
+ while (i < tokens.length) {
246
+ const token = tokens[i];
247
+ if (token.type === 'thead_open' || token.type === 'tbody_open') {
248
+ i++; // skip section open, process rows inside
249
+ continue;
250
+ }
251
+ if (token.type === 'thead_close' || token.type === 'tbody_close') {
252
+ i++;
253
+ continue;
254
+ }
255
+ if (token.type === 'tr_open') {
256
+ const trEnd = findClosingToken(tokens, i, 'tr');
257
+ const cells = [];
258
+ let j = i + 1;
259
+ while (j < trEnd) {
260
+ const cellToken = tokens[j];
261
+ if (cellToken.type === 'th_open' || cellToken.type === 'td_open') {
262
+ const cellType = cellToken.type === 'th_open' ? 'tableHeader' : 'tableCell';
263
+ const cellEnd = findClosingToken(tokens, j, cellToken.type === 'th_open' ? 'th' : 'td');
264
+ // Inline content is between open and close
265
+ let content = [];
266
+ if (j + 1 < cellEnd && tokens[j + 1].type === 'inline') {
267
+ content = tokens[j + 1].children ? inlineTokensToTiptap(tokens[j + 1].children) : [];
268
+ }
269
+ cells.push({
270
+ type: cellType,
271
+ attrs: { id: generateNodeId() },
272
+ content: [{
273
+ type: 'paragraph',
274
+ attrs: { id: generateNodeId() },
275
+ content,
276
+ }],
277
+ });
278
+ j = cellEnd + 1;
279
+ }
280
+ else {
281
+ j++;
282
+ }
283
+ }
284
+ rows.push({
285
+ type: 'tableRow',
286
+ attrs: { id: generateNodeId() },
287
+ content: cells,
288
+ });
289
+ i = trEnd + 1;
290
+ }
291
+ else {
292
+ i++;
293
+ }
294
+ }
295
+ return {
296
+ type: 'table',
297
+ attrs: { id: generateNodeId() },
298
+ content: rows,
299
+ };
300
+ }
301
+ function inlineTokensToTiptap(tokens) {
302
+ const nodes = [];
303
+ const markStack = [];
304
+ for (const token of tokens) {
305
+ if (token.type === 'text') {
306
+ if (!token.content)
307
+ continue; // ProseMirror rejects empty text nodes
308
+ const textNode = { type: 'text', text: token.content };
309
+ if (markStack.length > 0) {
310
+ textNode.marks = deduplicateMarks(markStack);
311
+ }
312
+ nodes.push(textNode);
313
+ }
314
+ else if (token.type === 'code_inline') {
315
+ if (!token.content)
316
+ continue;
317
+ const marks = deduplicateMarks([...markStack, { type: 'code' }]);
318
+ nodes.push({ type: 'text', text: token.content, marks });
319
+ }
320
+ else if (token.type === 'strong_open') {
321
+ markStack.push({ type: 'bold' });
322
+ }
323
+ else if (token.type === 'strong_close') {
324
+ popMarkByType(markStack, 'bold');
325
+ }
326
+ else if (token.type === 'em_open') {
327
+ markStack.push({ type: 'italic' });
328
+ }
329
+ else if (token.type === 'em_close') {
330
+ popMarkByType(markStack, 'italic');
331
+ }
332
+ else if (token.type === 's_open') {
333
+ markStack.push({ type: 'strike' });
334
+ }
335
+ else if (token.type === 's_close') {
336
+ popMarkByType(markStack, 'strike');
337
+ }
338
+ else if (token.type === 'ins_open') {
339
+ markStack.push({ type: 'underline' });
340
+ }
341
+ else if (token.type === 'ins_close') {
342
+ popMarkByType(markStack, 'underline');
343
+ }
344
+ else if (token.type === 'mark_open') {
345
+ markStack.push({ type: 'highlight' });
346
+ }
347
+ else if (token.type === 'mark_close') {
348
+ popMarkByType(markStack, 'highlight');
349
+ }
350
+ else if (token.type === 'sub_open') {
351
+ markStack.push({ type: 'subscript' });
352
+ }
353
+ else if (token.type === 'sub_close') {
354
+ popMarkByType(markStack, 'subscript');
355
+ }
356
+ else if (token.type === 'sup_open') {
357
+ markStack.push({ type: 'superscript' });
358
+ }
359
+ else if (token.type === 'sup_close') {
360
+ popMarkByType(markStack, 'superscript');
361
+ }
362
+ else if (token.type === 'link_open') {
363
+ const rawHref = token.attrGet('href') || '';
364
+ const href = decodeURI(rawHref);
365
+ markStack.push({ type: 'link', attrs: { href } });
366
+ }
367
+ else if (token.type === 'link_close') {
368
+ popMarkByType(markStack, 'link');
369
+ }
370
+ else if (token.type === 'image') {
371
+ const src = token.attrGet('src') || '';
372
+ const alt = token.content || token.attrGet('alt') || '';
373
+ nodes.push({
374
+ type: 'image',
375
+ attrs: { id: generateNodeId(), src, alt },
376
+ });
377
+ }
378
+ else if (token.type === 'hardbreak') {
379
+ nodes.push({ type: 'hardBreak' });
380
+ }
381
+ else if (token.type === 'softbreak') {
382
+ nodes.push({ type: 'text', text: ' ' });
383
+ }
384
+ }
385
+ return nodes;
386
+ }
387
+ /** Remove duplicate mark types (e.g. nested **bold** producing two bold marks). */
388
+ function deduplicateMarks(marks) {
389
+ const seen = new Set();
390
+ return marks.filter((m) => {
391
+ const key = m.type === 'link' ? `link:${m.attrs?.href}` : m.type;
392
+ if (seen.has(key))
393
+ return false;
394
+ seen.add(key);
395
+ return true;
396
+ });
397
+ }
398
+ function popMarkByType(stack, type) {
399
+ for (let i = stack.length - 1; i >= 0; i--) {
400
+ if (stack[i].type === type) {
401
+ stack.splice(i, 1);
402
+ return;
403
+ }
404
+ }
405
+ }
@@ -0,0 +1,263 @@
1
+ /**
2
+ * TipTap JSON -> Markdown serialization.
3
+ * Converts TipTap document to markdown with YAML frontmatter.
4
+ */
5
+ import { LEAF_BLOCK_TYPES } from './helpers.js';
6
+ // ============================================================================
7
+ // TipTap -> Markdown
8
+ // ============================================================================
9
+ /** Extract plain text from a TipTap node's inline content. */
10
+ export function nodeText(node) {
11
+ if (!node.content)
12
+ return '';
13
+ return node.content.map((c) => c.text || '').join('');
14
+ }
15
+ /**
16
+ * Collect pending state from leaf blocks into a position-indexed map.
17
+ * Each entry includes a text fingerprint (`t`) for robust matching
18
+ * across markdown round-trips where empty paragraphs may disappear.
19
+ */
20
+ function collectPendingState(doc) {
21
+ const pending = {};
22
+ let index = 0;
23
+ function walk(nodes) {
24
+ if (!nodes)
25
+ return;
26
+ for (const node of nodes) {
27
+ if (LEAF_BLOCK_TYPES.has(node.type)) {
28
+ if (node.attrs?.pendingStatus) {
29
+ const entry = { s: node.attrs.pendingStatus };
30
+ if (node.attrs.pendingOriginalContent) {
31
+ entry.o = node.attrs.pendingOriginalContent;
32
+ }
33
+ const t = nodeText(node);
34
+ if (t)
35
+ entry.t = t;
36
+ pending[String(index)] = entry;
37
+ }
38
+ index++;
39
+ }
40
+ else if (node.content) {
41
+ walk(node.content);
42
+ }
43
+ }
44
+ }
45
+ walk(doc.content || []);
46
+ return Object.keys(pending).length > 0 ? pending : undefined;
47
+ }
48
+ /**
49
+ * Convert TipTap document to markdown with JSON frontmatter.
50
+ * Metadata stored as minified JSON between --- delimiters (valid YAML).
51
+ * Editor never sees frontmatter — it's stripped on load, regenerated on save.
52
+ * Pending state is persisted in frontmatter `pending` key.
53
+ */
54
+ export function tiptapToMarkdown(doc, title, metadata) {
55
+ const meta = { ...metadata, title };
56
+ // Collect pending state from node attrs into frontmatter
57
+ const pendingState = collectPendingState(doc);
58
+ if (pendingState) {
59
+ meta.pending = pendingState;
60
+ }
61
+ else {
62
+ delete meta.pending;
63
+ }
64
+ // Strip undefined/null values
65
+ for (const key of Object.keys(meta)) {
66
+ if (meta[key] === undefined || meta[key] === null)
67
+ delete meta[key];
68
+ }
69
+ const frontmatter = `---\n${JSON.stringify(meta)}\n---\n\n`;
70
+ const body = nodesToMarkdown(doc.content || []);
71
+ return frontmatter + body;
72
+ }
73
+ function nodesToMarkdown(nodes) {
74
+ let result = '';
75
+ for (const node of nodes) {
76
+ result += nodeToMarkdown(node, '');
77
+ }
78
+ return result;
79
+ }
80
+ function nodeToMarkdown(node, indent) {
81
+ switch (node.type) {
82
+ case 'heading': {
83
+ const level = node.attrs?.level || 1;
84
+ const prefix = '#'.repeat(level);
85
+ return `${prefix} ${inlineToMarkdown(node.content)}\n\n`;
86
+ }
87
+ case 'paragraph': {
88
+ const text = inlineToMarkdown(node.content);
89
+ return text ? `${indent}${text}\n\n` : '\n';
90
+ }
91
+ case 'bulletList':
92
+ return listToMarkdown(node.content, '- ', indent);
93
+ case 'orderedList':
94
+ return listToMarkdown(node.content, null, indent);
95
+ case 'taskList':
96
+ return taskListToMarkdown(node.content, indent);
97
+ case 'blockquote': {
98
+ const inner = nodesToMarkdown(node.content || []);
99
+ return inner
100
+ .split('\n')
101
+ .map((line) => (line ? `> ${line}` : '>'))
102
+ .join('\n') + '\n';
103
+ }
104
+ case 'codeBlock': {
105
+ const lang = node.attrs?.language || '';
106
+ const text = extractPlainText(node.content);
107
+ return `\`\`\`${lang}\n${text}\n\`\`\`\n\n`;
108
+ }
109
+ case 'horizontalRule':
110
+ return '---\n\n';
111
+ case 'image': {
112
+ const src = node.attrs?.src || '';
113
+ const alt = node.attrs?.alt || '';
114
+ return `![${alt}](${src})\n\n`;
115
+ }
116
+ case 'table':
117
+ return tableToMarkdown(node);
118
+ default:
119
+ if (node.content)
120
+ return nodesToMarkdown(node.content);
121
+ if (node.text)
122
+ return node.text;
123
+ return '';
124
+ }
125
+ }
126
+ function listToMarkdown(items, bullet, indent) {
127
+ if (!items)
128
+ return '';
129
+ let result = '';
130
+ for (let i = 0; i < items.length; i++) {
131
+ const item = items[i];
132
+ const prefix = bullet || `${i + 1}. `;
133
+ const content = item.content || [];
134
+ for (let j = 0; j < content.length; j++) {
135
+ const child = content[j];
136
+ if (j === 0) {
137
+ const text = child.type === 'paragraph' ? inlineToMarkdown(child.content) : nodeToMarkdown(child, '');
138
+ result += `${indent}${prefix}${text.trimEnd()}\n`;
139
+ }
140
+ else {
141
+ result += nodeToMarkdown(child, indent + ' ');
142
+ }
143
+ }
144
+ }
145
+ return result + '\n';
146
+ }
147
+ function taskListToMarkdown(items, indent) {
148
+ if (!items)
149
+ return '';
150
+ let result = '';
151
+ for (const item of items) {
152
+ const checked = item.attrs?.checked ? 'x' : ' ';
153
+ const content = item.content || [];
154
+ for (let j = 0; j < content.length; j++) {
155
+ const child = content[j];
156
+ if (j === 0) {
157
+ const text = child.type === 'paragraph' ? inlineToMarkdown(child.content) : nodeToMarkdown(child, '');
158
+ result += `${indent}- [${checked}] ${text.trimEnd()}\n`;
159
+ }
160
+ else {
161
+ result += nodeToMarkdown(child, indent + ' ');
162
+ }
163
+ }
164
+ }
165
+ return result + '\n';
166
+ }
167
+ function tableToMarkdown(node) {
168
+ const rows = node.content || [];
169
+ if (rows.length === 0)
170
+ return '';
171
+ const lines = [];
172
+ let isFirstRow = true;
173
+ for (const row of rows) {
174
+ const cells = row.content || [];
175
+ const cellTexts = cells.map((cell) => {
176
+ const para = cell.content?.[0];
177
+ return para ? inlineToMarkdown(para.content) : '';
178
+ });
179
+ lines.push(`| ${cellTexts.join(' | ')} |`);
180
+ if (isFirstRow) {
181
+ const hasHeaders = cells.some((c) => c.type === 'tableHeader');
182
+ if (hasHeaders) {
183
+ lines.push(`| ${cellTexts.map(() => '---').join(' | ')} |`);
184
+ }
185
+ isFirstRow = false;
186
+ }
187
+ }
188
+ return lines.join('\n') + '\n\n';
189
+ }
190
+ // ---- Inline mark serialization ----
191
+ const SERIALIZED_MARKS = ['bold', 'italic', 'code', 'strike', 'underline', 'highlight', 'subscript', 'superscript', 'link'];
192
+ export function inlineToMarkdown(nodes) {
193
+ if (!nodes)
194
+ return '';
195
+ let result = '';
196
+ let openMarks = [];
197
+ for (const node of nodes) {
198
+ if (node.type === 'hardBreak') {
199
+ result += closeAllMarks(openMarks);
200
+ openMarks = [];
201
+ result += '\n';
202
+ continue;
203
+ }
204
+ if (node.type !== 'text')
205
+ continue;
206
+ const targetMarks = (node.marks || []).filter((m) => SERIALIZED_MARKS.includes(m.type));
207
+ // Find common prefix of marks between open and target
208
+ let commonLen = 0;
209
+ while (commonLen < openMarks.length && commonLen < targetMarks.length) {
210
+ if (!marksEqual(openMarks[commonLen], targetMarks[commonLen]))
211
+ break;
212
+ commonLen++;
213
+ }
214
+ // Close marks that are no longer needed (reverse order)
215
+ for (let i = openMarks.length - 1; i >= commonLen; i--) {
216
+ result += markSyntax(openMarks[i], false);
217
+ }
218
+ // Open new marks
219
+ for (let i = commonLen; i < targetMarks.length; i++) {
220
+ result += markSyntax(targetMarks[i], true);
221
+ }
222
+ result += node.text || '';
223
+ openMarks = [...targetMarks];
224
+ }
225
+ // Close remaining marks
226
+ for (let i = openMarks.length - 1; i >= 0; i--) {
227
+ result += markSyntax(openMarks[i], false);
228
+ }
229
+ return result;
230
+ }
231
+ function markSyntax(mark, isOpen) {
232
+ switch (mark.type) {
233
+ case 'bold': return '**';
234
+ case 'italic': return '*';
235
+ case 'code': return '`';
236
+ case 'strike': return '~~';
237
+ case 'underline': return '++';
238
+ case 'highlight': return '==';
239
+ case 'subscript': return '~';
240
+ case 'superscript': return '^';
241
+ case 'link': return isOpen ? '[' : `](<${mark.attrs?.href || ''}>)`;
242
+ default: return '';
243
+ }
244
+ }
245
+ function marksEqual(a, b) {
246
+ if (a.type !== b.type)
247
+ return false;
248
+ if (a.type === 'link')
249
+ return a.attrs?.href === b.attrs?.href;
250
+ return true;
251
+ }
252
+ function closeAllMarks(marks) {
253
+ let result = '';
254
+ for (let i = marks.length - 1; i >= 0; i--) {
255
+ result += markSyntax(marks[i], false);
256
+ }
257
+ return result;
258
+ }
259
+ function extractPlainText(nodes) {
260
+ if (!nodes)
261
+ return '';
262
+ return nodes.map((n) => n.text || '').join('');
263
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Barrel re-export for markdown serialization and parsing.
3
+ * All existing imports from './markdown.js' continue to work unchanged.
4
+ */
5
+ export { tiptapToMarkdown, nodeText, inlineToMarkdown } from './markdown-serialize.js';
6
+ export { markdownToTiptap, markdownToNodes } from './markdown-parse.js';