#!/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]] → / NuxtImg 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"; import { parseArgs } from "util"; // ─── CLI args ──────────────────────────────────────────────────────────────── const { values: args } = parseArgs({ options: { vault: { type: "string", default: "./vault" }, out: { type: "string", default: "./content" }, "base-path":{ type: "string", default: "" }, "img-prefix":{ type: "string", default: "/images" }, "nuxt-img": { type: "boolean", default: false }, "dry-run": { type: "boolean", default: false }, }, }); 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"].replace(/\/$/, ""); const USE_NUXT_IMG = args["nuxt-img"]; const DRY_RUN = args["dry-run"]; const IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".avif"]); // ─── Utilities ─────────────────────────────────────────────────────────────── /** Turn a note name / file stem into a URL-safe slug */ function slugify(name) { return name .toLowerCase() .replace(/\s+/g, "-") .replace(/[^\w\-]/g, "") .replace(/--+/g, "-"); } /** Turn a heading string into a GitHub-style anchor slug */ function headingSlug(heading) { return heading .toLowerCase() .replace(/\s+/g, "-") .replace(/[^\w\-]/g, ""); } /** Recursively collect all .md files in a directory */ 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; } /** * Build a lookup map: note stem (lowercase) → relative URL path * Handles duplicate names by preferring the shortest path (vault-root wins). */ function buildNoteIndex(files) { const index = new Map(); 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 key = stem.toLowerCase(); if (!index.has(key) || rel.split(path.sep).length < index.get(key).depth) { index.set(key, { url, depth: rel.split(path.sep).length }); } } return new Map([...index.entries()].map(([k, v]) => [k, v.url])); } // ─── Frontmatter helpers ───────────────────────────────────────────────────── 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); // Minimal YAML parser (handles string scalars and simple arrays) const fm = {}; let currentKey = null; for (const line of raw.split("\n")) { const listItem = line.match(/^\s+-\s+(.+)/); if (listItem && currentKey) { fm[currentKey] = 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-tag extractor ──────────────────────────────────────────────────── /** Pull #tags from body text (skip # inside code fences or headings) */ function extractInlineTags(body) { const tags = new Set(); // Skip fenced code blocks const stripped = body.replace(/```[\s\S]*?```/g, ""); const tagRe = /(? { return ` `; }); } // ─── Callouts ──────────────────────────────────────────────────────────────── const CALLOUT_START_RE = /^> \[!([A-Z]+)\]([+-]?)\s*(.*)/i; /** * Convert Obsidian callout blocks to MDC (Nuxt Content) component syntax: * * > [!NOTE] Title ::callout{type="note" title="Title"} * > content content * :: */ 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[3].trim(); const titleAttr = title ? ` title="${title}"` : ""; out.push(`::callout{type="${type}"${titleAttr}}`); inCallout = true; continue; } out.push(line); } else { // Continuation lines start with "> " or are blank (closing the block) if (line.startsWith("> ")) { out.push(line.slice(2)); // strip "> " } else { out.push("::"); // close MDC block out.push(line); inCallout = false; } } } if (inCallout) out.push("::"); return out.join("\n"); } // ─── Embedded content ![[...]] ────────────────────────────────────────────── function convertEmbeds(body, noteIndex) { return body.replace(/!\[\[([^\]]+?)\]\]/g, (_, ref) => { const [rawName, size] = ref.split("|"); const name = rawName.trim(); const ext = path.extname(name).toLowerCase(); if (IMAGE_EXTS.has(ext)) { // Image embed const src = `${IMG_PREFIX}/${name}`; const attrs = size ? ` width="${size.split("x")[0]}"` : ""; if (USE_NUXT_IMG) { return ``; } return `${path.basename(name, ext)}`; } if (ext === ".mp4" || ext === ".webm") { const src = `${IMG_PREFIX}/${name}`; return ``; } // Note embed → MDC include component 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, (_, ref) => { // Split off alias: [[Note|Alias]] const [target, alias] = ref.split("|").map(s => s.trim()); // Split off heading or block ref: [[Note#Heading]] [[Note#^block]] const [notePart, anchor] = target.split("#").map(s => s.trim()); const stem = path.basename(notePart, ".md"); const baseUrl = noteIndex.get(stem.toLowerCase()) ?? `${BASE_PATH}/${slugify(stem)}`; let url = baseUrl; if (anchor) { if (anchor.startsWith("^")) { // Block reference url += "#" + anchor.slice(1); } else { // Heading reference url += "#" + headingSlug(anchor); } } const label = alias || (anchor ? `${stem} › ${anchor}` : stem); return `[${label}](${url})`; }); } // ─── Main conversion ────────────────────────────────────────────────────────── function convertFile(content, noteIndex) { let { frontmatter, body } = parseFrontmatter(content); // 1. Extract and merge inline tags into frontmatter const inlineTags = extractInlineTags(body); if (inlineTags.length) { const existing = Array.isArray(frontmatter.tags) ? frontmatter.tags : frontmatter.tags ? [frontmatter.tags] : []; frontmatter.tags = [...new Set([...existing, ...inlineTags])]; body = stripInlineTags(body); } // 2. Callout blocks (before wiki-link pass so "> [[link]]" still works) body = convertCallouts(body); // 3. Block IDs body = convertBlockIds(body); // 4. Embedded files ![[...]] body = convertEmbeds(body, noteIndex); // 5. Wiki links [[...]] body = convertWikiLinks(body, noteIndex); const fmString = Object.keys(frontmatter).length ? serializeFrontmatter(frontmatter) + "\n\n" : ""; return fmString + body.trim() + "\n"; } // ─── Entry point ────────────────────────────────────────────────────────────── 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 files in ${VAULT_DIR}`); let converted = 0, skipped = 0; for (const file of files) { const rel = path.relative(VAULT_DIR, file); const outPath = path.join( OUT_DIR, rel .split(path.sep) .map((seg, i, arr) => i === arr.length - 1 ? seg : slugify(seg)) .join(path.sep) ); const content = fs.readFileSync(file, "utf8"); let converted_content; try { converted_content = convertFile(content, noteIndex); } catch (err) { console.warn(` ⚠ Skipping ${rel}: ${err.message}`); skipped++; continue; } if (DRY_RUN) { console.log(`\n${"─".repeat(60)}\n[DRY-RUN] ${rel} → ${path.relative(process.cwd(), outPath)}\n`); console.log(converted_content.slice(0, 500) + (converted_content.length > 500 ? "\n…" : "")); } else { fs.mkdirSync(path.dirname(outPath), { recursive: true }); fs.writeFileSync(outPath, converted_content, "utf8"); console.log(` ✓ ${rel} → ${path.relative(process.cwd(), outPath)}`); } converted++; } console.log(`\nDone. ${converted} file(s) converted, ${skipped} skipped.`); } main();