b13a1e7a31
- Landing : intro « Pourquoi ce site ? » (Chat Control expliqué au grand public) + sommaire-chimen en 3 arcs (Fondations 🟢 / Reprendre le contrôle 🟡 / Autodéfense avancée 🔴) avec compte d'outils par catégorie - PWA : service worker offline-first (précache coquille, network-first avec repli cache pour les fiches consultées), badge hors-ligne discret - Page /contribuer (3 voies + forge labola) + template d'issue Forgejo .gitea/ISSUE_TEMPLATE/nouvo-zouti.yaml ; liens GitHub → labola.o-k-i.net - Fiches jumelles créoles : chargeur content/outils/gcf/, affichage selon la langue avec bandeaux « an chantyé » / « póko tradui », 6 pilotes (signal, protonmail, brave, bitwarden, ublock, quad9) — À FAIRE RELIRE par un locuteur natif - Ton OKI : les 6 mêmes fiches réécrites (tutoiement, registre militant-chaleureux) ; le script de migration n'écrase plus les fiches retravaillées (--force pour tout régénérer) - UI : View Transitions (respect reduced-motion), aide clavier « ? » (dialog natif, raccourcis / h a), progression locale « jès fèt » (localStorage uniquement, compteur visible après le premier geste) - docs/DEPLOYMENT.md : guide VPS (Caddy, nginx, YunoHost, Docker, rsync) - Audits : axe-core 0 violation (5 pages), Lighthouse 99-98/100/100/100 ; corrections contraste monogrammes + ordre des titres - Budgets : JS 46 Ko gzip, CSS 3,5 Ko, 59 pages prérendues Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
157 lines
5.5 KiB
JavaScript
157 lines
5.5 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 { existsSync } from 'node:fs'
|
|
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) || /^".*"$/.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)
|
|
// ne jamais écraser une fiche retravaillée à la main (--force pour tout régénérer)
|
|
const outPath = path.join(OUT, 'outils', `${slug}.md`)
|
|
if (existsSync(outPath) && !process.argv.includes('--force')) {
|
|
written++
|
|
continue
|
|
}
|
|
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(outPath, `---\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(', ')}`)
|
|
}
|