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:
@@ -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`)
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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`)
|
||||
@@ -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(', ')})` : ''}`,
|
||||
)
|
||||
@@ -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
File diff suppressed because one or more lines are too long
@@ -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})`)
|
||||
Reference in New Issue
Block a user