This commit is contained in:
2026-07-18 01:32:38 +02:00
parent 270b292f6d
commit fcd25d4239
32 changed files with 865 additions and 236 deletions

View File

@@ -8,7 +8,7 @@
* Transformations applied:
* 1. [[Wiki links]] → [Wiki links](/slug)
* 2. [[Wiki links|Alias]] → [Alias](/slug)
* 3. ![[image.png]] → <img> / NuxtImg tag
* 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
@@ -21,43 +21,61 @@
* 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)
* --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 fs from "fs";
import path from "path";
import { parseArgs } from "util";
// ─── CLI args ────────────────────────────────────────────────────────────────
// ─── CLI args (manual parser, no dependencies) ───────────────────────────────
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 },
},
});
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,
};
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"];
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 ───────────────────────────────────────────────────────────────
/** Turn a note name / file stem into a URL-safe slug */
function slugify(name) {
return name
.toLowerCase()
@@ -66,7 +84,6 @@ function slugify(name) {
.replace(/--+/g, "-");
}
/** Turn a heading string into a GitHub-style anchor slug */
function headingSlug(heading) {
return heading
.toLowerCase()
@@ -74,7 +91,6 @@ function headingSlug(heading) {
.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 })) {
@@ -88,25 +104,50 @@ function collectMarkdownFiles(dir) {
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 });
const candidates = new Map();
function addCandidate(key, url, depth) {
if (key && (!candidates.has(key) || depth < candidates.get(key).depth)) {
candidates.set(key, { url, depth });
}
}
return new Map([...index.entries()].map(([k, v]) => [k, v.url]));
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 helpers ─────────────────────────────────────────────────────
// ─── Frontmatter ─────────────────────────────────────────────────────────────
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/;
@@ -114,16 +155,15 @@ function parseFrontmatter(content) {
const match = content.match(FRONTMATTER_RE);
if (!match) return { frontmatter: {}, body: content };
const raw = match[1];
const raw = match[1];
const body = content.slice(match[0].length);
// Minimal YAML parser (handles string scalars and simple arrays)
const fm = {};
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] : [];
if (!Array.isArray(fm[currentKey])) fm[currentKey] = [];
fm[currentKey].push(listItem[1].trim());
continue;
}
@@ -141,58 +181,45 @@ 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}`);
lines.push(key + ":");
for (const v of val) lines.push(" - " + v);
} else {
lines.push(`${key}: ${val}`);
lines.push(key + ": " + val);
}
}
lines.push("---");
return lines.join("\n");
}
// ─── Inline-tag extractor ────────────────────────────────────────────────────
// ─── Inline tags ─────────────────────────────────────────────────────────────
/** 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]);
}
let m;
while ((m = tagRe.exec(stripped)) !== null) 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, " ");
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>`;
return body.replace(/[ \t]+\^([a-zA-Z0-9-]+)\s*$/gm, function(_, id) {
return ' <span id="' + id + '"></span>';
});
}
// ─── Callouts ────────────────────────────────────────────────────────────────
const CALLOUT_START_RE = /^> \[!([A-Z]+)\]([+-]?)\s*(.*)/i;
const CALLOUT_START_RE = /^> \[!([A-Za-z]+)\][+-]?\s*(.*)/;
/**
* 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 = [];
@@ -200,122 +227,132 @@ function convertCallouts(body) {
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}}`);
const title = m[2].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);
}
} 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");
}
// ─── Embedded content ![[...]] ──────────────────────────────────────────────
// ─── Embeds ![[...]] ─────────────────────────────────────────────────────────
function convertEmbeds(body, noteIndex) {
return body.replace(/!\[\[([^\]]+?)\]\]/g, (_, ref) => {
const [rawName, size] = ref.split("|");
const name = rawName.trim();
const ext = path.extname(name).toLowerCase();
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)) {
// Image embed
const src = `${IMG_PREFIX}/${name}`;
const attrs = size ? ` width="${size.split("x")[0]}"` : "";
const src = IMG_PREFIX + "/" + name;
const altStr = path.basename(name, ext);
const wAttr = size ? ' width="' + size.split("x")[0] + '"' : "";
if (USE_NUXT_IMG) {
return `<NuxtImg src="${src}"${attrs} alt="${path.basename(name, ext)}" />`;
return '<NuxtImg src="' + src + '"' + wAttr + ' alt="' + altStr + '" />';
}
return `<img src="${src}"${attrs} alt="${path.basename(name, ext)}" />`;
return '<img src="' + src + '"' + wAttr + ' alt="' + altStr + '" />';
}
if (ext === ".mp4" || ext === ".webm") {
const src = `${IMG_PREFIX}/${name}`;
return `<video src="${src}" controls></video>`;
return '<video src="' + IMG_PREFIX + "/" + name + '" controls></video>';
}
// Note embed → MDC include component
// Note embed
const stem = path.basename(name, ext || ".md");
const slug = noteIndex.get(stem.toLowerCase()) ?? `${BASE_PATH}/${slugify(stem)}`;
return `::include{path="${slug}"}`;
const slug = noteIndex.get(stem.toLowerCase()) || (BASE_PATH + "/" + slugify(stem));
return '::include{path="' + slug + '"}';
});
}
// ─── Wiki links [[...]] ─────────────────────────────────────────────────────
// ─── 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());
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;
}
// Split off heading or block ref: [[Note#Heading]] [[Note#^block]]
const [notePart, anchor] = target.split("#").map(s => s.trim());
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 baseUrl = noteIndex.get(stem.toLowerCase()) ?? `${BASE_PATH}/${slugify(stem)}`;
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) {
if (anchor.startsWith("^")) {
// Block reference
url += "#" + anchor.slice(1);
} else {
// Heading reference
url += "#" + headingSlug(anchor);
}
url += "#" + (anchor.startsWith("^") ? anchor.slice(1) : headingSlug(anchor));
}
const label = alias || (anchor ? `${stem} ${anchor}` : stem);
return `[${label}](${url})`;
const label = alias || (anchor ? stem + " \u203a " + anchor : stem);
if (dead) {
return '<a href="' + url + '" class="dead-link" data-missing="' + stem + '">' + label + '</a>';
}
return "[" + label + "](" + url + ")";
});
}
// ─── Main conversion ──────────────────────────────────────────────────────────
// ─── Convert one file ────────────────────────────────────────────────────────
function convertFile(content, noteIndex) {
function convertFile(content, noteIndex, filename) {
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])];
: frontmatter.tags ? [frontmatter.tags] : [];
const merged = Array.from(new Set(existing.concat(inlineTags)));
frontmatter.tags = merged;
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);
// 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"
: "";
@@ -323,54 +360,55 @@ function convertFile(content, noteIndex) {
return fmString + body.trim() + "\n";
}
// ─── Entry point ──────────────────────────────────────────────────────────────
// ─── Main ────────────────────────────────────────────────────────────────────
function main() {
if (!fs.existsSync(VAULT_DIR)) {
console.error(`Vault directory not found: ${VAULT_DIR}`);
console.error("Vault directory not found: " + VAULT_DIR);
process.exit(1);
}
const files = collectMarkdownFiles(VAULT_DIR);
const noteIndex = buildNoteIndex(files);
const files = collectMarkdownFiles(VAULT_DIR);
const noteIndex = buildNoteIndex(files);
console.log(`Found ${files.length} markdown files in ${VAULT_DIR}`);
console.log("Found " + files.length + " markdown file(s) in " + VAULT_DIR);
let converted = 0, skipped = 0;
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((seg, i, arr) => i === arr.length - 1 ? seg : slugify(seg))
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 converted_content;
const content = fs.readFileSync(file, "utf8");
let result;
try {
converted_content = convertFile(content, noteIndex);
result = convertFile(content, noteIndex, path.basename(file));
} catch (err) {
console.warn(` Skipping ${rel}: ${err.message}`);
skipped++;
console.warn(" \u26A0 Skipping " + rel + ": " + err.message);
skipCount++;
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…" : ""));
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, converted_content, "utf8");
console.log(`${rel}${path.relative(process.cwd(), outPath)}`);
fs.writeFileSync(outPath, result, "utf8");
console.log(" \u2713 " + rel + " \u2192 " + path.relative(process.cwd(), outPath));
}
converted++;
doneCount++;
}
console.log(`\nDone. ${converted} file(s) converted, ${skipped} skipped.`);
console.log("\nDone. " + doneCount + " file(s) converted, " + skipCount + " skipped.");
}
main();