import { parse } from 'smol-toml'; import { readFileSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; // Zola frontmatter is delimited by a `+++` line, then TOML, then another // `+++` line. Only the first such block (right at the top of the file) is // treated as frontmatter; anything else is body content. const FRONTMATTER_RE = /^\+\+\+\r?\n([\s\S]*?)\r?\n\+\+\+\r?\n?/; // Values that would be ambiguous or invalid as a plain (unquoted) YAML // scalar: leading YAML indicator characters, empty strings, and strings // that would otherwise parse as a different type (bool/null/number). const LEADING_SPECIAL_RE = /^[-?:,[\]{}#&*!|>'"%@`]/; const AMBIGUOUS_SCALAR_RE = /^(true|false|null|~|-?\d+(\.\d+)?)$/i; function needsQuoting(value) { if (value === '') return true; if (LEADING_SPECIAL_RE.test(value)) return true; if (value.includes(':') || value.includes('#')) return true; if (/^\s|\s$/.test(value)) return true; if (AMBIGUOUS_SCALAR_RE.test(value)) return true; return false; } function yamlScalar(value) { return needsQuoting(value) ? JSON.stringify(value) : value; } function authorSlug(name) { return name.toLowerCase().replace(/\s+/g, '-'); } // Formats a TOML date (Date object, or a string) as YAML-safe `YYYY-MM-DD`. function isoDate(value) { const d = value instanceof Date ? value : new Date(value); return d.toISOString().slice(0, 10); } /** * Converts a leading Zola `+++ TOML +++` frontmatter block (blog post, cast, * or author page) into a content-collection `--- YAML ---` block, per the * documented field mapping for each `kind`. The body is left byte-for-byte * untouched. Returns `null` for posts marked `draft = true` (callers should * skip writing them). Files with no leading `+++` block are returned * unchanged. * * @param {string} rawMarkdown * @param {'blog'|'cast'|'author'} kind * @returns {string|null} */ export function convertBlogFrontmatter(rawMarkdown, kind) { const match = rawMarkdown.match(FRONTMATTER_RE); if (!match) return rawMarkdown; const toml = parse(match[1]); const body = rawMarkdown.slice(match[0].length); if (toml.draft === true) return null; const lines = ['---']; if (kind === 'author') { if ('title' in toml) lines.push(`name: ${yamlScalar(String(toml.title))}`); if ('description' in toml) lines.push(`description: ${yamlScalar(String(toml.description))}`); lines.push('---'); return lines.join('\n') + '\n' + body; } // blog + cast share title/description/pubDate/updatedDate/authors if ('title' in toml) lines.push(`title: ${yamlScalar(String(toml.title))}`); if ('description' in toml) lines.push(`description: ${yamlScalar(String(toml.description))}`); if ('date' in toml) lines.push(`pubDate: ${isoDate(toml.date)}`); if ('updated' in toml) lines.push(`updatedDate: ${isoDate(toml.updated)}`); const authors = toml.taxonomies?.authors ?? []; if (authors.length) { lines.push('authors:'); for (const name of authors) lines.push(` - ${authorSlug(name)}`); } if (kind === 'cast') { if (toml.extra?.num !== undefined) lines.push(`episode: ${yamlScalar(String(toml.extra.num))}`); if (toml.extra?.id !== undefined) lines.push(`youtube: ${yamlScalar(String(toml.extra.id))}`); } lines.push('---'); return lines.join('\n') + '\n' + body; } // --- CLI walker: migrates docs-site/content/{blog,casts,authors} into // website/src/content/{blog,casts,authors}, dropping each section's // `_index.md` (no equivalent needed in a content collection). const __dirname = path.dirname(fileURLToPath(import.meta.url)); const SRC_ROOT = path.resolve(__dirname, '../../docs-site/content'); const DEST_ROOT = path.resolve(__dirname, '../src/content'); const SECTIONS = [ { dir: 'blog', kind: 'blog' }, { dir: 'casts', kind: 'cast' }, { dir: 'authors', kind: 'author' }, ]; function migrateSection(dir, kind) { const srcDir = path.join(SRC_ROOT, dir); const destDir = path.join(DEST_ROOT, dir); mkdirSync(destDir, { recursive: true }); let count = 0; for (const entry of readdirSync(srcDir, { withFileTypes: true })) { if (!entry.isFile() || !entry.name.endsWith('.md')) continue; if (entry.name === '_index.md') continue; const raw = readFileSync(path.join(srcDir, entry.name), 'utf8'); const converted = convertBlogFrontmatter(raw, kind); if (converted === null) continue; // draft, skipped writeFileSync(path.join(destDir, entry.name), converted, 'utf8'); count += 1; } return count; } function migrate() { let total = 0; for (const { dir, kind } of SECTIONS) { const count = migrateSection(dir, kind); console.log(`${dir}: ${count}`); total += count; } console.log(`total: ${total}`); } // Only run the CLI walker when this file is executed directly (not when // imported for its `convertBlogFrontmatter` export by tests). if (import.meta.url === `file://${process.argv[1]}`) { migrate(); }