jdksjkj
This commit is contained in:
@@ -1,24 +1 @@
|
|||||||
# Welcome to Nuxt Content Starter
|
Hola que tal!
|
||||||
|
|
||||||
This is the main page displaying Markdown located at [content/index.md](https://github.com/nuxt/starter/blob/content/content/index.md).
|
|
||||||
|
|
||||||
Move to [about](/about) page.
|
|
||||||
|
|
||||||
## Manage your Contents
|
|
||||||
|
|
||||||
Create new pages or modify the existing ones in `content/` directory.
|
|
||||||
|
|
||||||
## Query & Render Pages
|
|
||||||
|
|
||||||
You can find an example of querying contents and rendering them in a [catch-all page](https://github.com/nuxt/starter/blob/content/app/pages/%5B...slug%5D.vue)
|
|
||||||
|
|
||||||
## Integrate Vue Component
|
|
||||||
|
|
||||||
::alert{color="green"}
|
|
||||||
The current [alert](https://github.com/nuxt/starter/blob/content/app/components/Alert.vue) and the [counter](https://github.com/nuxt/starter/blob/content/app/components/Counter.vue) below are `Vue` components integrated into the Markdown.
|
|
||||||
::
|
|
||||||
|
|
||||||
::counter
|
|
||||||
::
|
|
||||||
|
|
||||||
Checkout out the [documentation](https://content.nuxt.com/docs/getting-started) to learn more.
|
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
#!/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();
|
||||||
Reference in New Issue
Block a user