/** * Migration des données du site Astro vers les fichiers plats OKI (ADR-001, * mission §7). Extrait les 55 ToolCard des MDX français + métadonnées de * src/data/{tools,directory}.json et produit : * content/outils/.md (frontmatter JSON-par-ligne + sections HTML) * content/categories.json (catégories dérivées des sections sources) * * Idempotent : relançable, écrase la sortie. Usage : pnpm migrate */ import { readFile, readdir, writeFile, mkdir } from 'node:fs/promises' import path from 'node:path' import { fileURLToPath } from 'node:url' const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..') const SRC = path.join(ROOT, 'src') const OUT = path.join(ROOT, 'content') const DIFFICULTY = { 1: 'beginner', 2: 'intermediate', 3: 'advanced' } const tools = JSON.parse(await readFile(path.join(SRC, 'data', 'tools.json'), 'utf8')) const directory = JSON.parse(await readFile(path.join(SRC, 'data', 'directory.json'), 'utf8')) const toolById = new Map(tools.map((t) => [t.id, t])) const dirById = new Map(directory.map((d) => [d.id, d])) function parseFrontmatter(raw) { const m = raw.match(/^---\n([\s\S]*?)\n---\n?/) if (!m) return [{}, raw] const meta = {} for (const line of m[1].split('\n')) { const i = line.indexOf(':') if (i === -1) continue const key = line.slice(0, i).trim() let value = line.slice(i + 1).trim() if (/^'.*'$/.test(value)) value = value.slice(1, -1) meta[key] = /^\d+$/.test(value) ? Number(value) : value } return [meta, raw.slice(m[0].length)] } /** Extrait le contenu d'un slot (Fragment/ol/ul) dans un bloc ToolCard. */ function slot(block, name) { const re = new RegExp( `<(Fragment|ol|ul)([^>]*slot="${name}"[^>]*)>([\\s\\S]*?)` ) const m = block.match(re) if (!m) return null // Les listes gardent leur balise (le HTML passe tel quel dans le markdown) ; // les Fragments sont déballés. return m[1] === 'Fragment' ? m[3].trim() : `<${m[1]}>${m[3]}`.trim() } /** Tags = textes des hors difficulté (déjà structurée). */ function extractTags(block) { const html = slot(block, 'tags') ?? '' const tags = [...html.matchAll(/([\s\S]*?)<\/span>/g)] .map((m) => m[1].replace(/\s+/g, ' ').trim()) .filter((t) => !/^[🟢🟡🔴]/u.test(t)) return tags } /** Difficulté : `level` de tools.json, sinon l'emoji du tag MDX (🟢/🟡/🔴). */ function difficulty(tool, block) { if (DIFFICULTY[tool.level]) return DIFFICULTY[tool.level] const html = slot(block, 'tags') ?? '' if (html.includes('🟢')) return 'beginner' if (html.includes('🟡')) return 'intermediate' if (html.includes('🔴')) return 'advanced' console.warn(`⚠ ${tool.id} : difficulté introuvable — « intermediate » par défaut`) return 'intermediate' } const fm = (key, value) => (value == null ? null : `${key}: ${JSON.stringify(value)}`) const sectionsDir = path.join(SRC, 'content', 'sections', 'fr') const files = (await readdir(sectionsDir)).filter((f) => f.endsWith('.mdx')).sort() await mkdir(path.join(OUT, 'outils'), { recursive: true }) const categories = [] const seen = new Set() let written = 0 for (const file of files) { const raw = await readFile(path.join(sectionsDir, file), 'utf8') const [meta, body] = parseFrontmatter(raw) const blocks = [...body.matchAll(/]*>([\s\S]*?)<\/ToolCard>/g)] if (blocks.length === 0) continue categories.push({ slug: meta.id, title: meta.title, order: meta.order, part: meta.part }) for (const [, toolId, block] of blocks) { const tool = toolById.get(toolId) if (!tool) { console.warn(`⚠ ${file} : ToolCard "${toolId}" absent de tools.json — ignoré`) continue } const slug = toolId.replace(/^t-/, '') if (seen.has(slug)) { console.warn(`⚠ ${slug} : doublon (déjà émis) — ignoré dans ${file}`) continue } seen.add(slug) const dir = dirById.get(slug) const nameOverride = slot(block, 'name') const front = [ fm('name', nameOverride ?? tool.name), fm('slug', slug), fm('category', meta.id), fm('difficulty', difficulty(tool, block)), fm('license', dir?.license), fm('url', tool.url), fm('color', tool.color), fm('monogram', tool.monogram), fm('iconSlug', tool.iconSlug), fm('tags', extractTags(block)), fm('lang', 'fr') ] .filter(Boolean) .join('\n') const sections = [ ['what', slot(block, 'what')], ['why', slot(block, 'why')], ['who', slot(block, 'who')], ['install', slot(block, 'install')], ['extra', slot(block, 'extra')] ] .filter(([, html]) => html) .map(([key, html]) => `## ${key}\n\n${html}`) .join('\n\n') await writeFile(path.join(OUT, 'outils', `${slug}.md`), `---\n${front}\n---\n\n${sections}\n`) written++ } } await writeFile(path.join(OUT, 'categories.json'), JSON.stringify(categories, null, 2) + '\n') console.log(`✔ ${written} fiches écrites dans content/outils/, ${categories.length} catégories`) const missing = tools.filter((t) => !seen.has(t.id.replace(/^t-/, ''))) if (missing.length) { console.warn(`⚠ outils de tools.json sans ToolCard fr : ${missing.map((t) => t.id).join(', ')}`) }