Files
lage-chat-control/frontend/scripts/migrate-tools.mjs
T
sucupira db5a997e50 feat(oki): fondations de la migration vers la stack souveraine OKI
Architecture décidée et documentée dans ADR.md : SvelteKit natif en
fichiers plats plutôt que Strapi (55 outils, données statiques,
souveraineté et budget performance prioritaires — doctrine §1.2, §4.4).

- frontend/ : SvelteKit 2 + Svelte 5 (runes), adapter-static, 58 pages
  prérendues, CSS natif en @layer (reset→tokens→base→layouts→components),
  tokens oklch(), container queries, typo fluide clamp(), 2 polices WOFF2
  auto-hébergées (Atkinson Hyperlegible, Fraunces), zéro CDN tiers
- i18n FR/gcf typé (clé manquante = erreur de build), bascule Kréyòl
  persistée en localStorage, micro-textes créoles (« Ou pa manké ayen »)
- Composants : ToolCard, DifficultyBadge (forme+texte+couleur, jamais la
  couleur seule), CategoryNav (≤5 entrées, divulgation progressive),
  SearchBar (raccourci /), LanguageSwitcher, EndMarker
- content/ : 55 fiches migrées depuis src/ (script frontend/scripts/
  migrate-tools.mjs, idempotent), licences SPDX reprises de directory.json
- docs/ : DESIGN_SYSTEM.md et CONTENT_GUIDE.md

Budgets vérifiés : JS 41 Ko gzip, CSS 2,9 Ko gzip, svelte-check 0 erreur.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 12:51:17 -04:00

150 lines
5.3 KiB
JavaScript

/**
* 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/<slug>.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]*?)</\\1>`
)
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]}</${m[1]}>`.trim()
}
/** Tags = textes des <span class="tool-tag"> hors difficulté (déjà structurée). */
function extractTags(block) {
const html = slot(block, 'tags') ?? ''
const tags = [...html.matchAll(/<span class="tool-tag">([\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(/<ToolCard tool="([^"]+)"[^>]*>([\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(', ')}`)
}