Files
dnd-vault-template/template/convert.js
2026-07-17 23:40:22 +02:00

376 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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]] → <img> / NuxtImg tag
* 4. ![[note]] → MDC ::include component
* 5. Callouts > [!TYPE] Title → MDC ::callout{type} blocks
* 6. Block IDs ^block-id → <span id="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 <NuxtImg> instead of <img> (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 = /(?<![#\w])#([a-zA-Z][a-zA-Z0-9_/-]*)/g;
for (const m of stripped.matchAll(tagRe)) {
tags.add(m[1]);
}
return [...tags];
}
/** Remove inline #tags from body (but not ATX headings) */
function stripInlineTags(body) {
return body.replace(/(?<![#\w])#([a-zA-Z][a-zA-Z0-9_/-]*)/g, "").replace(/ {2,}/g, " ");
}
// ─── Block IDs ───────────────────────────────────────────────────────────────
/**
* Replace trailing `^block-id` markers with an HTML anchor span.
* Obsidian places them at the end of a paragraph line.
*/
function convertBlockIds(body) {
return body.replace(/\s+\^([a-zA-Z0-9-]+)\s*$/gm, (_, id) => {
return ` <span id="${id}"></span>`;
});
}
// ─── 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 `<NuxtImg src="${src}"${attrs} alt="${path.basename(name, ext)}" />`;
}
return `<img src="${src}"${attrs} alt="${path.basename(name, ext)}" />`;
}
if (ext === ".mp4" || ext === ".webm") {
const src = `${IMG_PREFIX}/${name}`;
return `<video src="${src}" controls></video>`;
}
// 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();