Chapitre 7.3 — ⭐ Le pattern Drill : questions JSON sur GitHub
Où on en est : le pipeline fetch + cache tourne. Reste à construire ce qu’il transporte : le repo
drill-questions— la banque de questions vivante, SANS serveur. Le chapitre signature du cours.
⏱️ TL;DR — Un repo GitHub public :
manifest.json(la liste des thèmes) + unthemes/<slug>.jsonpar banque. Servi gratuitement par raw.githubusercontent.com, en HTTPS, avec le CDN de GitHub. Le schéma est versionné (schemaVersion) et validé par zod à l’entrée de l’app — une question malformée est rejetée SANS crasher le jeu. Mise à jour = git push (par vous… ou par une IA qui génère des banques entières).
🎯 Objectifs
- Créer le repo drill-questions : structure, manifest, premier thème.
- Écrire le schéma zod et le brancher dans le pipeline (parse, filtrage des invalides).
- Versionner le schéma pour survivre aux évolutions (app ancienne ↔ banques nouvelles).
- Comprendre les limites du pattern (et pourquoi elles n’en sont pas pour ce cas).
Le repo drill-questions
Un repo séparé de l’app (décision ADR 0001 : les données vivent hors du code) :
drill-questions/
├── manifest.json ← la liste des thèmes (ce que l'app découvre)
├── themes/
│ ├── bootstrap.json
│ ├── nextjs.json
│ ├── php.json
│ └── symfony.json
└── README.md ← le contrat du schéma, pour les contributeurs (vous ou une IA)// manifest.json
{
"schemaVersion": 1,
"updatedAt": "2026-07-10",
"themes": [
{ "slug": "bootstrap", "title": "Bootstrap 5", "emoji": "🅱️", "file": "themes/bootstrap.json" },
{ "slug": "nextjs", "title": "Next.js 15", "emoji": "▲", "file": "themes/nextjs.json" },
{ "slug": "php", "title": "PHP 8.5", "emoji": "🐘", "file": "themes/php.json" },
{ "slug": "symfony", "title": "Symfony 7", "emoji": "🎼", "file": "themes/symfony.json" }
]
}// themes/symfony.json (extrait)
{
"schemaVersion": 1,
"slug": "symfony",
"questions": [
{
"id": "sf-0001",
"prompt": "Quel attribut PHP déclare une route sur une méthode de contrôleur ?",
"choices": ["#[Route]", "#[Path]", "#[Url]", "#[Endpoint]"],
"correctIndex": 0,
"explanation": "Depuis Symfony 6, l'attribut #[Route('/chemin', name: '…')] remplace les annotations. Il se pose sur la méthode (ou la classe pour un préfixe).",
"difficulty": "facile",
"tags": ["routing", "attributs"]
}
]
}Le contrat de contenu (dans le README du repo, pour vos futures sessions de génération) :
ids stables et préfixés (sf-0001 — l’historique SQLite s’y référera), exactement
4 choices, correctIndex 0-3, une explanation substantielle (c’est le cœur
pédagogique), difficulty ∈ facile/moyen/difficile, tags libres (la révision espacée de
Drill v2 s’en servira).
Pourquoi raw.githubusercontent.com est un « backend » légitime
- HTTPS + CDN mondial (Fastly) : latence correcte partout, fiabilité GitHub.
- Gratuit sans limite réaliste pour ce volume (des Mo de JSON, quelques hits/jour/user — les rate limits de raw ne concernent pas cet ordre de grandeur).
- Versionné par nature : l’URL
/main/suit la branche ; un tag/v1/fige une version ; l’historique git EST l’audit log des questions. - Éditable de partout : l’interface web GitHub suffit pour corriger une typo depuis le téléphone. Et une IA (moi) peut générer/mettre à jour des banques entières par PR.
Les limites assumées (et pourquoi on s’en moque ici) : ~5 min de cache CDN sur raw (un push met quelques minutes à se propager — non-problème pour des questions) ; pas de requêtes (on télécharge le fichier entier — nos banques font quelques centaines de Ko max) ; public (c’est le but).
zod : la frontière de confiance
Le JSON vient du réseau : il peut être malformé (typo dans un push manuel, schéma qui évolue).
Un as Question[] optimiste = crash au milieu d’une partie. On valide à l’entrée :
pnpm add zod// lib/schemas.ts
import { z } from 'zod'
export const questionSchema = z.object({
id: z.string().min(1),
prompt: z.string().min(8),
choices: z.tuple([z.string(), z.string(), z.string(), z.string()]),
correctIndex: z.union([z.literal(0), z.literal(1), z.literal(2), z.literal(3)]),
explanation: z.string().min(8),
difficulty: z.enum(['facile', 'moyen', 'difficile']),
tags: z.array(z.string()).default([]),
})
export const themeFileSchema = z.object({
schemaVersion: z.literal(1),
slug: z.string(),
questions: z.array(z.unknown()), // ← validées UNE PAR UNE ci-dessous
})
export const manifestSchema = z.object({
schemaVersion: z.literal(1),
updatedAt: z.string(),
themes: z.array(z.object({
slug: z.string(),
title: z.string(),
emoji: z.string(),
file: z.string(),
})),
})
export type Question = z.infer<typeof questionSchema>
export type Manifest = z.infer<typeof manifestSchema>Et le pipeline devient tolérant aux questions pourries :
// hooks/queries.ts — la queryFn blindée
async function fetchQuestions(slug: string): Promise<Question[]> {
const raw = await fetchJson<unknown>(`${BASE}/themes/${slug}.json`)
const file = themeFileSchema.parse(raw) // structure du fichier : stricte
const valid: Question[] = []
for (const q of file.questions) {
const parsed = questionSchema.safeParse(q) // question par question : tolérant
if (parsed.success) valid.push(parsed.data)
else console.warn(`Question invalide ignorée dans ${slug}:`, parsed.error.issues[0])
}
if (valid.length === 0) throw new Error(`Aucune question valide dans ${slug}`)
return valid
}La nuance stratégique : parse strict sur l’enveloppe (un fichier cassé = erreur franche), safeParse par question (une question sur 200 mal formée = ignorée avec warning, le jeu continue). Une typo dans un push ne bloque jamais toute une banque.
Bonus majeur : z.infer — les types de l’app dérivent du schéma. Le type Question de
la Partie 6 vient maintenant d’ici : une seule source de vérité, du JSON au TS.
⚡ schemaVersion : le contrat dans le temps — L’app v1 valide
schemaVersion: 1. Le jour où le schéma évolue (ex. choix multiples), les nouvelles banques passent enschemaVersion: 2: les vieilles apps rejettent proprement (message « mets à jour Drill ») au lieu de crasher sur un format inconnu, et l’app v2 accepte les deux. Un champ, une discipline — des années de tranquillité. (Le même principe pilotera les runtime versions OTA en Partie 12.)
💡 Pour un dev Next.js — Ce pattern (contenu statique versionné + CDN + validation client) est un cousin du JAMstack : « le git est le CMS ». Vous l’avez peut-être pratiqué avec du markdown + Vercel ; ici, le « site » est une app dans la poche et le déploiement… un git push. Réutilisez-le : configs distantes, changelogs in-app, catalogues — toute donnée lue-souvent-écrite-rarement.
✏️ Exercices
1. Créez réellement le repo drill-questions (public) avec le manifest et un premier thème de 10 questions sur un sujet que vous maîtrisez. Branchez BASE dessus et jouez une partie sur VOS questions.
✅ Solution
Repo → manifest.json + themes/<slug>.json conformes au schéma → remplacer <user> dans BASE →
npx expo start -c (nouvelle URL) → l’écran Thèmes affiche votre thème, la partie se joue.
Vérification du pattern complet : modifier une explanation sur GitHub (interface web), attendre
~2 min de CDN, pull-to-refresh → le texte a changé. Aucun rebuild. C’est le moment ADR 0001.
2. Sabotez volontairement une question du JSON (correctIndex: 7). Que se passe-t-il dans l’app, et où est la ligne de code qui vous a sauvé ?
✅ Solution
L’app charge la banque sans cette question (warning en console : « Question invalide
ignorée… »), le jeu tourne. Le sauveur : le safeParse par question — l’union
literal(0|1|2|3) a rejeté 7. Avec un as Question[], la question serait entrée et le jeu
n’aurait JAMAIS trouvé la bonne réponse (correctIndex 7 sur 4 choix) : bug silencieux le pire
genre.
3. Rédigez le README.md du repo : le contrat qu’une future session d’IA devra respecter pour générer 200 questions Symfony valides du premier coup.
✅ Solution
Sections : schéma JSON complet avec exemple ; règles des ids (sf-NNNN, jamais réutilisés —
l’historique s’y réfère) ; consignes qualité (explanation = le POURQUOI en 1-3 phrases, pas de
paraphrase de la réponse ; distracteurs plausibles ; difficulty calibrée : facile = définition,
moyen = usage, difficile = piège/edge case) ; répartition cible par difficulté (40/40/20) ;
interdiction de modifier les questions existantes autrement que pour corriger (append-only sauf
fix). Ce README est littéralement le prompt de vos futures sessions de génération.
🧠 Quiz
1. Pourquoi un repo séparé de l’app pour les questions ?
Réponse
Cycles de vie différents : les questions changent souvent (push quotidien possible), l’app rarement (builds). Séparation = mise à jour des données sans toucher au code, et génération de banques (IA) sans accès au code de l’app. ADR 0001.
2. Que garantit raw.githubusercontent.com, et quelle est sa principale latence ?
Réponse
HTTPS + CDN Fastly gratuit, URL par branche/tag. Latence principale : le cache CDN (~5 min) entre un push et sa visibilité — négligeable pour des questions de quiz.
3. Pourquoi parse strict sur le fichier mais safeParse par question ?
Réponse
Fichier corrompu = tout est suspect → erreur franche. Une question invalide parmi des centaines = dégât localisé → on l’ignore avec warning et le jeu continue. Tolérance proportionnée au rayon du dégât.
4. À quoi sert schemaVersion ?
Réponse
Contrat d’évolution : une app ancienne rejette proprement (message clair) un format plus récent au lieu de crasher ; une app récente peut accepter plusieurs versions. Le champ qui achète des années de compatibilité.
5. D’où vient désormais le type Question de l’app ?
Réponse
De z.infer<typeof questionSchema> : le schéma zod est LA source de vérité — validation
runtime et types compilés ne peuvent plus diverger.
👉 Chapitre suivant : 7.4 — Offline-first — le métro, le mode avion, et pourquoi Drill s’en moque.