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
+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`')
})
})