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,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([])
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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([])
|
||||
})
|
||||
}
|
||||
@@ -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}/`))
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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([])
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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(
|
||||
'<a href="x" title='y'> & </a>',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mdToHtml', () => {
|
||||
it('escapes HTML before rendering anything (no injection from overlays)', () => {
|
||||
expect(mdToHtml('<script>alert("x")</script>')).toBe(
|
||||
'<script>alert("x")</script>',
|
||||
)
|
||||
expect(mdToHtml('<img src=x onerror=alert(1)>')).toBe('<img src=x onerror=alert(1)>')
|
||||
})
|
||||
|
||||
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&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"><b>hi</b></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`')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user