446 lines
15 KiB
JavaScript
446 lines
15 KiB
JavaScript
#!/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
|
|
*
|
|
* VISIBILITY GATE:
|
|
* Only notes whose frontmatter contains `visible: true` are converted.
|
|
* Any note missing the field, or with `visible: false`, is silently skipped.
|
|
*
|
|
* 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";
|
|
|
|
// ─── 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();
|
|
// Coerce boolean-like values so `visible: true` becomes the boolean true
|
|
if (val === "true") fm[currentKey] = true;
|
|
else if (val === "false") fm[currentKey] = false;
|
|
else fm[currentKey] = val === "" ? [] : val.replace(/^["']|["']$/g, "");
|
|
}
|
|
}
|
|
return { frontmatter: fm, body };
|
|
}
|
|
|
|
/**
|
|
* Returns true only when the frontmatter contains `visible: true` (boolean).
|
|
* Missing field, `visible: false`, or any other value → false.
|
|
*/
|
|
function isVisible(frontmatter) {
|
|
return frontmatter.visible === true;
|
|
}
|
|
|
|
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 = /(?<![#\w])#([a-zA-Z][a-zA-Z0-9_/-]*)/g;
|
|
let m;
|
|
while ((m = tagRe.exec(stripped)) !== null) tags.add(m[1]);
|
|
return [...tags];
|
|
}
|
|
|
|
function stripInlineTags(body) {
|
|
return body
|
|
.replace(/(?<![#\w])#([a-zA-Z][a-zA-Z0-9_/-]*)/g, "")
|
|
.replace(/ {2,}/g, " ");
|
|
}
|
|
|
|
// ─── Block IDs ───────────────────────────────────────────────────────────────
|
|
|
|
function convertBlockIds(body) {
|
|
return body.replace(/[ \t]+\^([a-zA-Z0-9-]+)\s*$/gm, function(_, id) {
|
|
return ' <span id="' + id + '"></span>';
|
|
});
|
|
}
|
|
|
|
// ─── 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 '<NuxtImg src="' + src + '"' + wAttr + ' alt="' + altStr + '" />';
|
|
}
|
|
return '<img src="' + src + '"' + wAttr + ' alt="' + altStr + '" />';
|
|
}
|
|
|
|
if (ext === ".mp4" || ext === ".webm") {
|
|
return '<video src="' + IMG_PREFIX + "/" + name + '" controls></video>';
|
|
}
|
|
|
|
// 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 '<a href="' + url + '" class="dead-link" data-missing="' + stem + '">' + label + '</a>';
|
|
}
|
|
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");
|
|
if (title != "index") {
|
|
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, hiddenCount = 0;
|
|
|
|
for (const file of files) {
|
|
const rel = path.relative(VAULT_DIR, file);
|
|
const content = fs.readFileSync(file, "utf8");
|
|
|
|
// ── Visibility gate ──────────────────────────────────────────────────────
|
|
// Parse frontmatter just enough to check the `visible` field.
|
|
// Notes without `visible: true` are skipped entirely.
|
|
const { frontmatter: fm } = parseFrontmatter(content);
|
|
if (!isVisible(fm)) {
|
|
console.log(" \u23ED " + rel + " (skipped — no visible: true)");
|
|
hiddenCount++;
|
|
continue;
|
|
}
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
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)
|
|
);
|
|
|
|
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, " +
|
|
hiddenCount + " hidden (no visible: true), " +
|
|
skipCount + " skipped (error)."
|
|
);
|
|
}
|
|
|
|
main(); |