|
| 1 | +import fs from 'node:fs'; |
| 2 | + |
| 3 | +let SRC = process.argv[2]; |
| 4 | +if (!SRC) { |
| 5 | + console.error( |
| 6 | + 'usage: node docs/authoring/mdx-to-write-source.mjs <post-dir-or-index.mdx> [out.json]', |
| 7 | + ); |
| 8 | + process.exit(1); |
| 9 | +} |
| 10 | +if (!fs.existsSync(SRC)) { |
| 11 | + console.error(`not found: ${SRC}`); |
| 12 | + process.exit(1); |
| 13 | +} |
| 14 | +if (fs.statSync(SRC).isDirectory()) SRC = SRC.replace(/\/$/, '') + '/index.mdx'; |
| 15 | +const slug = SRC.split('/').filter(Boolean).at(-2) ?? 'post-slug'; |
| 16 | +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 | + |
| 104 | +let rest = body; |
| 105 | +const pattern = |
| 106 | + /(<Figure caption=(?:"([\s\S]*?)"|\{("(?:[^"\\]|\\.)*")\})>\s*([\s\S]*?)\s*<\/Figure>)|(<Note>\s*([\s\S]*?)\s*<\/Note>)|(```(\w*)\n([\s\S]*?)```)/g; |
| 107 | + |
| 108 | +let cursor = 0; |
| 109 | +const segments = []; |
| 110 | +let m; |
| 111 | +while ((m = pattern.exec(rest))) { |
| 112 | + if (m.index > cursor) segments.push({ kind: 'md', text: rest.slice(cursor, m.index) }); |
| 113 | + if (m[1]) { |
| 114 | + const caption = m[3] ? JSON.parse(m[3]) : (m[2] ?? ''); |
| 115 | + segments.push({ |
| 116 | + kind: 'figure', |
| 117 | + caption: caption.replace(/\s+/g, ' ').trim(), |
| 118 | + svg: m[4].trim(), |
| 119 | + }); |
| 120 | + } else if (m[5]) segments.push({ kind: 'note', text: m[6].replace(/\s+/g, ' ').trim() }); |
| 121 | + else if (m[7]) |
| 122 | + segments.push({ kind: 'code', lang: m[8] || 'text', code: m[9].replace(/\n$/, '') }); |
| 123 | + cursor = m.index + m[0].length; |
| 124 | +} |
| 125 | +if (cursor < rest.length) segments.push({ kind: 'md', text: rest.slice(cursor) }); |
| 126 | + |
| 127 | +function emitMd(text) { |
| 128 | + const lines = text.split('\n'); |
| 129 | + let i = 0; |
| 130 | + while (i < lines.length) { |
| 131 | + const line = lines[i]; |
| 132 | + if (!line.trim()) { |
| 133 | + i++; |
| 134 | + continue; |
| 135 | + } |
| 136 | + const h = line.match(/^(#{2,6}) /); |
| 137 | + if (h) { |
| 138 | + push( |
| 139 | + 'heading', |
| 140 | + { ...D, level: Math.min(h[1].length - 1, 3), isToggleable: false }, |
| 141 | + inline(line.slice(h[1].length + 1)), |
| 142 | + ); |
| 143 | + i++; |
| 144 | + continue; |
| 145 | + } |
| 146 | + const img = line.match(/^!\[([^\]]*)\]\(([^)\s]+)\)$/); |
| 147 | + if (img) { |
| 148 | + push('figure', { fileName: '', src: img[2], alt: img[1], caption: '', width: 360 }); |
| 149 | + i++; |
| 150 | + continue; |
| 151 | + } |
| 152 | + if (line.startsWith('> ')) { |
| 153 | + const q = []; |
| 154 | + while (i < lines.length && lines[i].startsWith('> ')) { |
| 155 | + q.push(lines[i].slice(2)); |
| 156 | + i++; |
| 157 | + } |
| 158 | + push('quote', { backgroundColor: 'default', textColor: 'default' }, inline(q.join(' '))); |
| 159 | + continue; |
| 160 | + } |
| 161 | + if (/^[-*] \[[ xX]\] /.test(line)) { |
| 162 | + while (i < lines.length && /^[-*] \[[ xX]\] /.test(lines[i])) { |
| 163 | + push( |
| 164 | + 'checkListItem', |
| 165 | + { ...D, checked: /^\S+ \[[xX]\]/.test(lines[i]) }, |
| 166 | + inline(lines[i].replace(/^[-*] \[[ xX]\] /, '')), |
| 167 | + ); |
| 168 | + i++; |
| 169 | + } |
| 170 | + continue; |
| 171 | + } |
| 172 | + if (/^[-*] /.test(line)) { |
| 173 | + while (i < lines.length && /^[-*] /.test(lines[i])) { |
| 174 | + push('bulletListItem', { ...D }, inline(lines[i].slice(2))); |
| 175 | + i++; |
| 176 | + } |
| 177 | + continue; |
| 178 | + } |
| 179 | + if (/^\d+\. /.test(line)) { |
| 180 | + while (i < lines.length && /^\d+\. /.test(lines[i])) { |
| 181 | + push('numberedListItem', { ...D }, inline(lines[i].replace(/^\d+\. /, ''))); |
| 182 | + i++; |
| 183 | + } |
| 184 | + continue; |
| 185 | + } |
| 186 | + if (/^(---+|\*\*\*+)$/.test(line.trim())) { |
| 187 | + push('separator', {}); |
| 188 | + i++; |
| 189 | + continue; |
| 190 | + } |
| 191 | + if (line.startsWith('|')) { |
| 192 | + const rows = []; |
| 193 | + while (i < lines.length && lines[i].startsWith('|')) { |
| 194 | + if (!/^\|[:\-\s|]+\|$/.test(lines[i])) { |
| 195 | + const cells = lines[i] |
| 196 | + .split('|') |
| 197 | + .slice(1, -1) |
| 198 | + .map((c) => c.trim()); |
| 199 | + rows.push({ cells: cells.map(cell) }); |
| 200 | + } |
| 201 | + i++; |
| 202 | + } |
| 203 | + const cols = rows[0].cells.length; |
| 204 | + push( |
| 205 | + 'table', |
| 206 | + { textColor: 'default' }, |
| 207 | + { type: 'tableContent', columnWidths: Array(cols).fill(null), rows }, |
| 208 | + ); |
| 209 | + continue; |
| 210 | + } |
| 211 | + const para = []; |
| 212 | + while ( |
| 213 | + i < lines.length && |
| 214 | + lines[i].trim() && |
| 215 | + !/^(#{2,6} |> |\||```|[-*] |\d+\. |!\[)/.test(lines[i]) && |
| 216 | + !/^(---+|\*\*\*+)$/.test(lines[i].trim()) |
| 217 | + ) { |
| 218 | + para.push(lines[i]); |
| 219 | + i++; |
| 220 | + } |
| 221 | + push('paragraph', { ...D }, inline(para.join(' '))); |
| 222 | + } |
| 223 | +} |
| 224 | + |
| 225 | +for (const seg of segments) { |
| 226 | + if (seg.kind === 'md') emitMd(seg.text); |
| 227 | + else if (seg.kind === 'figure') push('svg', { code: seg.svg, caption: seg.caption }); |
| 228 | + else if (seg.kind === 'note') push('note', {}, inline(seg.text)); |
| 229 | + else if (seg.kind === 'code') |
| 230 | + push('codeBlock', { language: seg.lang }, [{ type: 'text', text: seg.code, styles: {} }]); |
| 231 | +} |
| 232 | + |
| 233 | +const doc = { |
| 234 | + kind: 'mlsys-write-source', |
| 235 | + version: 1, |
| 236 | + meta: { |
| 237 | + title: fmVal('title'), |
| 238 | + summary: fmVal('summary'), |
| 239 | + authors: (() => { |
| 240 | + const list = [...fmText.matchAll(/^ {2}- (.+)$/gm)] |
| 241 | + .map((x) => x[1]) |
| 242 | + .filter((a) => !/^\d{4}-/.test(a)); |
| 243 | + return list.length ? list : ['guest']; |
| 244 | + })(), |
| 245 | + writerName: 'Author Name', |
| 246 | + topicId: fmVal('topicId'), |
| 247 | + topicName: fmVal('topic'), |
| 248 | + tags, |
| 249 | + slug, |
| 250 | + coverFileName: '', |
| 251 | + ogCard: false, |
| 252 | + proposedTopic: '', |
| 253 | + newAuthor: null, |
| 254 | + date: fmVal('date'), |
| 255 | + }, |
| 256 | + blocks, |
| 257 | + tableVariants: {}, |
| 258 | +}; |
| 259 | + |
| 260 | +const ids = blocks.map((b) => b.id); |
| 261 | +if (new Set(ids).size !== ids.length) throw new Error('duplicate ids'); |
| 262 | +const allowed = new Set([ |
| 263 | + 'paragraph', |
| 264 | + 'heading', |
| 265 | + 'bulletListItem', |
| 266 | + 'numberedListItem', |
| 267 | + 'checkListItem', |
| 268 | + 'toggleListItem', |
| 269 | + 'quote', |
| 270 | + 'note', |
| 271 | + 'codeBlock', |
| 272 | + 'math', |
| 273 | + 'separator', |
| 274 | + 'table', |
| 275 | + 'figure', |
| 276 | + 'gallery', |
| 277 | + 'video', |
| 278 | + 'svg', |
| 279 | + 'customComponent', |
| 280 | +]); |
| 281 | +for (const b of blocks) if (!allowed.has(b.type)) throw new Error('bad type ' + b.type); |
| 282 | +for (const b of blocks) |
| 283 | + if (['svg', 'codeBlock'].includes(b.type) === false && b.content) JSON.stringify(b.content); |
| 284 | + |
| 285 | +fs.writeFileSync(OUT, JSON.stringify(doc, null, 2) + '\n'); |
| 286 | +const counts = {}; |
| 287 | +for (const b of blocks) counts[b.type] = (counts[b.type] ?? 0) + 1; |
| 288 | +console.log('blocks:', blocks.length, JSON.stringify(counts)); |
| 289 | +console.log('written:', OUT); |
0 commit comments