Rebuild du site sur Astro : i18n multilingue, faits verifies, Docker, CI, tests

- Astro statique, EN par defaut + /fr/ + /nl/, detection langue navigateur
- i18n par fichiers JSON, ajouter une langue = ajouter des traductions
- Contenu original porte a l'identique (diff d'inventaire par langue)
- Chronologie legislative corrigee sur sources primaires (PE, Conseil, votes)
- Timeline 23 precedents + observatoire 35 items + annuaire 66 outils FOSS,
  chaque affirmation verifiee et sourcee
- Zero requete tierce (teste), lisible JS coupe, axe WCAG AA x2 themes
- Version offline monofichier par langue a chaque build
- Docker multi-stage vers nginx + CSP stricte auto-generee
- CI GitHub Actions (SHA-pinnees) + verification hebdo des liens
- CONTRIBUTING : divulgation d'affiliation obligatoire, allowlist de
  domaines testee en CI
This commit is contained in:
Aurealibe
2026-07-10 03:52:49 +01:00
parent 88b2f9bfd4
commit 8ebc1da9e4
181 changed files with 28167 additions and 2042 deletions
+61
View File
@@ -0,0 +1,61 @@
/* Post-build: packs each locale's page into ONE self-contained HTML file —
stylesheets and scripts inlined, favicon as a data URI — so the guide can
be mirrored, e-mailed, or carried on a USB stick and opened from file://.
No parallel implementation: the artifact IS the built page, inlined.
Outputs dist/exitchatcontrol-offline[.<locale>].html (contract shared
with Footer.astro). */
import { readdirSync, readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
const DIST = 'dist'
// Locales derive from the dictionary files; the default locale (served at /)
// is read from src/i18n/locales.ts — adding a language needs no change here.
const DEFAULT = /defaultLocale: Locale = '(\w+)'/.exec(
readFileSync('src/i18n/locales.ts', 'utf8'),
)?.[1]
if (!DEFAULT) throw new Error('[build-offline] cannot read defaultLocale from src/i18n/locales.ts')
const LOCALES = readdirSync('src/i18n')
.filter((f) => /^[a-z]{2}\.json$/.test(f))
.map((f) => f.slice(0, 2))
.map((code) => ({
code,
page: code === DEFAULT ? 'index.html' : `${code}/index.html`,
out: `exitchatcontrol-offline${code === DEFAULT ? '' : `.${code}`}.html`,
}))
const faviconDataUri = `data:image/png;base64,${readFileSync('public/favicon.png').toString('base64')}`
function inlineAssets(html) {
// stylesheets → <style>
html = html.replace(
/<link[^>]+rel="stylesheet"[^>]+href="(\/[^"]+\.css)"[^>]*>/g,
(_, href) => `<style>${readFileSync(join(DIST, href), 'utf8')}</style>`,
)
// module scripts → inline (first occurrence per src; duplicates dropped so
// delegated listeners never register twice)
const seen = new Set()
html = html.replace(
/<script([^>]*)\ssrc="(\/[^"]+\.js)"([^>]*)><\/script>/g,
(_, pre, src, post) => {
if (seen.has(src)) return ''
seen.add(src)
return `<script${pre}${post}>${readFileSync(join(DIST, src), 'utf8')}</script>`
},
)
// favicon → data URI; PWA/touch links make no sense from file://
html = html.replace(/href="\/favicon\.png"/g, `href="${faviconDataUri}"`)
html = html.replace(/<link rel="(manifest|apple-touch-icon)"[^>]*>/g, '')
return html
}
for (const { code, page, out } of LOCALES) {
let html = inlineAssets(readFileSync(join(DIST, page), 'utf8'))
// language switcher + brand link → sibling offline artifacts, so the
// whole multilingual set works side-by-side from file://
for (const sibling of LOCALES) {
const route = sibling.code === DEFAULT ? '/' : `/${sibling.code}/`
html = html.replaceAll(`href="${route}"`, `href="./${sibling.out}"`)
}
writeFileSync(join(DIST, out), html)
console.log(`[build-offline] ${out} (${code}) — ${(html.length / 1024).toFixed(0)} KB`)
}
+78
View File
@@ -0,0 +1,78 @@
/* Live-checks every citation the guide stands on — timeline, observatory,
directory, checklist, allies, footer sources and the narrative sections.
404 / DNS failure / 5xx = FAIL; 403/429 (bot walls on europa.eu etc.)
are reported as WARN and tolerated.
Run: node scripts/check-links.mjs */
import { readdirSync, readFileSync, statSync } from 'node:fs'
import { join } from 'node:path'
const UA =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36'
// English sources are canonical — the other locales cite the same URLs.
const ROOTS = ['src/data', 'src/i18n/content/en', 'src/content/sections/en', 'src/components']
function filesUnder(root) {
try {
if (statSync(root).isFile()) return [root]
} catch {
return []
}
return readdirSync(root, { withFileTypes: true }).flatMap((e) =>
e.isDirectory() ? filesUnder(join(root, e.name)) : [join(root, e.name)],
)
}
const corpus = ROOTS.flatMap(filesUnder)
.map((p) => readFileSync(p, 'utf8'))
.join('\n')
const urls = [
...new Set(
[...corpus.matchAll(/https:\/\/[^\s"'`<>)\\]+/g)].map((m) => m[0].replace(/[.,]$/, '')),
),
]
let fail = 0
let warn = 0
async function probe(url) {
for (const method of ['HEAD', 'GET']) {
try {
const controller = new AbortController()
const t = setTimeout(() => controller.abort(), 15_000)
const res = await fetch(url, {
method,
redirect: 'follow',
signal: controller.signal,
headers: { 'user-agent': UA, accept: 'text/html,application/xhtml+xml,*/*' },
})
clearTimeout(t)
if (res.status === 405 && method === 'HEAD') continue // try GET
return res.status
} catch (e) {
if (method === 'GET')
return `ERR:${e.name === 'AbortError' ? 'timeout' : (e.cause?.code ?? e.name)}`
}
}
return 'ERR:unknown'
}
console.log(`checking ${urls.length} cited sources…`)
const results = await Promise.all(urls.map(async (url) => ({ url, status: await probe(url) })))
for (const { url, status } of results.sort((a, b) =>
String(a.status).localeCompare(String(b.status)),
)) {
if (typeof status === 'number' && status >= 200 && status < 300) {
console.log(`${status} ${url}`)
} else if (status === 403 || status === 429 || status === 401) {
warn++
console.log(` ~ ${status} ${url} (bot wall — verify by hand once)`)
} else {
fail++
console.log(`${status} ${url}`)
}
}
console.log(`\nlinks: ${urls.length - fail - warn} ok · ${warn} bot-walled · ${fail} broken`)
process.exit(fail === 0 ? 0 : 1)
+45
View File
@@ -0,0 +1,45 @@
// Post-build: compute the Content-Security-Policy for the built site.
// Hashes every inline <script> found in dist/**/*.html (the theme/language
// boot script, plus anything Astro chose to inline) so the CSP can stay
// strict without 'unsafe-inline'. Emits docker/csp.conf (nginx snippet).
// Regenerated on every build — the hash can never drift from the markup.
import { createHash } from 'node:crypto'
import { readdirSync, readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
const DIST = 'dist'
function htmlFiles(dir) {
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const path = join(dir, entry.name)
if (entry.isDirectory()) return htmlFiles(path)
return entry.name.endsWith('.html') ? [path] : []
})
}
const hashes = new Set()
const inlineScript = /<script(?![^>]*\bsrc=)[^>]*>([\s\S]*?)<\/script>/gi
for (const file of htmlFiles(DIST)) {
const html = readFileSync(file, 'utf8')
for (const [, body] of html.matchAll(inlineScript)) {
if (!body.trim()) continue
hashes.add(`'sha256-${createHash('sha256').update(body).digest('base64')}'`)
}
}
const scriptSrc = ["'self'", ...hashes].join(' ')
const csp = [
"default-src 'none'",
`script-src ${scriptSrc}`,
"style-src 'self'",
"img-src 'self' data:",
"font-src 'self'",
"manifest-src 'self'",
"base-uri 'none'",
"form-action 'none'",
"frame-ancestors 'none'",
].join('; ')
writeFileSync('docker/csp.conf', `add_header Content-Security-Policy "${csp}" always;\n`)
console.log(`[gen-csp] ${hashes.size} inline script hash(es) → docker/csp.conf`)
+42
View File
@@ -0,0 +1,42 @@
/* Generates src/icons.generated.ts from the simple-icons npm package —
build-time only, so the site never phones a CDN (v1 loaded brand logos
from cdn.simpleicons.org at runtime: a third-party request on a privacy
guide). The slug list is DERIVED from the data files (tools.json +
directory.json iconSlug fields) so it can never drift from the content.
Slugs missing from the installed set fall back to the letter tile at
render time. Run: pnpm icons */
import { readFileSync, writeFileSync } from 'node:fs'
import * as icons from 'simple-icons'
const dataFiles = ['../src/data/tools.json', '../src/data/directory.json']
const slugs = new Set(['github']) // footer link icon
for (const file of dataFiles) {
const entries = JSON.parse(readFileSync(new URL(file, import.meta.url), 'utf8'))
for (const entry of entries) {
if (entry.iconSlug) slugs.add(entry.iconSlug)
}
}
const bySlug = new Map(Object.values(icons).map((i) => [i.slug, i]))
const found = []
const missing = []
for (const slug of [...slugs].sort()) {
const icon = bySlug.get(slug)
if (icon) found.push(icon)
else missing.push(slug)
}
const lines = found.map((i) => ` ${JSON.stringify(i.slug)}: ${JSON.stringify(i.path)},`)
const out = `/* AUTO-GENERATED by scripts/gen-icons.mjs — do not edit.
SVG path data from the simple-icons npm package (CC0), inlined at build
time. Zero runtime CDN request. Missing slugs at generation time: ${
missing.length ? missing.join(', ') : 'none'
}. */
export const BRAND_ICONS: Record<string, string> = {
${lines.join('\n')}
}
`
writeFileSync(new URL('../src/icons.generated.ts', import.meta.url), out)
console.log(
`icons: ${found.length} embedded, ${missing.length} missing${missing.length ? ` (${missing.join(', ')})` : ''}`,
)
+11
View File
@@ -0,0 +1,11 @@
// Renders scripts/og.html (1200×630) to public/og.png via Playwright's
// bundled chromium — no hand-rolled browser driving, no image dependency.
// Run: node scripts/gen-og.mjs
import { chromium } from '@playwright/test'
const browser = await chromium.launch()
const page = await browser.newPage({ viewport: { width: 1200, height: 630 } })
await page.goto(new URL('./og.html', import.meta.url).href)
await page.screenshot({ path: 'public/og.png' })
await browser.close()
console.log('[gen-og] public/og.png regenerated (1200×630)')
+105
View File
File diff suppressed because one or more lines are too long
+22
View File
@@ -0,0 +1,22 @@
// Quick visual QA: screenshots a built page region in a given theme.
// Usage: node scripts/shot.mjs <url-path> <out.png> [dark|light] [#anchor]
// Requires `pnpm preview` (or any server) on 127.0.0.1:4321.
import { chromium } from '@playwright/test'
const [path = '/', out = 'shot.png', theme = 'dark', anchor = ''] = process.argv.slice(2)
const browser = await chromium.launch()
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } })
// documentElement doesn't exist yet at init-script time — go through
// localStorage, which the theme boot script applies before first paint.
await page.addInitScript((t) => localStorage.setItem('ecc-theme', t), theme)
await page.goto(`http://127.0.0.1:4321${path}`)
if (anchor) {
/* global document -- runs inside the browser via page.evaluate */
await page.evaluate((id) => {
document.getElementById(id)?.scrollIntoView({ behavior: 'instant' })
}, anchor.slice(1))
await page.waitForTimeout(150)
}
await page.screenshot({ path: out })
await browser.close()
console.log(`[shot] ${out}${path}${anchor} (${theme})`)