铸渊 ICE-GL-ZY001 · 冰朔 TCS-0002∞ 21361b287f
Some checks failed
自动更新代码和重启 / update-and-restart (push) Has been cancelled
CI检查 + 自动部署 / check (push) Has been cancelled
CI检查 + 自动部署 / deploy (push) Has been cancelled
MOB-OS-001 完整收尾 · 8 hdlp 归档 + RETROSPECTIVE 5 大误判 + CLOSEOUT 4 sibling 解散
- 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
2026-07-04 00:39:24 +08:00

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();