import fs from 'node:fs'; import path from 'node:path'; import { slugify } from './slug.mjs'; import { noteUrl, mediaUrl } from '../../notes-src/stack/routes.mjs'; /** * @typedef {{status:'published', slug:string}|{status:'unpublished'}|{status:'unknown'}} Resolution * @typedef {{ resolve: (target: string) => Resolution }} LinkIndex */ /** * Index every note (published or not) by lowercase H1 title and filename. * Only notes present in slugByFile are reported as published. * @param {Array<{file: string, title: string}>} notes * @param {Map} slugByFile published notes only * @returns {LinkIndex} */ export function buildLinkIndex(notes, slugByFile) { const byName = new Map(); for (const n of notes) { const keys = [n.title, n.file.replace(/\.md$/, '')].map((k) => k.trim().toLowerCase()); for (const k of keys) if (!byName.has(k)) byName.set(k, n); } return { resolve(target) { const note = byName.get(target.trim().toLowerCase()); if (!note) return { status: 'unknown' }; const slug = slugByFile.get(note.file); return slug ? { status: 'published', slug } : { status: 'unpublished' }; }, }; } const HEADING = /^#{1,6}\s/; const FENCE = /^\s*(```|~~~)/; function mapOutsideFences(text, fn) { let inFence = false; return text .split('\n') .map((line) => { if (FENCE.test(line)) { inFence = !inFence; return line; } return inFence ? line : fn(line); }) .filter((line) => line !== null) .join('\n'); } /** * @param {string} body * @param {{ slug: string, sourceDir: string, index: LinkIndex }} ctx */ export function transformBody(body, ctx) { const warnings = []; const tags = []; const links = new Set(); const images = []; // 1. cleanup let text = body.replace(/[\uFFFC\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, '').replace(/\r\n/g, '\n'); // 2. task markers -> plain list items text = text.replace(/^([ \t]*)[*+-] \[[ xX>\-<]\] /gm, '$1- '); // 3. tags (extracted and removed; never on heading lines or inside fences) const tagRe = /(^|\s)#([A-Za-z][\w-]*)/g; text = mapOutsideFences(text, (line) => { if (HEADING.test(line)) return line; let found = false; // mask wikilinks and inline code so '#' inside them is neither extracted nor removed const masked = []; const hidden = line.replace(/\[\[[^\]]*\]\]|`[^`]*`/g, (m) => { masked.push(m); return `\uE000${masked.length - 1}\uE001`; }); const out = hidden.replace(tagRe, (_m, pre, tag) => { found = true; const t = tag.toLowerCase(); if (!tags.includes(t)) tags.push(t); return pre; }); if (!found) return line; const cleaned = out .replace(/(\S) {2,}/g, '$1 ') .replace(/\s+$/, '') .replace(/\uE000(\d+)\uE001/g, (_m, i) => masked[Number(i)]); return cleaned.trim() === '' ? null : cleaned; }); const hashInput = text; // 4. wikilinks const render = (rawTarget, label, asLinkText) => { const target = rawTarget.trim(); const hashAt = target.indexOf('#'); const name = (hashAt >= 0 ? target.slice(0, hashAt) : target).trim(); const heading = hashAt >= 0 ? target.slice(hashAt + 1).trim() : ''; const plain = label != null && label.trim() ? label.trim() : name; if (/^\d{4}-\d{2}-\d{2}$/.test(name)) return plain; const res = ctx.index.resolve(name); if (res.status === 'published') { links.add(res.slug); const anchor = heading ? `#${slugify(heading)}` : ''; return `[${plain}](${noteUrl(res.slug)}${anchor})`; } if (res.status === 'unknown') warnings.push(`unknown wikilink target: ${name}`); return plain; }; // [text]([[Target]]) first, so we never nest a link inside a link text = text.replace(/\[([^\]]*)\]\(\s*\[\[([^\]]+)\]\]\s*\)/g, (_m, label, inner) => { const [target, alias] = inner.split('|'); const res = render(target, label || alias, true); return res; }); text = text.replace(/\[\[([^\]|]+)(?:\|([^\]]*))?\]\]/g, (_m, target, label) => render(target, label)); // 5. images const root = path.resolve(ctx.sourceDir); text = text.replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g, (m, alt, ref) => { if (/^[a-z][a-z0-9+.-]*:/i.test(ref) || ref.startsWith('/') || ref.startsWith('data:')) return m; let decoded = ref; try { decoded = decodeURIComponent(ref); } catch { /* keep raw */ } const from = path.resolve(root, decoded); const inside = from.startsWith(root + path.sep); if (!inside || !fs.existsSync(from) || !fs.statSync(from).isFile()) { warnings.push(`image not found: ${decoded}`); return ''; } const base = path.basename(from); images.push({ from, to: `${ctx.slug}/${base}` }); return `![${alt}](${mediaUrl(ctx.slug, base)})`; }); return { body: text, hashInput, tags, links: [...links], images, warnings: [...new Set(warnings)] }; }