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
+24
View File
@@ -0,0 +1,24 @@
import AxeBuilder from '@axe-core/playwright'
import { expect, test } from '@playwright/test'
import { localePaths } from './helpers'
// WCAG 2 A/AA on every locale, in BOTH themes. The theme override is
// applied exactly like the boot script does (data-theme on <html>).
test.skip(({ javaScriptEnabled }) => javaScriptEnabled === false, 'axe needs JS')
for (const path of localePaths) {
for (const theme of ['light', 'dark'] as const) {
test(`axe WCAG A/AA · ${path} · ${theme}`, async ({ page }) => {
// 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(path)
const results = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']).analyze()
expect(
results.violations.map((v) => `${v.id}: ${v.nodes.length} node(s) — ${v.help}`),
).toEqual([])
})
}
}
+28
View File
@@ -0,0 +1,28 @@
import { readFileSync } from 'node:fs'
import { expect, test } from '@playwright/test'
import { localePaths } from './helpers'
// Legacy deep links (/#messagerie, /#t-signal…) are shared all over the
// place — every anchor id from the old index.html must resolve on every
// locale page. The inventory is produced by migration/extract.mjs.
// Four legacy ids were toggle BUTTONS in the old chrome (never link
// targets, nothing on the web points at them) — the new chrome replaces
// them, so they are deliberately not preserved.
const LEGACY_CHROME_IDS = new Set(['lang-fr', 'lang-en', 'lang-nl', 'theme'])
const inventory = (
JSON.parse(
readFileSync(new URL('../../migration/inventory/anchors.json', import.meta.url), 'utf8'),
) as string[]
).filter((id) => !LEGACY_CHROME_IDS.has(id))
for (const path of localePaths) {
test(`all ${inventory.length} legacy anchors exist on ${path}`, async ({ page }) => {
await page.goto(path)
const present = await page.evaluate(
(ids) => ids.filter((id) => !document.getElementById(id)),
inventory,
)
expect(present, 'missing legacy anchor ids').toEqual([])
})
}
+6
View File
@@ -0,0 +1,6 @@
import { defaultLocale, locales } from '../../src/i18n/locales'
export { defaultLocale, locales }
/** ['/', '/fr/', '/nl/', …] derived from the locales tuple. */
export const localePaths = locales.map((l) => (l === defaultLocale ? '/' : `/${l}/`))
+25
View File
@@ -0,0 +1,25 @@
import { expect, test } from '@playwright/test'
import { localePaths } from './helpers'
// Tor Browser "safest" mode is part of this site's audience: the FULL
// guide must be readable with JavaScript disabled. These run in the
// `nojs` project (javaScriptEnabled: false) — and still pass with JS on.
for (const path of localePaths) {
test(`guide fully readable without JS on ${path}`, async ({ page }) => {
await page.goto(path)
await expect(page.locator('h1')).toBeVisible()
// the guide's narrative sections are prerendered
const sections = page.locator('main section[id]')
expect(await sections.count()).toBeGreaterThan(10)
// <details> is native HTML — install steps must open with JS off
const details = page.locator('main details').first()
if ((await details.count()) > 0) {
await details.locator('summary').click()
await expect(details).toHaveAttribute('open', '')
}
// the language switcher is a native <details> dropdown: it opens and
// exposes plain links with no script at all
await page.locator('[data-lang-menu] summary').click()
await expect(page.locator('[data-lang-menu] a').first()).toBeVisible()
})
}
+41
View File
@@ -0,0 +1,41 @@
import { expect, test } from '@playwright/test'
test.skip(({ javaScriptEnabled }) => javaScriptEnabled === false, 'prefs need JS')
test('theme toggle persists across reloads', async ({ page }) => {
await page.goto('/')
const before = await page.evaluate(() => document.documentElement.dataset.theme || '')
await page.locator('[data-theme-toggle]').click()
const after = await page.evaluate(() => document.documentElement.dataset.theme)
expect(after).toMatch(/^(light|dark)$/)
expect(after).not.toBe(before)
await page.reload()
await expect(page.locator('html')).toHaveAttribute('data-theme', after!)
})
test('explicit language choice is remembered on the root', async ({ page }) => {
await page.goto('/')
// pick French from the dropdown: stores ecc-lang and navigates
await page.locator('[data-lang-menu] summary').click()
await page.locator('[data-lang-choice="fr"]').click()
await page.waitForURL('**/fr/')
// back to the root: the boot script honors the stored choice
await page.goto('/')
await page.waitForURL('**/fr/')
await expect(page.locator('html')).toHaveAttribute('lang', 'fr')
// switching back to English sticks too (no redirect loop)
await page.locator('[data-lang-menu] summary').click()
await page.locator('[data-lang-choice="en"]').click()
await page.waitForURL((url) => url.pathname === '/')
await page.goto('/')
await expect(page.locator('html')).toHaveAttribute('lang', 'en')
})
test('checklist state survives a reload (when present)', async ({ page }) => {
await page.goto('/')
const box = page.locator('[data-checklist] input[type="checkbox"]').first()
test.skip((await box.count()) === 0, 'checklist not yet wired')
await box.check()
await page.reload()
await expect(page.locator('[data-checklist] input[type="checkbox"]').first()).toBeChecked()
})
+20
View File
@@ -0,0 +1,20 @@
import { expect, test } from '@playwright/test'
import { localePaths } from './helpers'
// The site's core promise, enforced: not a single request leaves for a
// third-party host. Fonts, icons, styles, scripts — everything same-origin.
for (const path of localePaths) {
test(`zero third-party requests on ${path}`, async ({ page, baseURL }) => {
const origin = new URL(baseURL!).origin
const offenders: string[] = []
page.on('request', (req) => {
const url = new URL(req.url())
if (url.origin !== origin && url.protocol !== 'data:') offenders.push(req.url())
})
await page.goto(path, { waitUntil: 'networkidle' })
// scroll to the bottom to trigger any lazy loading
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight))
await page.waitForTimeout(500)
expect(offenders).toEqual([])
})
}
+183
View File
@@ -0,0 +1,183 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import {
DATASET_NAMES,
DIRECTORY_CATEGORIES,
THEMES,
assertOverlayParity,
loadDataset,
} from '../../src/lib/content'
import { locales } from '../../src/i18n/locales'
/* Content-integrity tests, ported from the pr1 typed-React content to the
JSON core + per-locale overlay split: the DATA is what the sections render,
so these guard it at build time in every language. */
describe('core + overlay integrity', () => {
it('every dataset merges cleanly with every locale overlay (en/fr/nl parity)', () => {
for (const name of DATASET_NAMES) {
for (const locale of locales) {
const rows = loadDataset(name, locale)
expect(rows.length, `${name}/${locale}`).toBeGreaterThan(0)
}
}
})
it('ids are unique within each dataset AND across all datasets (deep links)', () => {
const all: string[] = []
for (const name of DATASET_NAMES) {
const ids = loadDataset(name, 'en').map((row) => row.id)
expect(new Set(ids).size, name).toBe(ids.length)
for (const id of ids) expect(id, `${name}: ${id}`).toMatch(/^[a-z0-9-]+$/)
all.push(...ids)
}
const dupes = all.filter((id, i) => all.indexOf(id) !== i)
expect(dupes, `cross-dataset id collisions: ${dupes.join(', ')}`).toEqual([])
})
it('the parity check throws listing every missing and extra id', () => {
const overlay = { a: { body: 'x' }, ghost: { body: 'y' }, _marker: true }
expect(() => assertOverlayParity(['a', 'b', 'c'], overlay, 'unit')).toThrow(
/missing ids \(2\): b, c.*extra ids \(1\): ghost/,
)
expect(() => assertOverlayParity(['a', 'ghost'], overlay, 'unit')).not.toThrow()
})
it('nl overlays carry the machine-translated stamp (no pending marker left)', () => {
for (const name of DATASET_NAMES) {
const raw = JSON.parse(
readFileSync(new URL(`../../src/i18n/content/nl/${name}.json`, import.meta.url), 'utf8'),
) as Record<string, unknown>
expect(raw._machineTranslationPending, name).toBeUndefined()
expect(raw._machineTranslated, name).toMatch(/^\d{4}-\d{2}-\d{2}$/)
}
})
})
describe('precedents timeline (events)', () => {
const events = loadDataset('events', 'en')
it('has the full 23-event arc, chronologically sorted', () => {
expect(events).toHaveLength(23)
const dates = events.map((e) => e.date)
expect(dates).toEqual([...dates].sort())
})
it('opens in the 90s crypto wars, ends on the 9 July 2026 second-reading vote', () => {
expect(events[0]?.id).toBe('clipper-chip')
expect(events[0]?.date).toBe('1993')
expect(events[events.length - 1]?.date).toBe('2026-07-09')
})
it('every event is titled, bodied and primary-sourced over https in every locale', () => {
for (const locale of locales) {
for (const ev of loadDataset('events', locale)) {
expect(ev.title.length, `${ev.id}/${locale}`).toBeGreaterThan(3)
expect(ev.body.length, `${ev.id}/${locale}`).toBeGreaterThan(80)
expect(ev.src.href.startsWith('https://'), `${ev.id}: ${ev.src.href}`).toBe(true)
}
}
})
})
describe('big brother observatory', () => {
const drift = loadDataset('observatory', 'en')
it('has 35 entries, chronologically sorted', () => {
expect(drift).toHaveLength(35)
const dates = drift.map((e) => e.date)
expect(dates).toEqual([...dates].sort())
})
it('covers all 5 fronts and the 3 region groups (no filter chip is ever empty)', () => {
const themes = new Set(drift.flatMap((e) => e.themes))
for (const t of THEMES) expect(themes.has(t), t).toBe(true)
const groups = new Set(
drift.map((e) => (e.region === 'eu' ? 'eu' : e.region === 'fr' ? 'fr' : 'world')),
)
expect([...groups].sort()).toEqual(['eu', 'fr', 'world'])
})
it('every entry is sourced over https and stays disjoint from the precedents timeline', () => {
const timelineSources = new Set(loadDataset('events', 'en').map((e) => e.src.href))
for (const ev of drift) {
expect(ev.src.href.startsWith('https://'), `${ev.id}: ${ev.src.href}`).toBe(true)
expect(timelineSources.has(ev.src.href), `${ev.id} reuses a precedents source`).toBe(false)
}
})
it('pending-verification flags only sit on the unverified 2025-09 → 2026-07 batch', () => {
for (const ev of drift.filter((e) => e._pendingVerification)) {
expect(
ev.date >= '2025-09',
`${ev.id} (${ev.date}) flagged but part of the verified batch`,
).toBe(true)
}
})
})
describe('open-source directory', () => {
const SPDX_ALLOWED = new Set([
'AGPL-3.0',
'GPL-3.0',
'GPL-2.0',
'GPL-2.0/3.0',
'LGPL-3.0',
'MIT',
'Apache-2.0',
'MPL-2.0',
'BSD-2-Clause',
'BSD-3-Clause',
'EUPL-1.2',
'Artistic/GPL',
])
const directory = loadDataset('directory', 'en')
it('has 66 entries: free-software license allowlist, https links', () => {
expect(directory).toHaveLength(66)
for (const e of directory) {
expect(SPDX_ALLOWED.has(e.license), `${e.name}: license ${e.license}`).toBe(true)
expect(e.url.startsWith('https://'), e.name).toBe(true)
}
})
it('names are unique and no category of the 14 is empty', () => {
const names = directory.map((e) => e.name)
expect(new Set(names).size).toBe(names.length)
expect(DIRECTORY_CATEGORIES).toHaveLength(14)
for (const cat of DIRECTORY_CATEGORIES) {
expect(
directory.some((e) => e.category === cat),
cat,
).toBe(true)
}
})
it('the undisclosed self-promo and scaffolding entries stay removed', () => {
const names = new Set(directory.map((e) => e.name))
for (const gone of ['Nika', 'goose', 'OpenHands', 'Aider', 'vLLM']) {
expect(names.has(gone), gone).toBe(false)
}
expect(directory.some((e) => e.url.includes('nika.sh'))).toBe(false)
})
})
describe('migration checklist', () => {
const checklist = loadDataset('checklist', 'en')
it('has the 14-step plan with all three effort levels', () => {
expect(checklist).toHaveLength(14)
const levels = new Set(checklist.map((c) => c.level))
expect([...levels].sort()).toEqual(['green', 'red', 'yellow'])
})
})
describe('allied initiatives', () => {
const allies = loadDataset('allies', 'en')
it('lists 8 campaigns and 8 chroniclers, all https', () => {
expect(allies.filter((a) => a.group === 'campaigns')).toHaveLength(8)
expect(allies.filter((a) => a.group === 'chroniclers')).toHaveLength(8)
for (const a of allies) expect(a.url.startsWith('https://'), a.name).toBe(true)
})
})
+74
View File
@@ -0,0 +1,74 @@
import { existsSync, readdirSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import allowlist from '../../src/data/domains-allowlist.json'
// The anti-backlink gate, run against the BUILT site (ground truth).
// PR #1 taught us the lesson: a smuggled external link must never be
// mergeable silently. Every external hostname in dist/ must be declared
// in src/data/domains-allowlist.json (a reviewable diff), and every
// external anchor must carry the full safe rel.
//
// Requires `pnpm build` first (CI builds before testing).
const DIST = 'dist'
function htmlFiles(dir: string): string[] {
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 built = existsSync(DIST)
describe.skipIf(!built)('built site external links', () => {
const allowed = new Set<string>(allowlist.domains)
const anchorRe = /<a\s[^>]*href="(https?:\/\/[^"]+)"[^>]*>/gi
const anchors: { file: string; href: string; tag: string }[] = []
for (const file of htmlFiles(DIST)) {
const html = readFileSync(file, 'utf8')
for (const m of html.matchAll(anchorRe)) {
anchors.push({ file, href: m[1]!, tag: m[0]! })
}
}
it('found external anchors to audit (sanity)', () => {
expect(anchors.length).toBeGreaterThan(0)
})
it('every external hostname is on the allowlist', () => {
const offenders = anchors
.map((a) => ({ ...a, host: new URL(a.href).hostname }))
.filter((a) => !allowed.has(a.host) && a.host !== 'exitchatcontrol.org')
expect(
offenders.map((o) => `${o.host} (${o.file})`),
'unlisted external domain in built output — add it to domains-allowlist.json ONLY after review',
).toEqual([])
})
it('every external anchor is https and carries rel="noopener noreferrer"', () => {
const offenders = anchors.filter(
(a) =>
!a.href.startsWith('https://') ||
!/rel="[^"]*noopener[^"]*"/.test(a.tag) ||
!/rel="[^"]*noreferrer[^"]*"/.test(a.tag),
)
expect(offenders.map((o) => o.tag)).toEqual([])
})
it('never links the domains PR #1 tried to smuggle in', () => {
for (const { tag } of anchors) {
expect(tag).not.toMatch(/nika\.sh|vpn-gratuit\.fr/)
}
})
})
describe('allowlist hygiene', () => {
it('is sorted and unique (reviewable diffs)', () => {
const domains = allowlist.domains
expect([...new Set(domains)].sort()).toEqual(domains)
})
})
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import en from '../../src/i18n/en.json'
import fr from '../../src/i18n/fr.json'
import nl from '../../src/i18n/nl.json'
import { defaultLocale, locales } from '../../src/i18n/locales'
// Every locale dictionary must have exactly the English key shape:
// a missing key would silently fall back to English in production.
function keyPaths(node: unknown, prefix = ''): string[] {
if (typeof node === 'string') return [prefix]
if (node && typeof node === 'object') {
return Object.entries(node).flatMap(([k, v]) => keyPaths(v, prefix ? `${prefix}.${k}` : k))
}
return [prefix]
}
const dicts: Record<string, unknown> = { en, fr, nl }
describe('i18n dictionary parity', () => {
it('declares a dictionary for every locale', () => {
for (const locale of locales) expect(dicts[locale], `src/i18n/${locale}.json`).toBeDefined()
})
const reference = keyPaths(dicts[defaultLocale]).sort()
for (const locale of locales.filter((l) => l !== defaultLocale)) {
it(`${locale}.json matches the ${defaultLocale}.json key shape`, () => {
const keys = keyPaths(dicts[locale]).sort()
expect(keys).toEqual(reference)
})
}
it('has no empty strings', () => {
for (const [locale, dict] of Object.entries(dicts)) {
const walk = (node: unknown, path: string) => {
if (typeof node === 'string') {
expect(node.trim(), `${locale}:${path}`).not.toBe('')
} else if (node && typeof node === 'object') {
for (const [k, v] of Object.entries(node)) walk(v, `${path}.${k}`)
}
}
walk(dict, locale)
}
})
})
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest'
import { escapeHtml, mdToHtml } from '../../src/lib/md'
// The md micro-renderer is the ONLY place dataset prose becomes HTML
// (set:html in Timeline/Observatory/Directory/Checklist). It must escape
// first and only ever emit <strong>, <br /> and Ext-policy anchors.
describe('escapeHtml', () => {
it('escapes all five HTML special characters', () => {
expect(escapeHtml(`<a href="x" title='y'> & </a>`)).toBe(
'&lt;a href=&quot;x&quot; title=&#39;y&#39;&gt; &amp; &lt;/a&gt;',
)
})
})
describe('mdToHtml', () => {
it('escapes HTML before rendering anything (no injection from overlays)', () => {
expect(mdToHtml('<script>alert("x")</script>')).toBe(
'&lt;script&gt;alert(&quot;x&quot;)&lt;/script&gt;',
)
expect(mdToHtml('<img src=x onerror=alert(1)>')).toBe('&lt;img src=x onerror=alert(1)&gt;')
})
it('renders **bold** as <strong>', () => {
expect(mdToHtml('a **very bold** claim')).toBe('a <strong>very bold</strong> claim')
})
it('renders [text](https://url) with the Ext.astro rel policy', () => {
expect(mdToHtml('see [the ruling](https://example.org/a?b=c&d=e)')).toBe(
'see <a href="https://example.org/a?b=c&amp;d=e" target="_blank" rel="noopener noreferrer">the ruling</a>',
)
})
it('never links non-https URLs (they stay literal, escaped text)', () => {
expect(mdToHtml('[x](http://insecure.example)')).toBe('[x](http://insecure.example)')
expect(mdToHtml('[x](javascript:alert(1))')).toBe('[x](javascript:alert(1))')
})
it('escapes markup inside link labels', () => {
expect(mdToHtml('[<b>hi</b>](https://example.org)')).toBe(
'<a href="https://example.org" target="_blank" rel="noopener noreferrer">&lt;b&gt;hi&lt;/b&gt;</a>',
)
})
it('renders bold inside a link label', () => {
expect(mdToHtml('[**Blaze**, 1994](https://example.org)')).toBe(
'<a href="https://example.org" target="_blank" rel="noopener noreferrer"><strong>Blaze</strong>, 1994</a>',
)
})
it('converts line breaks to <br />', () => {
expect(mdToHtml('one\ntwo')).toBe('one<br />two')
})
it('renders NOTHING else from markdown', () => {
expect(mdToHtml('# heading * item _em_ `code`')).toBe('# heading * item _em_ `code`')
})
})