Skip to content

Commit bf47e08

Browse files
committed
Universal post import: in-browser convert, ZIP round-trip, math/table fixes
1 parent 44c6600 commit bf47e08

20 files changed

Lines changed: 999 additions & 473 deletions

File tree

Lines changed: 12 additions & 346 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import fs from 'node:fs';
2+
import { convertMdx } from '../../src/write/convert/mdxToSource.mjs';
23

34
let SRC = process.argv[2];
45
if (!SRC) {
@@ -14,357 +15,22 @@ if (!fs.existsSync(SRC)) {
1415
if (fs.statSync(SRC).isDirectory()) SRC = SRC.replace(/\/$/, '') + '/index.mdx';
1516
const slug = SRC.split('/').filter(Boolean).at(-2) ?? 'post-slug';
1617
const OUT = process.argv[3] ?? `${slug}.write-source.json`;
17-
18-
let src = fs.readFileSync(SRC, 'utf8');
19-
20-
const fm = src.match(/^---\n([\s\S]*?)\n---\n/);
21-
const body = fm ? src.slice(fm[0].length) : src;
22-
const fmText = fm ? fm[1] : '';
23-
const fmVal = (key) => {
24-
const m = fmText.match(new RegExp(`^${key}: (.*)$`, 'm'));
25-
return m ? m[1].replace(/^"|"$/g, '') : '';
26-
};
27-
const tagsMatch = fmText.match(/^tags: (\[.*\])$/m);
28-
const tags = tagsMatch ? JSON.parse(tagsMatch[1].replace(/'/g, '"')) : [];
29-
30-
let n = 0;
31-
const id = (p) => `${p}-${++n}`;
32-
const D = { backgroundColor: 'default', textColor: 'default', textAlignment: 'left' };
33-
34-
const INLINE_PATTERNS = [
35-
{ re: /\*\*`([^`]+)`\*\*/g, kind: 'code' },
36-
{ re: /`([^`]+)`/g, kind: 'code' },
37-
{ re: /(?<!!)\[([^\]]+)\]\(([^)\s]+)\)/g, kind: 'link' },
38-
{ re: /\*\*([^*]+(?:\*(?!\*)[^*]*)*)\*\*/g, kind: 'style', key: 'bold' },
39-
{ re: /__([^_]+)__/g, kind: 'style', key: 'bold' },
40-
{ re: /~~([^~]+)~~/g, kind: 'style', key: 'strike' },
41-
{ re: /\*([^*\n]+)\*/g, kind: 'style', key: 'italic' },
42-
{ re: /(?<![\w`])_([^_\n]+)_(?![\w`])/g, kind: 'style', key: 'italic' },
43-
];
44-
45-
function smartQuotes(s) {
46-
return s
47-
.replace(/(\w)'(\w)/g, '$1’$2')
48-
.replace(/(^|[\s([{-])"/g, '$1“')
49-
.replace(/"/g, '”')
50-
.replace(/(^|[\s([{-])'/g, '$1‘')
51-
.replace(/'/g, '’');
52-
}
53-
54-
function inline(text, inherited = {}) {
55-
const runs = [];
56-
let pos = 0;
57-
while (pos < text.length) {
58-
let best = null;
59-
for (const p of INLINE_PATTERNS) {
60-
p.re.lastIndex = pos;
61-
const m = p.re.exec(text);
62-
if (m && (!best || m.index < best.m.index)) best = { p, m };
63-
}
64-
if (!best) {
65-
runs.push({ type: 'text', text: smartQuotes(text.slice(pos)), styles: { ...inherited } });
66-
break;
67-
}
68-
if (best.m.index > pos)
69-
runs.push({
70-
type: 'text',
71-
text: smartQuotes(text.slice(pos, best.m.index)),
72-
styles: { ...inherited },
73-
});
74-
const { p, m } = best;
75-
if (p.kind === 'code') {
76-
runs.push({ type: 'text', text: m[1], styles: { code: true } });
77-
} else if (p.kind === 'link') {
78-
runs.push({
79-
type: 'link',
80-
href: m[2],
81-
content: inline(m[1], inherited).filter((r) => r.type === 'text'),
82-
});
83-
} else {
84-
runs.push(...inline(m[1], { ...inherited, [p.key]: true }));
85-
}
86-
pos = m.index + m[0].length;
87-
}
88-
return runs.filter((r) => r.type !== 'text' || r.text !== '');
89-
}
90-
91-
const blocks = [];
92-
const push = (type, props, content, children = []) => {
93-
const b = { id: id(type), type, props, children };
94-
if (content !== undefined) b.content = content;
95-
blocks.push(b);
96-
};
97-
98-
const cell = (text) => ({
99-
type: 'tableCell',
100-
content: inline(text),
101-
props: { colspan: 1, rowspan: 1, ...D },
102-
});
103-
10418
const DIR = SRC.replace(/\/index\.mdx$/, '');
10519

106-
function componentSource(name) {
107-
const file = `${DIR}/${name}.tsx`;
108-
if (fs.existsSync(file)) return fs.readFileSync(file, 'utf8');
109-
console.warn(`warning: ${name}.tsx not found next to index.mdx — source left empty`);
110-
return '';
111-
}
112-
113-
function frameAttr(attrs, name) {
114-
return (
115-
attrs.match(new RegExp(`${name}="([^"]*)"`))?.[1] ??
116-
JSON.parse(attrs.match(new RegExp(`${name}=\\{("(?:[^"\\\\]|\\\\.)*")\\}`))?.[1] ?? '""')
117-
);
118-
}
119-
120-
function frameProps(attrs) {
121-
return {
122-
frameTitle: frameAttr(attrs, 'title'),
123-
frameCaption: frameAttr(attrs, 'caption'),
124-
frameSize: /size="wide"/.test(attrs) ? 'wide' : 'normal',
125-
frameExpand: /(^|\s)expand(\s|$|=)/.test(attrs),
126-
};
127-
}
128-
129-
function unwrapJsxStyle(svg) {
130-
return svg.replace(
131-
/<style([^>]*)>\{`([\s\S]*?)`\}<\/style>/g,
132-
(_m, attrs, css) => `<style${attrs}>${css.replace(/\\([\\`$])/g, '$1')}</style>`,
133-
);
134-
}
135-
136-
function figureSegment(attrs, inner) {
137-
const caption = frameAttr(attrs, 'caption').replace(/\s+/g, ' ').trim();
138-
const width = Number(attrs.match(/width=\{(\d+)\}/)?.[1] ?? '') || '';
139-
if (/^<svg[\s>]/.test(inner)) {
140-
return { kind: 'figure', caption, svg: unwrapJsxStyle(inner) };
141-
}
142-
const img =
143-
inner.match(/^!\[([^\]]*)\]\(([^)\s]+)\)$/) ?? inner.match(/^<img[^>]*\ssrc="([^"]+)"[^>]*>$/);
144-
if (img) {
145-
const md = inner.startsWith('![');
146-
return {
147-
kind: 'image',
148-
alt: md ? img[1] : (inner.match(/\salt="([^"]*)"/)?.[1] ?? ''),
149-
src: md ? img[2] : img[1],
150-
caption,
151-
width: width || 360,
152-
};
153-
}
154-
return { kind: 'md', text: inner };
155-
}
156-
157-
let rest = body;
158-
const pattern =
159-
/(<Figure([^>]*)>\s*([\s\S]*?)\s*<\/Figure>)|(<Note>\s*([\s\S]*?)\s*<\/Note>)|(```(\w*)\n([\s\S]*?)```)|(<Interactive\b([^>]*)>\s*<([A-Za-z]\w*)\s+client:visible\s*\/>\s*<\/Interactive>)|(<([A-Z]\w*)\s+client:visible\s*\/>)/g;
160-
161-
let cursor = 0;
162-
const segments = [];
163-
let m;
164-
while ((m = pattern.exec(rest))) {
165-
if (m.index > cursor) segments.push({ kind: 'md', text: rest.slice(cursor, m.index) });
166-
if (m[1]) {
167-
segments.push(figureSegment(m[2] ?? '', m[3].trim()));
168-
} else if (m[4]) segments.push({ kind: 'note', text: m[5].replace(/\s+/g, ' ').trim() });
169-
else if (m[6]) {
170-
const code = m[8].replace(/\n$/, '');
171-
if ((m[7] || '') === 'mermaid') segments.push({ kind: 'mermaid', source: code });
172-
else segments.push({ kind: 'code', lang: m[7] || 'text', code });
173-
} else if (m[9]) segments.push({ kind: 'component', name: m[11], frame: frameProps(m[10]) });
174-
else if (m[12])
175-
segments.push({
176-
kind: 'component',
177-
name: m[13],
178-
frame: { frameTitle: '', frameCaption: '', frameSize: 'normal', frameExpand: false },
179-
});
180-
cursor = m.index + m[0].length;
181-
}
182-
if (cursor < rest.length) segments.push({ kind: 'md', text: rest.slice(cursor) });
183-
184-
function emitMd(text) {
185-
const lines = text.split('\n');
186-
let i = 0;
187-
while (i < lines.length) {
188-
const line = lines[i];
189-
if (!line.trim() || /^import\s.+\sfrom\s/.test(line)) {
190-
i++;
191-
continue;
192-
}
193-
const h = line.match(/^(#{2,6}) /);
194-
if (h) {
195-
push(
196-
'heading',
197-
{ ...D, level: Math.min(h[1].length - 1, 3), isToggleable: false },
198-
inline(line.slice(h[1].length + 1)),
199-
);
200-
i++;
201-
continue;
202-
}
203-
const img = line.match(/^!\[([^\]]*)\]\(([^)\s]+)\)$/);
204-
if (img) {
205-
push('figure', { fileName: '', src: img[2], alt: img[1], caption: '', width: 360 });
206-
i++;
207-
continue;
208-
}
209-
if (line.startsWith('> ')) {
210-
const q = [];
211-
while (i < lines.length && lines[i].startsWith('> ')) {
212-
q.push(lines[i].slice(2));
213-
i++;
214-
}
215-
push('quote', { backgroundColor: 'default', textColor: 'default' }, inline(q.join(' ')));
216-
continue;
217-
}
218-
if (/^[-*] \[[ xX]\] /.test(line)) {
219-
while (i < lines.length && /^[-*] \[[ xX]\] /.test(lines[i])) {
220-
push(
221-
'checkListItem',
222-
{ ...D, checked: /^\S+ \[[xX]\]/.test(lines[i]) },
223-
inline(lines[i].replace(/^[-*] \[[ xX]\] /, '')),
224-
);
225-
i++;
226-
}
227-
continue;
228-
}
229-
if (/^[-*] /.test(line)) {
230-
while (i < lines.length && /^[-*] /.test(lines[i])) {
231-
push('bulletListItem', { ...D }, inline(lines[i].slice(2)));
232-
i++;
233-
}
234-
continue;
235-
}
236-
if (/^\d+\. /.test(line)) {
237-
while (i < lines.length && /^\d+\. /.test(lines[i])) {
238-
push('numberedListItem', { ...D }, inline(lines[i].replace(/^\d+\. /, '')));
239-
i++;
240-
}
241-
continue;
242-
}
243-
if (/^(---+|\*\*\*+)$/.test(line.trim())) {
244-
push('separator', {});
245-
i++;
246-
continue;
247-
}
248-
if (/^<hr\b/.test(line.trim())) {
249-
push('divider', {});
250-
i++;
251-
continue;
252-
}
253-
if (line.startsWith('|')) {
254-
const rows = [];
255-
while (i < lines.length && lines[i].startsWith('|')) {
256-
if (!/^\|[:\-\s|]+\|$/.test(lines[i])) {
257-
const cells = lines[i]
258-
.split('|')
259-
.slice(1, -1)
260-
.map((c) => c.trim());
261-
rows.push({ cells: cells.map(cell) });
262-
}
263-
i++;
264-
}
265-
const cols = rows[0].cells.length;
266-
push(
267-
'table',
268-
{ textColor: 'default' },
269-
{ type: 'tableContent', columnWidths: Array(cols).fill(null), rows },
270-
);
271-
continue;
272-
}
273-
const para = [];
274-
while (
275-
i < lines.length &&
276-
lines[i].trim() &&
277-
!/^(#{2,6} |> |\||```|[-*] |\d+\. |!\[|<hr\b)/.test(lines[i]) &&
278-
!/^(---+|\*\*\*+)$/.test(lines[i].trim())
279-
) {
280-
para.push(lines[i]);
281-
i++;
282-
}
283-
push('paragraph', { ...D }, inline(para.join(' ')));
284-
}
285-
}
286-
287-
for (const seg of segments) {
288-
if (seg.kind === 'md') emitMd(seg.text);
289-
else if (seg.kind === 'figure') push('svg', { code: seg.svg, caption: seg.caption });
290-
else if (seg.kind === 'note') push('note', {}, inline(seg.text));
291-
else if (seg.kind === 'image')
292-
push('figure', {
293-
fileName: '',
294-
src: seg.src,
295-
alt: seg.alt,
296-
caption: seg.caption,
297-
width: seg.width,
298-
});
299-
else if (seg.kind === 'code')
300-
push('codeBlock', { language: seg.lang }, [{ type: 'text', text: seg.code, styles: {} }]);
301-
else if (seg.kind === 'mermaid') {
302-
console.warn('note: mermaid block emitted without render — open the post in /write once');
303-
push('mermaid', { source: seg.source, svg: '', caption: '' });
304-
} else if (seg.kind === 'component')
305-
push('customComponent', {
306-
componentName: seg.name,
307-
source: componentSource(seg.name),
308-
...seg.frame,
309-
});
310-
}
311-
312-
const doc = {
313-
kind: 'mlsys-write-source',
314-
version: 1,
315-
meta: {
316-
title: fmVal('title'),
317-
summary: fmVal('summary'),
318-
authors: (() => {
319-
const list = [...fmText.matchAll(/^ {2}- (.+)$/gm)]
320-
.map((x) => x[1])
321-
.filter((a) => !/^\d{4}-/.test(a));
322-
return list.length ? list : ['guest'];
323-
})(),
324-
writerName: 'Author Name',
325-
topicId: fmVal('topicId'),
326-
topicName: fmVal('topic'),
327-
tags,
328-
slug,
329-
coverFileName: '',
330-
ogCard: false,
331-
proposedTopic: '',
332-
newAuthor: null,
333-
date: fmVal('date'),
20+
const { doc, warnings } = convertMdx(fs.readFileSync(SRC, 'utf8'), {
21+
slug,
22+
componentSource: (name) => {
23+
const file = `${DIR}/${name}.tsx`;
24+
if (fs.existsSync(file)) return fs.readFileSync(file, 'utf8');
25+
console.warn(`warning: ${name}.tsx not found next to index.mdx — source left empty`);
26+
return '';
33427
},
335-
blocks,
336-
tableVariants: {},
337-
};
28+
});
33829

339-
const ids = blocks.map((b) => b.id);
340-
if (new Set(ids).size !== ids.length) throw new Error('duplicate ids');
341-
const allowed = new Set([
342-
'paragraph',
343-
'heading',
344-
'bulletListItem',
345-
'numberedListItem',
346-
'checkListItem',
347-
'toggleListItem',
348-
'quote',
349-
'note',
350-
'codeBlock',
351-
'math',
352-
'separator',
353-
'divider',
354-
'table',
355-
'figure',
356-
'gallery',
357-
'video',
358-
'svg',
359-
'mermaid',
360-
'customComponent',
361-
]);
362-
for (const b of blocks) if (!allowed.has(b.type)) throw new Error('bad type ' + b.type);
363-
for (const b of blocks)
364-
if (['svg', 'codeBlock'].includes(b.type) === false && b.content) JSON.stringify(b.content);
30+
for (const w of warnings) console.warn(`note: ${w}`);
36531

36632
fs.writeFileSync(OUT, JSON.stringify(doc, null, 2) + '\n');
36733
const counts = {};
368-
for (const b of blocks) counts[b.type] = (counts[b.type] ?? 0) + 1;
369-
console.log('blocks:', blocks.length, JSON.stringify(counts));
34+
for (const b of doc.blocks) counts[b.type] = (counts[b.type] ?? 0) + 1;
35+
console.log('blocks:', doc.blocks.length, JSON.stringify(counts));
37036
console.log('written:', OUT);

0 commit comments

Comments
 (0)