/** * Checks that need every note at once: unique names, unique slugs and links * that resolve. The content service runs these on each change; the migration * runs them on its output. */ import { slugify, wikilinkTarget, wikilinksIn } from "./names.ts"; import { typeDef } from "./notes.ts"; export interface VaultNote { /** Path under the Site folder, like `Library/Books/Cosmere.md`. */ path: string; type: string; data: Record; body: string; } export interface VaultProblem { path: string; message: string; } /** The name Obsidian resolves a wikilink against: the file name without `.md`. */ export const linkName = (path: string) => path.replace(/^.*\//, "").replace(/\.md$/, ""); /** Every wikilink a note makes, from frontmatter and body. */ export function outgoingLinks(note: VaultNote): string[] { const found = new Set(); const visit = (v: unknown) => { if (typeof v === "string") { const t = wikilinkTarget(v); if (t) found.add(t); } else if (Array.isArray(v)) v.forEach(visit); else if (v && typeof v === "object") Object.values(v).forEach(visit); }; visit(note.data); for (const t of wikilinksIn(note.body)) found.add(t); return [...found]; } const IMAGE = /\.(jpe?g|png|gif|webp|svg|avif)$/i; /** * Vault-wide problems. `attachments` lists attachment file names so image * links can be checked too. */ export function checkVault(notes: VaultNote[], attachments: string[] = []): VaultProblem[] { const problems: VaultProblem[] = []; const byName = new Map(); for (const n of notes) { const name = linkName(n.path).toLowerCase(); const other = byName.get(name); if (other) problems.push({ path: n.path, message: `Same file name as ${other.path}, so links to it are ambiguous` }); else byName.set(name, n); } const slugs = new Map(); for (const n of notes) { const def = typeDef(n.type); if (!def || def.singleton) continue; const slug = String(n.data.slug ?? slugify(String(n.data.title))); const key = `${n.type}:${slug}`; const other = slugs.get(key); if (other) problems.push({ path: n.path, message: `Slug "${slug}" is already used by ${other}` }); else slugs.set(key, n.path); } const files = new Set(attachments.map((a) => a.toLowerCase())); for (const n of notes) { for (const target of outgoingLinks(n)) { const known = IMAGE.test(target) ? files.has(target.toLowerCase()) : byName.has(target.toLowerCase()); if (!known) problems.push({ path: n.path, message: `Links to [[${target}]], which does not exist` }); } } return problems; }