#!/usr/bin/env node // tolaria-to-blocknote.mjs // ============================================================================ // Tolaria .md → BlockNote Block JSON converter // ---------------------------------------------------------------------------- // MOB-OS-001-003 · 冰朔 → 铸渊 → 铭序 // 2026-07-03 23:58 CST · 国作登字-2026-A-00037559 // // 设计目标: // - 把 Tolaria vault(桌面 Tolaria笔记库/)的 .md 文件转为 BlockNote 0.46 的 // Block[] JSON,供光湖 OS 客户端使用 `useCreateBlockNote({ initialContent })` // 直接装载。 // - 不复制 .md 文件到新位置(等苍耳搭好骨架后铸渊协调)。 // - 保留 frontmatter 元数据到 sidecar `.meta.json`,wikilinks 索引到 // `.links.json`,Block[] 到 `.blocks.json`。 // - BlockNote 的 markdownToBlocks / markdownToBlocks 是 lossy 转换,且 // 不支持 wikilinks 语法,所以我们自己写一个严格、保留语义的解析器。 // // 用法: // node tolaria-to-blocknote.mjs --input --output // node tolaria-to-blocknote.mjs --input --output --recursive // // 输出文件(每个 .md): // .blocks.json BlockNote Block[] (主交付) // .meta.json frontmatter 元数据 + 解析时元信息 // .links.json wikilink 索引 + 关系引用清单 // .warnings.json 解析警告(嵌入本地图片、特殊 block、cell 格式 _sheet 等) // // 零外部依赖,纯 Node 24 ESM。 // ============================================================================ import fs from "node:fs"; import path from "node:path"; import crypto from "node:crypto"; import process from "node:process"; // ─── CLI ──────────────────────────────────────────────────────────────────── function parseArgs(argv) { const args = { input: null, output: null, recursive: false, help: false }; for (let i = 2; i < argv.length; i++) { const a = argv[i]; if (a === "--input" || a === "-i") args.input = argv[++i]; else if (a === "--output" || a === "-o") args.output = argv[++i]; else if (a === "--recursive" || a === "-r") args.recursive = true; else if (a === "--help" || a === "-h") args.help = true; else throw new Error(`unknown arg: ${a}`); } if (!args.input || !args.output) { console.error("usage: tolaria-to-blocknote.mjs --input --output [--recursive]"); process.exit(2); } return args; } // ─── Frontmatter (minimal YAML) ───────────────────────────────────────────── // 冰朔的 frontmatter 用法: // - string / multiline string // - list of scalars // - list of strings // - nested object (e.g. code_repo: { si010: brain/... }) // 我们的解析器只支撑 ice-gl 自家语法,够用就行,不复用 js-yaml(避免依赖)。 function parseFrontmatter(raw) { if (!raw.startsWith("---")) return { meta: {}, body: raw }; const end = raw.indexOf("\n---", 3); if (end < 0) return { meta: {}, body: raw }; const yamlText = raw.slice(3, end).trim(); const body = raw.slice(end + 4).replace(/^\r?\n/, ""); const meta = {}; const lines = yamlText.split(/\r?\n/); let i = 0; while (i < lines.length) { const line = lines[i]; if (!line.trim() || line.trim().startsWith("#")) { i++; continue; } const m = line.match(/^([A-Za-z0-9_\-]+):\s*(.*)$/); if (!m) { i++; continue; } const key = m[1]; let val = m[2]; // List with dash items on subsequent lines (only if next non-blank line // starts with a dash; otherwise fall through to nested-object detection) if (val === "" || val === "|") { // peek next non-blank line let j = i + 1; while (j < lines.length && lines[j].trim() === "") j++; const peek = j < lines.length ? lines[j] : ""; const isList = /^\s+-\s+/.test(peek); const isMap = /^\s{2,}[A-Za-z0-9_\-]+:/.test(peek); if (isList || (val === "|" && !isMap)) { const items = []; while (i + 1 < lines.length) { const nx = lines[i + 1]; const im = nx.match(/^\s+-\s+(.*)$/); if (!im) break; items.push(stripQuotes(im[1].trim())); i++; } meta[key] = items; i++; continue; } if (isMap) { const obj = {}; i++; while (i < lines.length) { const nx = lines[i]; const om = nx.match(/^\s+([A-Za-z0-9_\-]+):\s*(.*)$/); if (!om) break; obj[om[1]] = stripQuotes(om[2].trim()); i++; } meta[key] = obj; continue; } // empty value with nothing following — leave as "" meta[key] = ""; i++; continue; } // Multiline literal block "|" if (val === "|") { const buf = []; let baseIndent = null; i++; while (i < lines.length) { const ln = lines[i]; if (ln.trim() === "" || /^[A-Za-z0-9_\-]+:/.test(ln)) break; const ind = ln.match(/^(\s+)/)?.[1].length ?? 0; if (baseIndent === null) baseIndent = ind; buf.push(ln.slice(baseIndent)); i++; } meta[key] = buf.join("\n").trim(); continue; } // Nested object (next lines indented with ` key: value`) if (val === "" && i + 1 < lines.length && /^\s{2,}[A-Za-z0-9_\-]+:/.test(lines[i + 1])) { const obj = {}; i++; while (i < lines.length) { const nx = lines[i]; const om = nx.match(/^\s+([A-Za-z0-9_\-]+):\s*(.*)$/); if (!om) break; obj[om[1]] = stripQuotes(om[2].trim()); i++; } meta[key] = obj; continue; } meta[key] = stripQuotes(val.trim()); i++; } return { meta, body }; } // keep this stub so old reference compiles function _legacyNestedObject() { // (kept for reference; the loop above already handles nested objects) return null; } function stripQuotes(s) { if (s.startsWith('"') && s.endsWith('"')) return s.slice(1, -1); if (s.startsWith("'") && s.endsWith("'")) return s.slice(1, -1); return s; } // ─── Markdown → BlockNote line-level parser ───────────────────────────────── function parseMarkdownToBlocks(body, warnings) { const lines = body.split(/\r?\n/); const blocks = []; const ctx = { blocks, warnings }; let i = 0; while (i < lines.length) { const line = lines[i]; // skip pure blank lines between blocks if (line.trim() === "") { i++; continue; } // fenced code block if (/^```/.test(line)) { i = parseCodeBlock(lines, i, ctx); continue; } // heading const h = line.match(/^(#{1,6})\s+(.+?)\s*#*\s*$/); if (h) { ctx.blocks.push(makeHeading(parseInline(h[2], ctx.warnings), h[1].length)); i++; continue; } // hr / divider if (/^(\-{3,}|\*{3,}|_{3,})\s*$/.test(line)) { ctx.blocks.push(makeDivider()); i++; continue; } // blockquote (single or multi line contiguous) if (/^>\s?/.test(line)) { i = parseQuote(lines, i, ctx); continue; } // table if (/^\|.*\|/.test(line) && i + 1 < lines.length && /^\|?[\s\-:|]+\|?$/.test(lines[i + 1])) { i = parseTable(lines, i, ctx); continue; } // check list item const ck = line.match(/^(\s*)-\s+\[( |x|X)\]\s+(.*)$/); if (ck) { i = parseCheckList(lines, i, ctx); continue; } // unordered list item const ul = line.match(/^(\s*)[-*+]\s+(.*)$/); if (ul) { i = parseBulletList(lines, i, ctx); continue; } // ordered list item const ol = line.match(/^(\s*)(\d+)\.\s+(.*)$/); if (ol) { i = parseNumberedList(lines, i, ctx); continue; } // image on its own line const img = line.match(/^!\[([^\]]*)\]\(([^)]+)\)\s*$/); if (img) { ctx.blocks.push(makeImage(img[2], img[1], ctx.warnings)); i++; continue; } // paragraph (collect contiguous non-blank lines) i = parseParagraph(lines, i, ctx); } return ctx.blocks; } function makeId() { return crypto.randomUUID(); } // ─── Block builders ───────────────────────────────────────────────────────── function makeHeading(content, level) { // BlockNote default heading supports levels 1-3 by default; clamp. const lvl = Math.min(Math.max(level, 1), 6); return { id: makeId(), type: "heading", props: { level: lvl }, content: parseInlineContent(content, { bold: false, italic: false }), children: [], }; } function makeParagraph(text, warnings) { return { id: makeId(), type: "paragraph", props: {}, content: parseInlineContent(text, { bold: false, italic: false }, warnings), children: [], }; } function makeDivider() { return { id: makeId(), type: "divider", props: {}, content: [], children: [] }; } function makeQuote(content, warnings) { return { id: makeId(), type: "quote", props: {}, content: parseInlineContent(content, { bold: false, italic: false }, warnings), children: [], }; } function makeCodeBlock(language, code) { return { id: makeId(), type: "codeBlock", props: { language: language || "plaintext" }, content: [{ type: "text", text: code, styles: {} }], children: [], }; } function makeBulletItem(text, warnings) { return { id: makeId(), type: "bulletListItem", props: {}, content: parseInlineContent(text, { bold: false, italic: false }, warnings), children: [], }; } function makeNumberedItem(text, warnings) { return { id: makeId(), type: "numberedListItem", props: {}, content: parseInlineContent(text, { bold: false, italic: false }, warnings), children: [], }; } function makeCheckItem(text, checked, warnings) { return { id: makeId(), type: "checkListItem", props: { checked: !!checked }, content: parseInlineContent(text, { bold: false, italic: false }, warnings), children: [], }; } function makeImage(url, caption, warnings) { // image url that is local file or has empty body → warning if (!url || url.startsWith("./") || url.startsWith("../") || /^attachments\//.test(url) || /^image\//.test(url)) { warnings.push({ kind: "local_image", block: "image", url, message: "本地图片路径,BlockNote 需要 URL。苍耳搭好骨架后由铸渊上传到对象存储并替换。", }); } return { id: makeId(), type: "image", props: { url, caption: caption || "", previewWidth: undefined, }, content: undefined, children: [], }; } function makeTable(rows, warnings) { if (!rows.length) return makeParagraph("", warnings); // BlockNote table.content = { type: "tableContent", rows: [{ cells: InlineContent[][] }] } return { id: makeId(), type: "table", props: {}, content: { type: "tableContent", rows: rows.map((cells) => ({ cells: cells.map((cellText) => parseInlineContent(cellText, { bold: false, italic: false }, warnings) ), })), }, children: [], }; } // ─── Block parsers ────────────────────────────────────────────────────────── function parseCodeBlock(lines, i, ctx) { const open = lines[i]; const lang = open.replace(/^```/, "").trim() || "plaintext"; i++; const buf = []; while (i < lines.length && !/^```/.test(lines[i])) { buf.push(lines[i]); i++; } i++; // skip closing ``` ctx.blocks.push(makeCodeBlock(lang, buf.join("\n"))); return i; } function parseQuote(lines, i, ctx) { const buf = []; while (i < lines.length && /^>\s?/.test(lines[i])) { buf.push(lines[i].replace(/^>\s?/, "")); i++; } ctx.blocks.push(makeQuote(buf.join("\n"), ctx.warnings)); return i; } function parseParagraph(lines, i, ctx) { const buf = []; while ( i < lines.length && lines[i].trim() !== "" && !/^#{1,6}\s/.test(lines[i]) && !/^```/.test(lines[i]) && !/^>\s?/.test(lines[i]) && !/^(\s*)[-*+]\s+/.test(lines[i]) && !/^(\s*)\d+\.\s+/.test(lines[i]) && !/^\|.*\|/.test(lines[i]) && !/^(\-{3,}|\*{3,}|_{3,})\s*$/.test(lines[i]) ) { buf.push(lines[i]); i++; } ctx.blocks.push(makeParagraph(buf.join(" "), ctx.warnings)); return i; } function parseBulletList(lines, i, ctx) { // collect contiguous same-indent items (BlockNote nests via children) const startIndent = lines[i].match(/^(\s*)/)[1].length; const stack = [{ indent: startIndent, blocks: ctx.blocks }]; while ( i < lines.length && /^(\s*)[-*+]\s+/.test(lines[i]) && lines[i].match(/^(\s*)/)[1].length >= stack[stack.length - 1].indent ) { const indent = lines[i].match(/^(\s*)/)[1].length; const text = lines[i].replace(/^(\s*)[-*+]\s+/, ""); // pop deeper indents while (stack.length > 1 && indent < stack[stack.length - 1].indent) stack.pop(); const parent = stack[stack.length - 1].blocks; const newBlock = makeBulletItem(text, ctx.warnings); parent.push(newBlock); stack.push({ indent: indent + 2, blocks: newBlock.children }); i++; } return i; } function parseNumberedList(lines, i, ctx) { const startIndent = lines[i].match(/^(\s*)/)[1].length; const stack = [{ indent: startIndent, blocks: ctx.blocks }]; while ( i < lines.length && /^(\s*)\d+\.\s+/.test(lines[i]) && lines[i].match(/^(\s*)/)[1].length >= stack[stack.length - 1].indent ) { const indent = lines[i].match(/^(\s*)/)[1].length; const text = lines[i].replace(/^(\s*)\d+\.\s+/, ""); while (stack.length > 1 && indent < stack[stack.length - 1].indent) stack.pop(); const parent = stack[stack.length - 1].blocks; const newBlock = makeNumberedItem(text, ctx.warnings); parent.push(newBlock); stack.push({ indent: indent + 3, blocks: newBlock.children }); i++; } return i; } function parseCheckList(lines, i, ctx) { const startIndent = lines[i].match(/^(\s*)/)[1].length; const stack = [{ indent: startIndent, blocks: ctx.blocks }]; while ( i < lines.length && /^(\s*)-\s+\[( |x|X)\]\s+/.test(lines[i]) && lines[i].match(/^(\s*)/)[1].length >= stack[stack.length - 1].indent ) { const indent = lines[i].match(/^(\s*)/)[1].length; const checked = /\[x\]/i.test(lines[i]); const text = lines[i].replace(/^(\s*)-\s+\[( |x|X)\]\s+/, ""); while (stack.length > 1 && indent < stack[stack.length - 1].indent) stack.pop(); const parent = stack[stack.length - 1].blocks; const newBlock = makeCheckItem(text, checked, ctx.warnings); parent.push(newBlock); stack.push({ indent: indent + 2, blocks: newBlock.children }); i++; } return i; } function parseTable(lines, i, ctx) { const headerLine = lines[i]; const headers = splitTableRow(headerLine); i++; // separator line i++; const rows = [headers]; while (i < lines.length && /^\|.*\|/.test(lines[i])) { rows.push(splitTableRow(lines[i])); i++; } ctx.blocks.push(makeTable(rows, ctx.warnings)); return i; } function splitTableRow(line) { // | a | b | c | → ["a","b","c"] ; trim each cell return line.replace(/^\|/, "").replace(/\|\s*$/, "").split("|").map((c) => c.trim()); } // ─── Inline parser ────────────────────────────────────────────────────────── // 支持 inline: **bold** *italic* ***both*** ~~strike~~ `code` [text](url) // [[wikilink]] {{frontmatter-ref}} [text][ref] (后面两个警告) // BlockNote InlineContent 数组,相邻文本可合并。 function parseInlineContent(text, flags, warnings) { const out = []; if (!text) return out; // regex with capture groups for all inline patterns // 顺序: code -> bold/italic/strike -> link -> wikilink -> plain text const re = /(`[^`\n]+`)|(\*\*\*(.+?)\*\*\*)|(\*\*(.+?)\*\*)|(\*(.+?)\*)|(~~(.+?)~~)|(\[\[([^\]]+)\]\])|(\[([^\]]+)\]\(([^)]+)\))|(\[([^\]]+)\]\[([^\]]+)\])/g; let lastIdx = 0; let m; while ((m = re.exec(text)) !== null) { if (m.index > lastIdx) { pushText(out, text.slice(lastIdx, m.index), flags); } if (m[1]) { // inline code const code = m[1].slice(1, -1); out.push({ type: "text", text: code, styles: { ...flags, code: true } }); } else if (m[3]) { // bold + italic out.push(...parseInlineContent(m[3], { ...flags, bold: true, italic: true }, warnings)); } else if (m[5]) { out.push(...parseInlineContent(m[5], { ...flags, bold: true }, warnings)); } else if (m[7]) { out.push(...parseInlineContent(m[7], { ...flags, italic: true }, warnings)); } else if (m[9]) { // strike out.push(...parseInlineContent(m[9], { ...flags, strike: true }, warnings)); } else if (m[11]) { // wikilink → 转换为 link,href = "tolaria://note/" const target = m[11].trim(); const href = `tolaria://note/${encodeURIComponent(target)}`; warnings.push({ kind: "wikilink", target, message: `[[${target}]] → 转成 link block,href=tolaria://note/。前端需要监听并跳转到对应笔记。`, }); out.push({ type: "link", href, content: [{ type: "text", text: target, styles: { ...flags } }], }); } else if (m[14]) { // [text](url) const linkText = m[13]; const url = m[14]; out.push({ type: "link", href: url, content: parseInlineContent(linkText, flags, warnings), }); } else if (m[18]) { // reference-style link [text][ref] — warning warnings.push({ kind: "ref_link", message: `reference-style link [${m[17]}][${m[18]}] 不被支持,转成普通文本。`, }); pushText(out, m[0], flags); } lastIdx = m.index + m[0].length; } if (lastIdx < text.length) { pushText(out, text.slice(lastIdx), flags); } return mergeAdjacentText(out); } function pushText(out, text, flags) { if (!text) return; out.push({ type: "text", text, styles: { ...flags } }); } function mergeAdjacentText(arr) { const out = []; for (const x of arr) { const last = out[out.length - 1]; if ( last && last.type === "text" && x.type === "text" && JSON.stringify(last.styles) === JSON.stringify(x.styles) ) { last.text += x.text; } else { out.push(x); } } return out; } // aliases used by markdown parser — parseInline returns a string for // parseInlineContent contract on heading / paragraph function parseInline(text, warnings) { return text; // placeholder, real parsing happens in content builders } // ─── Wikilink + frontmatter-ref extractor ─────────────────────────────────── function extractReferences(body) { const wikilinks = []; const imageRefs = []; const re = /!\[\[([^\]]+)\]\]/g; let m; while ((m = re.exec(body)) !== null) { imageRefs.push({ target: m[1], kind: "embedded_image" }); } const re2 = /(?|]/g, "_"); fs.writeFileSync( path.join(outDir, `${safeName}.blocks.json`), JSON.stringify(result.blocks, null, 2), ); fs.writeFileSync( path.join(outDir, `${safeName}.meta.json`), JSON.stringify({ source: result.source, frontmatter: result.frontmatter, wikilinks: result.wikilinks, imageRefs: result.imageRefs, }, null, 2), ); if (result.warnings.length) { fs.writeFileSync( path.join(outDir, `${safeName}.warnings.json`), JSON.stringify(result.warnings, null, 2), ); } } // ─── Entry ────────────────────────────────────────────────────────────────── function main() { const args = parseArgs(process.argv); if (args.help) { console.log("tolaria-to-blocknote.mjs — Tolaria .md → BlockNote Block JSON"); console.log("usage: node tolaria-to-blocknote.mjs --input --output [--recursive]"); return; } const inPath = path.resolve(args.input); const outPath = path.resolve(args.output); const stat = fs.statSync(inPath); if (stat.isFile()) { const baseName = path.basename(inPath, ".md"); const result = convertFile(inPath); writeOutputs(outPath, baseName, result); console.log(`✓ ${inPath} → ${outPath}/${baseName}.{blocks,meta,warnings}.json (warnings=${result.warnings.length})`); return; } if (stat.isDirectory()) { let files = []; if (args.recursive) { const stack = [inPath]; while (stack.length) { const p = stack.pop(); for (const ent of fs.readdirSync(p, { withFileTypes: true })) { const full = path.join(p, ent.name); if (ent.isDirectory()) stack.push(full); else if (ent.isFile() && ent.name.endsWith(".md")) files.push(full); } } } else { files = fs.readdirSync(inPath) .filter((n) => n.endsWith(".md")) .map((n) => path.join(inPath, n)); } let ok = 0, warn = 0, err = 0; for (const f of files) { try { const rel = path.relative(inPath, f); const baseName = rel.replace(/\.md$/, "").replace(/\//g, "__"); const result = convertFile(f); writeOutputs(outPath, baseName, result); if (result.warnings.length) warn++; else ok++; } catch (e) { console.error(`✗ ${f}: ${e.message}`); err++; } } console.log(`\n${files.length} files → ok=${ok} with-warnings=${warn} error=${err}`); console.log(`output dir: ${outPath}`); } } main();