#!/usr/bin/env node /** * obsidian-to-nuxt.js * * Converts an Obsidian vault's Markdown files to Nuxt Content-compatible pages. * * Transformations applied: * 1. [[Wiki links]] → [Wiki links](/slug) * 2. [[Wiki links|Alias]] → [Alias](/slug) * 3. ![[image.png]] → / tag * 4. ![[note]] → MDC ::include component * 5. Callouts > [!TYPE] Title → MDC ::callout{type} blocks * 6. Block IDs ^block-id → anchors * 7. Block refs [[note#^ref]] → [note](/note#block-ref) * 8. Heading links [[note#Head]] → [note](/note#heading-slug) * 9. Inline #tags → stripped (kept in frontmatter.tags) * 10. Frontmatter → passed through; tags array merged * * Usage: * node obsidian-to-nuxt.js --vault ./my-vault --out ./content --base-path /notes * * Options: * --vault Path to Obsidian vault root (default: ./vault) * --out Output directory for converted files (default: ./content) * --base-path URL prefix for all generated links (default: "") * --img-prefix URL prefix for embedded images (default: /images) * --nuxt-img Use instead of (flag) * --dry-run Print conversions without writing files (flag) */ import fs from "fs"; import path from "path"; // ─── CLI args (manual parser, no dependencies) ─────────────────────────────── function parseArgs() { const argv = process.argv.slice(2); const args = { vault: "./vault", out: "./content", "base-path": "", "img-prefix": "/images", "nuxt-img": false, "dry-run": false, }; for (let i = 0; i < argv.length; i++) { const key = argv[i].replace(/^--/, ""); if (argv[i].startsWith("--")) { const next = argv[i + 1]; if (!next || next.startsWith("--")) { // boolean flag args[key] = true; } else { args[key] = next; i++; } } } return args; } const args = parseArgs(); const VAULT_DIR = path.resolve(args["vault"]); const OUT_DIR = path.resolve(args["out"]); const BASE_PATH = (args["base-path"] || "").replace(/\/$/, ""); const IMG_PREFIX = (args["img-prefix"] || "/images").replace(/\/$/, ""); const USE_NUXT_IMG = args["nuxt-img"] === true || args["nuxt-img"] === "true"; const DRY_RUN = args["dry-run"] === true || args["dry-run"] === "true"; const IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".avif"]); // ─── Utilities ─────────────────────────────────────────────────────────────── function slugify(name) { return name .toLowerCase() .replace(/\s+/g, "-") .replace(/[^\w\-]/g, "") .replace(/--+/g, "-"); } function headingSlug(heading) { return heading .toLowerCase() .replace(/\s+/g, "-") .replace(/[^\w\-]/g, ""); } function collectMarkdownFiles(dir) { const results = []; for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, entry.name); if (entry.isDirectory()) { results.push(...collectMarkdownFiles(full)); } else if (entry.isFile() && entry.name.endsWith(".md")) { results.push(full); } } return results; } function buildNoteIndex(files) { const candidates = new Map(); function addCandidate(key, url, depth) { if (key && (!candidates.has(key) || depth < candidates.get(key).depth)) { candidates.set(key, { url, depth }); } } for (const file of files) { const stem = path.basename(file, ".md"); const rel = path.relative(VAULT_DIR, file).replace(/\.md$/, ""); const url = BASE_PATH + "/" + rel.split(path.sep).map(slugify).join("/"); const depth = rel.split(path.sep).length; const content = fs.readFileSync(file, "utf8"); // Key 1: exact lowercase filename stem e.g. "note2" addCandidate(stem.toLowerCase(), url, depth); // Key 2: slugified filename stem e.g. "note-two" from "Note Two.md" addCandidate(slugify(stem), url, depth); // Key 3: frontmatter title const fmMatch = content.match(/^---[\s\S]*?^title:\s*(.+)/m); if (fmMatch) { const title = fmMatch[1].trim().replace(/^["']|["']$/g, ""); addCandidate(title.toLowerCase(), url, depth); addCandidate(slugify(title), url, depth); } // Key 4: first H1 heading const h1Match = content.match(/^#\s+(.+)/m); if (h1Match) { const h1 = h1Match[1].trim(); addCandidate(h1.toLowerCase(), url, depth); addCandidate(slugify(h1), url, depth); } } const result = new Map(); for (const [k, v] of candidates.entries()) result.set(k, v.url); return result; } // ─── Frontmatter ───────────────────────────────────────────────────────────── const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/; function parseFrontmatter(content) { const match = content.match(FRONTMATTER_RE); if (!match) return { frontmatter: {}, body: content }; const raw = match[1]; const body = content.slice(match[0].length); const fm = {}; let currentKey = null; for (const line of raw.split("\n")) { const listItem = line.match(/^\s+-\s+(.+)/); if (listItem && currentKey) { if (!Array.isArray(fm[currentKey])) fm[currentKey] = []; fm[currentKey].push(listItem[1].trim()); continue; } const kv = line.match(/^(\w[\w-]*):\s*(.*)/); if (kv) { currentKey = kv[1]; const val = kv[2].trim(); fm[currentKey] = val === "" ? [] : val.replace(/^["']|["']$/g, ""); } } return { frontmatter: fm, body }; } function serializeFrontmatter(fm) { const lines = ["---"]; for (const [key, val] of Object.entries(fm)) { if (Array.isArray(val)) { lines.push(key + ":"); for (const v of val) lines.push(" - " + v); } else { lines.push(key + ": " + val); } } lines.push("---"); return lines.join("\n"); } // ─── Inline tags ───────────────────────────────────────────────────────────── function extractInlineTags(body) { const tags = new Set(); const stripped = body.replace(/```[\s\S]*?```/g, ""); const tagRe = /(?'; }); } // ─── Callouts ──────────────────────────────────────────────────────────────── const CALLOUT_START_RE = /^> \[!([A-Za-z]+)\][+-]?\s*(.*)/; function convertCallouts(body) { const lines = body.split("\n"); const out = []; let inCallout = false; for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (!inCallout) { const m = line.match(CALLOUT_START_RE); if (m) { const type = m[1].toLowerCase(); const title = m[2].trim(); const titleAttr = title ? ' title="' + title + '"' : ""; out.push('::callout{type="' + type + '"' + titleAttr + "}"); inCallout = true; } else { out.push(line); } } else { if (line.startsWith("> ")) { out.push(line.slice(2)); } else { out.push("::"); out.push(line); inCallout = false; } } } if (inCallout) out.push("::"); return out.join("\n"); } // ─── Embeds ![[...]] ───────────────────────────────────────────────────────── function convertEmbeds(body, noteIndex) { return body.replace(/!\[\[([^\]]+?)\]\]/g, function(_, ref) { const parts = ref.split("|"); const name = parts[0].trim(); const size = parts[1] ? parts[1].trim() : null; const ext = path.extname(name).toLowerCase(); if (IMAGE_EXTS.has(ext)) { const src = IMG_PREFIX + "/" + name; const altStr = path.basename(name, ext); const wAttr = size ? ' width="' + size.split("x")[0] + '"' : ""; if (USE_NUXT_IMG) { return ''; } return '' + altStr + ''; } if (ext === ".mp4" || ext === ".webm") { return ''; } // Note embed const stem = path.basename(name, ext || ".md"); const slug = noteIndex.get(stem.toLowerCase()) || (BASE_PATH + "/" + slugify(stem)); return '::include{path="' + slug + '"}'; }); } // ─── Wiki links [[...]] ────────────────────────────────────────────────────── function convertWikiLinks(body, noteIndex) { return body.replace(/\[\[([^\]]+?)\]\]/g, function(_, ref) { const pipeIdx = ref.indexOf("|"); let target, alias; if (pipeIdx !== -1) { target = ref.slice(0, pipeIdx).trim(); alias = ref.slice(pipeIdx + 1).trim(); } else { target = ref.trim(); alias = null; } const hashIdx = target.indexOf("#"); let notePart, anchor; if (hashIdx !== -1) { notePart = target.slice(0, hashIdx).trim(); anchor = target.slice(hashIdx + 1).trim(); } else { notePart = target; anchor = null; } const stem = path.basename(notePart, ".md"); const resolved = noteIndex.get(stem.toLowerCase()) || noteIndex.get(slugify(stem)); const dead = !resolved; const baseUrl = resolved || (BASE_PATH + "/" + slugify(stem)); let url = baseUrl; if (anchor) { url += "#" + (anchor.startsWith("^") ? anchor.slice(1) : headingSlug(anchor)); } const label = alias || (anchor ? stem + " \u203a " + anchor : stem); if (dead) { return '' + label + ''; } return "[" + label + "](" + url + ")"; }); } // ─── Convert one file ──────────────────────────────────────────────────────── function convertFile(content, noteIndex, filename) { let { frontmatter, body } = parseFrontmatter(content); const inlineTags = extractInlineTags(body); if (inlineTags.length) { const existing = Array.isArray(frontmatter.tags) ? frontmatter.tags : frontmatter.tags ? [frontmatter.tags] : []; const merged = Array.from(new Set(existing.concat(inlineTags))); frontmatter.tags = merged; body = stripInlineTags(body); } body = convertCallouts(body); body = convertBlockIds(body); body = convertEmbeds(body, noteIndex); body = convertWikiLinks(body, noteIndex); // Prepend a title if the body doesn't already start with an H1 heading const trimmed = body.trimStart(); const hasTitle = /^#[^#]/.test(trimmed); if (!hasTitle) { const title = frontmatter.title || path.basename(filename, ".md"); body = "# " + title + "\n\n" + trimmed; } const fmString = Object.keys(frontmatter).length ? serializeFrontmatter(frontmatter) + "\n\n" : ""; return fmString + body.trim() + "\n"; } // ─── Main ──────────────────────────────────────────────────────────────────── function main() { if (!fs.existsSync(VAULT_DIR)) { console.error("Vault directory not found: " + VAULT_DIR); process.exit(1); } const files = collectMarkdownFiles(VAULT_DIR); const noteIndex = buildNoteIndex(files); console.log("Found " + files.length + " markdown file(s) in " + VAULT_DIR); let doneCount = 0, skipCount = 0; for (const file of files) { const rel = path.relative(VAULT_DIR, file); const outPath = path.join( OUT_DIR, rel.split(path.sep) .map(function(seg, i, arr) { return i === arr.length - 1 ? seg : slugify(seg); }) .join(path.sep) ); const content = fs.readFileSync(file, "utf8"); let result; try { result = convertFile(content, noteIndex, path.basename(file)); } catch (err) { console.warn(" \u26A0 Skipping " + rel + ": " + err.message); skipCount++; continue; } if (DRY_RUN) { console.log("\n" + "─".repeat(60)); console.log("[DRY-RUN] " + rel + " \u2192 " + path.relative(process.cwd(), outPath)); console.log(""); console.log(result.slice(0, 800) + (result.length > 800 ? "\n\u2026" : "")); } else { fs.mkdirSync(path.dirname(outPath), { recursive: true }); fs.writeFileSync(outPath, result, "utf8"); console.log(" \u2713 " + rel + " \u2192 " + path.relative(process.cwd(), outPath)); } doneCount++; } console.log("\nDone. " + doneCount + " file(s) converted, " + skipCount + " skipped."); } main();