Add educational censorship-resistance quiz as a native Astro page
CI / ci (push) Failing after 6m53s
CI / ci (push) Failing after 6m53s
Reworks the standalone-HTML quiz from PR #7 into the site's real architecture so it actually ships and respects every project rule. - /quiz, /fr/quiz, /nl/quiz — prerendered per-locale routes with correct canonical + hreflang (new optional `path` prop on Base.astro). - Progressive enhancement: the twelve questions and the full scoring key (bands + per-area guidance with deep links into the guide) are server-rendered and readable with JavaScript disabled; the client script only computes the live score and reveals the result panel. - CSP-clean: bundled same-origin script (no inline handlers), bar widths set via a CSS custom property through the CSSOM (no inline styles), no third-party request. docker/csp.conf needs no new hash. - i18n via the existing core+overlay model: src/data/quiz.json (neutral scoring) + src/i18n/content/<locale>/quiz.json (prose), loaded by a dedicated loadQuiz() that checks question/option/band parity in all three locales. Page chrome added to the `quiz` namespace (key parity tested). - Share is copy-link + native Web Share + X (already allowlisted); the third-party mastodonshare.com redirector from PR #7 is dropped. - TopBar "Contents" link made absolute (/#toc) so it works off the guide, plus a new "Quiz" nav link. New tests/e2e/quiz.spec.ts covers no-JS readability, scoring, the TOC link and zero third-party requests. Co-Authored-By: arnaudom <1535627+arnaudom@users.noreply.github.com> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,470 @@
|
||||
---
|
||||
// Censorship-resistance quiz — a progressive-enhancement scorer. EVERYTHING
|
||||
// the reader needs is server-rendered: the twelve questions (native radios,
|
||||
// selectable with JS off) and the full scoring key (what each band means +
|
||||
// where to improve, with deep links into the guide). The client script only
|
||||
// computes the live score and reveals the result panel; with JS off the
|
||||
// static key still teaches the whole thing. No third-party request, no inline
|
||||
// styles (bar widths come from a CSS custom property set via the CSSOM, which
|
||||
// style-src 'self' allows), no inline handlers (delegated on document).
|
||||
import { localePath, useT, type Locale } from '../i18n/config'
|
||||
import { loadQuiz } from '../lib/content'
|
||||
import Ext from './Ext.astro'
|
||||
|
||||
interface Props {
|
||||
locale: Locale
|
||||
}
|
||||
|
||||
const { locale } = Astro.props
|
||||
const t = useT(locale)
|
||||
const { bands, questions } = loadQuiz(locale)
|
||||
|
||||
const localeRoot = `${localePath(locale)}/`.replace('//', '/')
|
||||
const guideHref = (anchor: string) => `${localeRoot}#${anchor}`
|
||||
const total = questions.length
|
||||
const progress0 = t('quiz.progress').replace('{done}', '0').replace('{total}', String(total))
|
||||
---
|
||||
|
||||
<main id="content" class="mx-auto max-w-3xl px-4 py-8">
|
||||
<header class="my-8">
|
||||
<p class="eyebrow mt-0">{t('quiz.eyebrow')}</p>
|
||||
<h1 class="my-2 text-4xl uppercase sm:text-5xl">{t('quiz.title')}</h1>
|
||||
<p class="text-dim max-w-[60ch]">{t('quiz.lede')}</p>
|
||||
<p class="text-dim font-mono text-xs">{t('quiz.privacyNote')}</p>
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<a
|
||||
href="#quiz-form"
|
||||
class="rounded-dossier border border-accent bg-accent px-3 py-1.5 font-mono text-xs text-on-accent no-underline"
|
||||
>
|
||||
{t('quiz.start')}
|
||||
</a>
|
||||
<a
|
||||
href={localeRoot}
|
||||
class="rounded-dossier border border-rule bg-surface px-3 py-1.5 font-mono text-xs text-ink no-underline hover:border-accent"
|
||||
>
|
||||
{t('quiz.readGuide')}
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div data-quiz-progress class="sticky top-12 z-30 bg-bg/95 py-2 backdrop-blur-sm" hidden>
|
||||
<p
|
||||
data-quiz-progress-text
|
||||
data-tpl={t('quiz.progress')}
|
||||
class="eyebrow mt-0 mb-1"
|
||||
aria-live="polite"
|
||||
>
|
||||
{progress0}
|
||||
</p>
|
||||
<div class="quiz-progress" aria-hidden="true"><i class="quiz-progress__fill"></i></div>
|
||||
</div>
|
||||
|
||||
<form id="quiz-form" data-quiz aria-describedby="quiz-note">
|
||||
<p id="quiz-note" class="sr-only">{t('quiz.privacyNote')}</p>
|
||||
<ol class="m-0 list-none p-0">
|
||||
{
|
||||
questions.map((question, i) => (
|
||||
<li class="my-6">
|
||||
<fieldset
|
||||
data-quiz-question
|
||||
data-cat={question.id}
|
||||
class="rounded-dossier border border-rule bg-surface m-0 p-4"
|
||||
>
|
||||
<legend class="px-1 font-mono text-base font-bold">
|
||||
<span class="text-accent">{String(i + 1).padStart(2, '0')}</span> {question.prompt}
|
||||
</legend>
|
||||
<div class="mt-3 grid gap-2 sm:grid-cols-2">
|
||||
{question.options.map((option) => (
|
||||
<label class="rounded-dossier border border-rule bg-surface-2 has-[:checked]:border-accent has-[:checked]:bg-surface flex cursor-pointer flex-col gap-1 p-3">
|
||||
<span class="flex items-start gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
name={question.id}
|
||||
value={option.points}
|
||||
class="mt-1 size-4 shrink-0 cursor-pointer accent-accent"
|
||||
/>
|
||||
<span class="font-mono text-sm font-bold">{option.label}</span>
|
||||
</span>
|
||||
<span class="text-dim ms-6 text-sm">{option.hint}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ol>
|
||||
</form>
|
||||
|
||||
<section
|
||||
data-quiz-result
|
||||
data-share-tpl={t('quiz.shareText')}
|
||||
tabindex="-1"
|
||||
aria-live="polite"
|
||||
class="rounded-dossier border border-rule border-l-4 border-l-accent bg-surface my-8 p-5"
|
||||
hidden
|
||||
>
|
||||
<p class="eyebrow mt-0 mb-1">{t('quiz.resultEyebrow')}</p>
|
||||
<p class="font-mono text-5xl font-bold">
|
||||
<span data-quiz-score>0</span><span class="text-dim text-2xl"> {t('quiz.outOf')}</span>
|
||||
</p>
|
||||
<p data-quiz-band class="text-accent font-mono text-sm font-bold tracking-widest uppercase"></p>
|
||||
<p data-quiz-summary class="text-dim"></p>
|
||||
|
||||
<h3 class="mt-6 mb-2 font-mono text-sm uppercase">{t('quiz.barsHeading')}</h3>
|
||||
<div data-quiz-bars class="grid gap-2"></div>
|
||||
|
||||
<h3 class="mt-6 mb-2 font-mono text-sm uppercase">{t('quiz.recHeading')}</h3>
|
||||
<div data-quiz-recs class="grid gap-2 sm:grid-cols-3"></div>
|
||||
|
||||
<div class="mt-5 flex flex-wrap gap-2 border-t border-rule pt-4">
|
||||
<button
|
||||
type="button"
|
||||
data-quiz-share="copy"
|
||||
data-copied={t('quiz.copied')}
|
||||
class="rounded-dossier border border-rule bg-surface-2 text-ink cursor-pointer px-3 py-1.5 font-mono text-xs hover:border-accent"
|
||||
>
|
||||
{t('quiz.copy')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-quiz-share="native"
|
||||
class="rounded-dossier border border-rule bg-surface-2 text-ink cursor-pointer px-3 py-1.5 font-mono text-xs hover:border-accent"
|
||||
hidden
|
||||
>
|
||||
{t('quiz.share')}
|
||||
</button>
|
||||
<Ext
|
||||
href="https://x.com/intent/tweet"
|
||||
class="quiz-x-share rounded-dossier border border-rule bg-surface-2 text-ink px-3 py-1.5 font-mono text-xs no-underline hover:border-accent"
|
||||
>
|
||||
{t('quiz.shareX')}
|
||||
</Ext>
|
||||
<button
|
||||
type="button"
|
||||
data-quiz-restart
|
||||
class="rounded-dossier border border-rule bg-surface text-dim ms-auto cursor-pointer px-3 py-1.5 font-mono text-xs hover:border-accent"
|
||||
>
|
||||
{t('quiz.restart')}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="quiz-key" aria-labelledby="quiz-key-bands" class="my-10">
|
||||
<h2 id="quiz-key-bands" class="text-2xl uppercase">{t('quiz.bandsHeading')}</h2>
|
||||
<ul data-quiz-band-key class="m-0 grid list-none gap-2 p-0">
|
||||
{
|
||||
bands.map((band) => (
|
||||
<li
|
||||
data-band-id={band.id}
|
||||
data-band-min={band.min}
|
||||
data-band-max={band.max}
|
||||
class="rounded-dossier border border-rule bg-surface p-3"
|
||||
>
|
||||
<span class="flex items-baseline gap-2">
|
||||
<strong data-band-title class="font-mono text-sm uppercase">
|
||||
{band.title}
|
||||
</strong>
|
||||
<span class="text-dim font-mono text-xs">
|
||||
{band.min}–{band.max}
|
||||
</span>
|
||||
</span>
|
||||
<p data-band-summary class="text-dim my-1 text-sm">
|
||||
{band.summary}
|
||||
</p>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
|
||||
<h2 class="mt-8 text-2xl uppercase">{t('quiz.categoriesHeading')}</h2>
|
||||
<ul data-quiz-cat-key class="m-0 grid list-none gap-2 p-0 sm:grid-cols-2">
|
||||
{
|
||||
questions.map((question) => (
|
||||
<li data-cat={question.id} class="rounded-dossier border border-rule bg-surface p-3">
|
||||
<strong data-cat-label class="font-mono text-sm">
|
||||
{question.label}
|
||||
</strong>
|
||||
<p data-cat-rec class="text-dim my-1 text-sm">
|
||||
{question.rec}
|
||||
</p>
|
||||
<a data-cat-link href={guideHref(question.anchor)} class="font-mono text-xs">
|
||||
{t('quiz.openGuide')}
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
// ── Quiz enhancement ──────────────────────────────────────────────────
|
||||
// Delegated on document (survives ClientRouter swaps), re-init on
|
||||
// astro:page-load. Answers persist in localStorage 'ecc-quiz'.
|
||||
const KEY = 'ecc-quiz'
|
||||
type Answers = Record<string, number>
|
||||
|
||||
const qa = <T extends Element>(sel: string, root: ParentNode = document): T[] =>
|
||||
Array.from(root.querySelectorAll<T>(sel))
|
||||
const one = <T extends Element>(sel: string, root: ParentNode = document): T | null =>
|
||||
root.querySelector<T>(sel)
|
||||
|
||||
const form = () => one<HTMLFormElement>('[data-quiz]')
|
||||
|
||||
const readSaved = (): Answers => {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(localStorage.getItem(KEY) ?? '{}')
|
||||
const out: Answers = {}
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
|
||||
if (typeof v === 'number') out[k] = v
|
||||
}
|
||||
}
|
||||
return out
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
const save = (answers: Answers) => {
|
||||
try {
|
||||
localStorage.setItem(KEY, JSON.stringify(answers))
|
||||
} catch {
|
||||
/* private mode: not persisted, quiz still works for this visit */
|
||||
}
|
||||
}
|
||||
|
||||
type Cat = { cat: string; points: number | null; max: number }
|
||||
|
||||
const scan = (root: HTMLFormElement): { total: number; answered: number; cats: Cat[] } => {
|
||||
const cats: Cat[] = []
|
||||
let answered = 0
|
||||
for (const fs of qa<HTMLFieldSetElement>('[data-quiz-question]', root)) {
|
||||
const radios = qa<HTMLInputElement>('input[type="radio"]', fs)
|
||||
const max = Math.max(0, ...radios.map((r) => Number(r.value)))
|
||||
const checked = radios.find((r) => r.checked)
|
||||
if (checked) answered += 1
|
||||
cats.push({ cat: fs.dataset.cat ?? '', points: checked ? Number(checked.value) : null, max })
|
||||
}
|
||||
return { total: cats.length, answered, cats }
|
||||
}
|
||||
|
||||
const bandFor = (score: number): { title: string; summary: string } => {
|
||||
for (const li of qa<HTMLElement>('[data-quiz-band-key] [data-band-id]')) {
|
||||
const min = Number(li.dataset.bandMin)
|
||||
const max = Number(li.dataset.bandMax)
|
||||
if (score >= min && score <= max) {
|
||||
return {
|
||||
title: one<HTMLElement>('[data-band-title]', li)?.textContent ?? '',
|
||||
summary: one<HTMLElement>('[data-band-summary]', li)?.textContent ?? '',
|
||||
}
|
||||
}
|
||||
}
|
||||
return { title: '', summary: '' }
|
||||
}
|
||||
|
||||
const catNode = (cat: string) => one<HTMLElement>(`[data-quiz-cat-key] [data-cat="${cat}"]`)
|
||||
const catLabel = (cat: string) =>
|
||||
one<HTMLElement>('[data-cat-label]', catNode(cat) ?? undefined)?.textContent ?? cat
|
||||
|
||||
const setBar = (fill: HTMLElement, ratio: number) => {
|
||||
fill.style.setProperty('--quiz-bar', `${Math.round(ratio * 100)}%`)
|
||||
}
|
||||
|
||||
const buildBars = (cats: Cat[]) => {
|
||||
const host = one<HTMLElement>('[data-quiz-bars]')
|
||||
if (!host) return
|
||||
host.textContent = ''
|
||||
for (const c of cats) {
|
||||
const ratio = c.max > 0 && c.points != null ? c.points / c.max : 0
|
||||
const row = document.createElement('div')
|
||||
row.className = 'quiz-bar-row'
|
||||
const label = document.createElement('span')
|
||||
label.className = 'quiz-bar-row__label font-mono text-xs text-dim uppercase'
|
||||
label.textContent = catLabel(c.cat)
|
||||
const track = document.createElement('div')
|
||||
track.className = 'quiz-bar'
|
||||
const fill = document.createElement('i')
|
||||
fill.className = 'quiz-bar__fill'
|
||||
setBar(fill, ratio)
|
||||
track.appendChild(fill)
|
||||
const val = document.createElement('strong')
|
||||
val.className = 'font-mono text-xs'
|
||||
val.textContent = `${c.points ?? 0}/${c.max}`
|
||||
row.append(label, track, val)
|
||||
host.appendChild(row)
|
||||
}
|
||||
}
|
||||
|
||||
const buildRecs = (cats: Cat[]) => {
|
||||
const host = one<HTMLElement>('[data-quiz-recs]')
|
||||
if (!host) return
|
||||
host.textContent = ''
|
||||
const weakest = [...cats]
|
||||
.filter((c) => c.points != null)
|
||||
.sort((a, b) => a.points! / a.max - b.points! / b.max)
|
||||
.slice(0, 3)
|
||||
for (const c of weakest) {
|
||||
const node = catNode(c.cat)
|
||||
const card = document.createElement('div')
|
||||
card.className =
|
||||
'rounded-dossier border border-rule border-l-2 border-l-accent bg-surface p-3'
|
||||
const label = document.createElement('strong')
|
||||
label.className = 'font-mono text-sm'
|
||||
label.textContent = catLabel(c.cat)
|
||||
const rec = document.createElement('p')
|
||||
rec.className = 'text-dim my-1 text-sm'
|
||||
rec.textContent = one<HTMLElement>('[data-cat-rec]', node ?? undefined)?.textContent ?? ''
|
||||
const link = document.createElement('a')
|
||||
link.className = 'font-mono text-xs'
|
||||
const src = one<HTMLAnchorElement>('[data-cat-link]', node ?? undefined)
|
||||
link.href = src?.getAttribute('href') ?? '#'
|
||||
link.textContent = src?.textContent ?? ''
|
||||
card.append(label, rec, link)
|
||||
host.appendChild(card)
|
||||
}
|
||||
}
|
||||
|
||||
const shareUrl = () => location.href.replace(/[?#].*$/, '')
|
||||
|
||||
const shareMessage = (score: number, band: string): string => {
|
||||
const panel = one<HTMLElement>('[data-quiz-result]')
|
||||
const tpl = panel?.dataset.shareTpl ?? '{score}/100 — {band}'
|
||||
return tpl.replace('{score}', String(score)).replace('{band}', band)
|
||||
}
|
||||
|
||||
const showResult = (cats: Cat[]) => {
|
||||
const totalPoints = cats.reduce((s, c) => s + (c.points ?? 0), 0)
|
||||
const maxPoints = cats.reduce((s, c) => s + c.max, 0)
|
||||
const score = maxPoints > 0 ? Math.round((totalPoints / maxPoints) * 100) : 0
|
||||
const band = bandFor(score)
|
||||
|
||||
const scoreEl = one<HTMLElement>('[data-quiz-score]')
|
||||
if (scoreEl) scoreEl.textContent = String(score)
|
||||
const bandEl = one<HTMLElement>('[data-quiz-band]')
|
||||
if (bandEl) bandEl.textContent = band.title
|
||||
const summaryEl = one<HTMLElement>('[data-quiz-summary]')
|
||||
if (summaryEl) summaryEl.textContent = band.summary
|
||||
|
||||
buildBars(cats)
|
||||
buildRecs(cats)
|
||||
|
||||
const msg = shareMessage(score, band.title)
|
||||
const x = one<HTMLAnchorElement>('.quiz-x-share')
|
||||
if (x) x.href = `https://x.com/intent/tweet?text=${encodeURIComponent(`${msg} ${shareUrl()}`)}`
|
||||
|
||||
const panel = one<HTMLElement>('[data-quiz-result]')
|
||||
if (panel && panel.hidden) {
|
||||
panel.hidden = false
|
||||
panel.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
panel.focus({ preventScroll: true })
|
||||
}
|
||||
}
|
||||
|
||||
const hideResult = () => {
|
||||
const panel = one<HTMLElement>('[data-quiz-result]')
|
||||
if (panel) panel.hidden = true
|
||||
}
|
||||
|
||||
const update = () => {
|
||||
const root = form()
|
||||
if (!root) return
|
||||
const { total, answered, cats } = scan(root)
|
||||
|
||||
const block = one<HTMLElement>('[data-quiz-progress]')
|
||||
if (block) block.hidden = false
|
||||
const fill = one<HTMLElement>('[data-quiz-progress] .quiz-progress__fill')
|
||||
if (fill) fill.style.setProperty('--quiz-progress', `${total ? (answered / total) * 100 : 0}%`)
|
||||
const text = one<HTMLElement>('[data-quiz-progress-text]')
|
||||
if (text)
|
||||
text.textContent = (text.dataset.tpl ?? '{done} / {total}')
|
||||
.replace('{done}', String(answered))
|
||||
.replace('{total}', String(total))
|
||||
|
||||
const answers: Answers = {}
|
||||
for (const c of cats) if (c.points != null) answers[c.cat] = c.points
|
||||
save(answers)
|
||||
|
||||
if (answered === total && total > 0) showResult(cats)
|
||||
else hideResult()
|
||||
}
|
||||
|
||||
const restore = () => {
|
||||
const root = form()
|
||||
if (!root) return
|
||||
const native = one<HTMLButtonElement>('[data-quiz-share="native"]')
|
||||
if (native) native.hidden = typeof navigator.share !== 'function'
|
||||
|
||||
const saved = readSaved()
|
||||
for (const fs of qa<HTMLFieldSetElement>('[data-quiz-question]', root)) {
|
||||
const cat = fs.dataset.cat ?? ''
|
||||
if (!(cat in saved)) continue
|
||||
for (const radio of qa<HTMLInputElement>('input[type="radio"]', fs)) {
|
||||
if (Number(radio.value) === saved[cat]) radio.checked = true
|
||||
}
|
||||
}
|
||||
update()
|
||||
}
|
||||
|
||||
const restart = () => {
|
||||
const root = form()
|
||||
if (!root) return
|
||||
for (const radio of qa<HTMLInputElement>('input[type="radio"]', root)) radio.checked = false
|
||||
try {
|
||||
localStorage.removeItem(KEY)
|
||||
} catch {
|
||||
/* nothing to clear */
|
||||
}
|
||||
hideResult()
|
||||
update()
|
||||
root.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
|
||||
document.addEventListener('change', (e) => {
|
||||
const target = e.target as HTMLElement
|
||||
if (
|
||||
target instanceof HTMLInputElement &&
|
||||
target.type === 'radio' &&
|
||||
target.closest('[data-quiz]')
|
||||
)
|
||||
update()
|
||||
})
|
||||
|
||||
document.addEventListener('click', async (e) => {
|
||||
const target = e.target as HTMLElement
|
||||
if (target.closest('[data-quiz-restart]')) {
|
||||
restart()
|
||||
return
|
||||
}
|
||||
const btn = target.closest<HTMLButtonElement>('[data-quiz-share]')
|
||||
if (!btn) return
|
||||
const action = btn.dataset.quizShare
|
||||
const score = one<HTMLElement>('[data-quiz-score]')?.textContent ?? ''
|
||||
const band = one<HTMLElement>('[data-quiz-band]')?.textContent ?? ''
|
||||
const message = shareMessage(Number(score), band)
|
||||
if (action === 'native') {
|
||||
try {
|
||||
await navigator.share({ title: document.title, text: message, url: shareUrl() })
|
||||
} catch {
|
||||
/* dismissed or unsupported */
|
||||
}
|
||||
return
|
||||
}
|
||||
if (action === 'copy') {
|
||||
try {
|
||||
await navigator.clipboard.writeText(`${message} ${shareUrl()}`)
|
||||
if (!btn.dataset.label) btn.dataset.label = btn.textContent ?? ''
|
||||
btn.textContent = btn.dataset.copied ?? btn.dataset.label
|
||||
window.setTimeout(() => {
|
||||
btn.textContent = btn.dataset.label ?? ''
|
||||
}, 2000)
|
||||
} catch {
|
||||
/* clipboard unavailable */
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
restore()
|
||||
document.addEventListener('astro:page-load', restore)
|
||||
</script>
|
||||
Reference in New Issue
Block a user