- MOB-OS-001-RETROSPECTIVE.hdlp(经验回执·5 大误判 + 4 次拦截 + 10 句话教训) - MOB-OS-001-CLOSEOUT.hdlp(关闭回执·4 sibling 解散命令) - MOB-OS-001-POLL-LOG.hdlp(主控轮询日志·从 guanghulab-product 移过来) - 5/5 验证全过 · 8.5/10 复位 · 待冰朔视觉验收 - 团队原地解散 · 等待下次复盘和重启 铸渊 ICE-GL-ZY001 · 冰朔 TCS-0002∞ · 2026-07-04 00:38 CST
715 lines
23 KiB
JavaScript
715 lines
23 KiB
JavaScript
#!/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 <md-file> --output <json-file>
|
|
// node tolaria-to-blocknote.mjs --input <vault-dir> --output <out-dir> --recursive
|
|
//
|
|
// 输出文件(每个 .md):
|
|
// <name>.blocks.json BlockNote Block[] (主交付)
|
|
// <name>.meta.json frontmatter 元数据 + 解析时元信息
|
|
// <name>.links.json wikilink 索引 + 关系引用清单
|
|
// <name>.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 <path> --output <path> [--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/<encoded target>"
|
|
const target = m[11].trim();
|
|
const href = `tolaria://note/${encodeURIComponent(target)}`;
|
|
warnings.push({
|
|
kind: "wikilink",
|
|
target,
|
|
message: `[[${target}]] → 转成 link block,href=tolaria://note/<encoded>。前端需要监听并跳转到对应笔记。`,
|
|
});
|
|
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;
|
|
while ((m = re2.exec(body)) !== null) {
|
|
wikilinks.push({ target: m[1].trim(), kind: "wikilink" });
|
|
}
|
|
return { wikilinks, imageRefs };
|
|
}
|
|
|
|
// ─── File-level converter ───────────────────────────────────────────────────
|
|
|
|
function convertFile(mdPath) {
|
|
const raw = fs.readFileSync(mdPath, "utf8");
|
|
const { meta, body } = parseFrontmatter(raw);
|
|
const warnings = [];
|
|
const blocks = parseMarkdownToBlocks(body, warnings);
|
|
const { wikilinks, imageRefs } = extractReferences(body);
|
|
|
|
// special frontmatter handling — `_sheet`, `_display`
|
|
const specialNotes = [];
|
|
if (meta._display === "sheet" || meta._sheet) {
|
|
specialNotes.push({
|
|
kind: "_sheet",
|
|
message:
|
|
"_display=sheet + _sheet cell-formatting 在 demo-vault-v2 是 spreadsheet 自定义渲染,BlockNote 默认 table 不支持 cell 级加粗/斜体/列宽。需要苍耳搭好骨架后扩展 BlockNote schema 或做后处理。",
|
|
});
|
|
}
|
|
|
|
return {
|
|
source: mdPath,
|
|
frontmatter: meta,
|
|
blocks,
|
|
wikilinks,
|
|
imageRefs,
|
|
warnings: [...warnings, ...specialNotes],
|
|
};
|
|
}
|
|
|
|
// ─── Output writers ─────────────────────────────────────────────────────────
|
|
|
|
function writeOutputs(outDir, baseName, result) {
|
|
fs.mkdirSync(outDir, { recursive: true });
|
|
const safeName = baseName.replace(/[\\/:*?"<>|]/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 <path> --output <path> [--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(); |