From 7d2dc6c872c6e804740d196a71c01e475ff8306f Mon Sep 17 00:00:00 2001
From: electron-rare <108685187+electron-rare@users.noreply.github.com>
Date: Wed, 10 Jun 2026 20:59:13 +0200
Subject: [PATCH] docs: implementation plan reader + atom hub
---
.../2026-06-10-formations-reader-hub3d.md | 1123 +++++++++++++++++
1 file changed, 1123 insertions(+)
create mode 100644 docs/superpowers/plans/2026-06-10-formations-reader-hub3d.md
diff --git a/docs/superpowers/plans/2026-06-10-formations-reader-hub3d.md b/docs/superpowers/plans/2026-06-10-formations-reader-hub3d.md
new file mode 100644
index 0000000..85244eb
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-10-formations-reader-hub3d.md
@@ -0,0 +1,1123 @@
+# Formations Reader + 3D Atom Hub — Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Public reading experience for the 3 published formations: dark/copper catalogue, three.js "atom = table of contents" hub per course, server-rendered chapter reading — backed by Moodle web services.
+
+**Architecture:** Astro 6 SSR app (node adapter) in `electron-rare/formations-app`. Astro API/loaders act as the BFF to Moodle REST web services (read-only token, in-memory stale-while-revalidate cache). three.js (pinned exact) loaded lazily only on hub pages; every page has a complete SSR HTML fallback. Deployed as one Docker container on Tower (port 8096) next to Moodle, routed `formations.saillant.cc` via traefik on electron-server (Tailscale) + CF tunnel ingress.
+
+**Tech Stack:** Astro 6 + @astrojs/node + Tailwind 4 (`@tailwindcss/vite`), three (pinned exact, ONLY runtime 3D dep), vitest (dev-only, pinned) for lib unit tests. node:22-alpine Docker.
+
+**Repos/hosts:** Dev repo: `electron-server:/home/electron/lelectron-rare/formations-app` (git → Gitea `electron-rare/formations-app`). Build & run on **Tower** (`clems@192.168.0.120`, clone `~/formations-app`). Moodle: containers `moodle`/`moodle-db` on Tower, network `moodle-net`, public `https://moodle.saillant.cc`.
+
+**Build check command** (from the repo dir on electron-server; docker0 egress is blocked → `--network=host`):
+
+```bash
+docker run --rm --network=host -v "$PWD":/app -w /app node:22-alpine \
+ sh -c 'npm ci --silent && npm run build'
+```
+
+**Conventions:** commits subject ≤ 50 chars, body ≤ 72, no AI attribution. All file edits from this controller/agents: write locally, `scp` to the host, `mv` into place (no giant heredocs).
+
+---
+
+### Task 1: Moodle web services provisioning + real fixtures
+
+**Files:**
+- Create: `ops/provision-ws.php` (run inside the moodle container)
+- Create: `fixtures/course-contents-3.json` (REAL response, committed)
+- Create: `ops/README.md`
+
+- [ ] **Step 1.1:** Create `ops/provision-ws.php`:
+
+```php
+dirroot.'/user/lib.php');
+require_once($CFG->dirroot.'/lib/enrollib.php');
+
+// 1) Enable web services + REST + built-in mobile service
+// (mobile service already includes core_course_get_contents & file serving)
+set_config('enablewebservices', 1);
+set_config('enablemobilewebservice', 1);
+$protocols = (string)get_config('core', 'webserviceprotocols');
+if (strpos($protocols, 'rest') === false) {
+ set_config('webserviceprotocols', trim($protocols === '' ? 'rest' : $protocols . ',rest', ','));
+}
+
+// 2) Service user
+$username = 'wsreader';
+$password = getenv('WSREADER_PASSWORD');
+if (!$password) { fwrite(STDERR, "WSREADER_PASSWORD required\n"); exit(1); }
+$user = core_user::get_user_by_username($username);
+if (!$user) {
+ $u = new stdClass();
+ $u->username = $username;
+ $u->password = $password;
+ $u->firstname = 'WS';
+ $u->lastname = 'Reader';
+ $u->email = 'wsreader@saillant.cc';
+ $u->confirmed = 1;
+ $u->mnethostid = $CFG->mnet_localhost_id;
+ $uid = user_create_user($u, true, false);
+ $user = core_user::get_user($uid);
+ echo "user created: {$user->id}\n";
+} else {
+ echo "user exists: {$user->id}\n";
+}
+
+// 3) Enrol as student in every real course (id > 1) so get_contents works
+$student = $DB->get_record('role', ['shortname' => 'student'], '*', MUST_EXIST);
+$manual = enrol_get_plugin('manual');
+foreach ($DB->get_records_select('course', 'id > 1') as $course) {
+ $minstance = null;
+ foreach (enrol_get_instances($course->id, false) as $i) {
+ if ($i->enrol === 'manual') { $minstance = $i; break; }
+ }
+ if (!$minstance) {
+ $id = $manual->add_instance($course);
+ $minstance = $DB->get_record('enrol', ['id' => $id], '*', MUST_EXIST);
+ }
+ $manual->enrol_user($minstance, $user->id, $student->id);
+ echo "enrolled: {$course->shortname}\n";
+}
+echo "OK\n";
+```
+
+- [ ] **Step 1.2:** Run it on Tower (generate a strong password first, keep it in `clems@Tower:~/formations-app.env` — NOT in git):
+
+```bash
+WSPASS=$(openssl rand -base64 18 | tr -d '/+=' | head -c 20)A1!
+echo "WSREADER_PASSWORD=$WSPASS" >> ~/formations-app.env
+docker cp ops/provision-ws.php moodle:/tmp/provision-ws.php
+docker exec -e WSREADER_PASSWORD="$WSPASS" moodle php /tmp/provision-ws.php
+```
+Expected: `user created: …`, 6× `enrolled: …`, `OK`.
+
+- [ ] **Step 1.3:** Obtain the token (public URL — Moodle enforces wwwroot) and PROVE the API works; capture the real fixture:
+
+```bash
+TOKEN=$(curl -s "https://moodle.saillant.cc/login/token.php" \
+ --data-urlencode "username=wsreader" \
+ --data-urlencode "password=$WSPASS" \
+ --data-urlencode "service=moodle_mobile_app" | python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])')
+echo "MOODLE_WS_TOKEN=$TOKEN" >> ~/formations-app.env
+curl -s "https://moodle.saillant.cc/webservice/rest/server.php" \
+ --data-urlencode "wstoken=$TOKEN" \
+ --data-urlencode "wsfunction=core_course_get_contents" \
+ --data-urlencode "moodlewsrestformat=json" \
+ --data-urlencode "courseid=3" > /tmp/course-contents-3.json
+python3 -m json.tool /tmp/course-contents-3.json | head -40
+```
+Expected: JSON array of sections; `mod_book` modules carry `contents` entries with `fileurl` per chapter. **If the shape differs from the client in Task 3, Task 3's parser is adapted to THIS fixture — the fixture is the source of truth.** Also verify a chapter file fetch works:
+
+```bash
+FILEURL=$(python3 -c "import json;d=json.load(open('/tmp/course-contents-3.json'));print(next(c['fileurl'] for s in d for m in s['modules'] if m['modname']=='book' for c in m.get('contents',[]) if c['filename']=='index.html'))")
+curl -s "${FILEURL}&token=$TOKEN" | head -5
+```
+Expected: chapter HTML.
+
+- [ ] **Step 1.4:** Copy the fixture into the repo (electron-server) as `fixtures/course-contents-3.json`. Write `ops/README.md` documenting: what provision-ws.php does, where the env file lives on Tower (`~/formations-app.env`, holds `WSREADER_PASSWORD` + `MOODLE_WS_TOKEN`), and the token-renewal curl from Step 1.3.
+
+- [ ] **Step 1.5: Commit** — `git add ops fixtures && git commit -m 'feat: moodle ws provisioning + real fixture'`
+
+---
+
+### Task 2: Scaffold the Astro app
+
+**Files:**
+- Create: `package.json`, `astro.config.mjs`, `tsconfig.json`, `Dockerfile`, `.gitignore`, `.dockerignore`
+- Create: `src/styles/global.css`, `src/layouts/Base.astro`, `src/components/Nav.astro`, `src/pages/index.astro` (placeholder)
+
+- [ ] **Step 2.1:** `package.json` (pin three EXACTLY — use the latest stable at implementation time via `npm view three version`, write it without `^`):
+
+```json
+{
+ "name": "formations-app",
+ "type": "module",
+ "version": "0.1.0",
+ "engines": { "node": ">=22.12.0" },
+ "scripts": {
+ "dev": "astro dev",
+ "build": "astro build",
+ "test": "vitest run",
+ "astro": "astro"
+ },
+ "dependencies": {
+ "@astrojs/node": "^10.0.4",
+ "astro": "^6.1.3",
+ "@tailwindcss/vite": "^4.2.2",
+ "tailwindcss": "^4.2.2",
+ "three": "0.180.0"
+ },
+ "devDependencies": {
+ "vitest": "^3.0.0",
+ "@types/three": "0.180.0"
+ }
+}
+```
+(If `three@0.180.0` does not exist, pin the latest exact version and matching `@types/three`; record the chosen version in the commit body.)
+
+- [ ] **Step 2.2:** `astro.config.mjs`:
+
+```js
+// @ts-check
+import { defineConfig } from 'astro/config';
+import tailwindcss from '@tailwindcss/vite';
+import node from '@astrojs/node';
+
+export default defineConfig({
+ site: 'https://formations.saillant.cc',
+ output: 'server',
+ adapter: node({ mode: 'standalone' }),
+ vite: { plugins: [tailwindcss()] },
+});
+```
+
+- [ ] **Step 2.3:** `Dockerfile` (same pattern as lelectronrare.fr, no legacy-peer-deps needed here):
+
+```dockerfile
+FROM node:22-alpine AS builder
+WORKDIR /app
+COPY package.json package-lock.json ./
+RUN npm ci
+COPY . .
+RUN npm run build
+
+FROM node:22-alpine
+WORKDIR /app
+COPY --from=builder /app/dist ./dist
+COPY --from=builder /app/node_modules ./node_modules
+ENV HOST=0.0.0.0 PORT=4321
+EXPOSE 4321
+HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://localhost:4321/ || exit 1
+CMD ["node", "dist/server/entry.mjs"]
+```
+
+`.gitignore`: `node_modules`, `dist`, `.astro`, `*.env`. `.dockerignore`: `node_modules`, `dist`, `.git`, `docs`, `fixtures`, `ops`.
+
+- [ ] **Step 2.4:** `src/styles/global.css` — dark/copper identity (NOT the blue site palette; this app is the dark experience):
+
+```css
+@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
+@import "tailwindcss";
+
+@theme {
+ --color-night: #0b0f17;
+ --color-night-soft: #141c2e;
+ --color-copper: #f97316;
+ --color-copper-soft: #fdba74;
+ --color-copper-dim: rgba(249, 115, 22, 0.12);
+ --color-ink: #1d1d1f;
+ --color-paper: #fafafa;
+ --font-family-sans: 'Inter', system-ui, sans-serif;
+ --font-family-mono: 'JetBrains Mono', monospace;
+ --max-width-content: 980px;
+ --max-width-prose: 720px;
+}
+
+html { font-family: 'Inter', system-ui, sans-serif; background: var(--color-night); color: #e2e8f0; scroll-behavior: smooth; }
+::selection { background: var(--color-copper); color: white; }
+* { box-sizing: border-box; }
+```
+
+- [ ] **Step 2.5:** `src/layouts/Base.astro`:
+
+```astro
+---
+interface Props { title: string; description: string; light?: boolean; }
+const { title, description, light = false } = Astro.props;
+const fullTitle = `${title} | Formations L'Electron Rare`;
+---
+
+
+
+
+
+ {fullTitle}
+
+
+
+
+
+
+
+
+
+
+
+```
+
+- [ ] **Step 2.6:** `src/components/Nav.astro` — fixed, transparent on dark / white on light pages:
+
+```astro
+---
+interface Props { light?: boolean; }
+const { light = false } = Astro.props;
+---
+
+
+
+```
+
+- [ ] **Step 2.7:** Placeholder `src/pages/index.astro` (replaced in Task 5):
+
+```astro
+---
+import Base from '../layouts/Base.astro';
+import Nav from '../components/Nav.astro';
+---
+
+
+
+ Formations
+
+
+```
+
+- [ ] **Step 2.8:** Generate the lockfile and build (this also pins three):
+
+```bash
+docker run --rm --network=host -v "$PWD":/app -w /app node:22-alpine sh -c 'npm install --silent && npm run build'
+```
+Expected: exit 0, `package-lock.json` created.
+
+- [ ] **Step 2.9: Commit** — `git add -A && git commit -m 'feat: scaffold astro app, dark identity'`
+
+---
+
+### Task 3: BFF — cache + Moodle client (TDD on the real fixture)
+
+**Files:**
+- Create: `src/lib/cache.ts`, `src/lib/moodle.ts`, `src/config/courses.ts`
+- Test: `src/lib/cache.test.ts`, `src/lib/moodle.test.ts`
+
+- [ ] **Step 3.1:** `src/config/courses.ts` — single source of truth, app-side:
+
+```ts
+export interface CourseDef {
+ id: number; // Moodle course id
+ slug: string;
+ title: string;
+ blurb: string;
+ published: boolean;
+}
+
+export const COURSES: CourseDef[] = [
+ { id: 2, slug: 'esp32-ia', title: 'ESP32 + IA Embarquée', blurb: "Capteurs, TinyML et LLM connectés sur ESP32-S3.", published: true },
+ { id: 3, slug: 'kicad-makers', title: 'KiCad pour Makers', blurb: "Du schéma au PCB fabriqué, projet fil rouge ESP32-Breakout.", published: true },
+ { id: 5, slug: 'freertos', title: "FreeRTOS pour l'embarqué", blurb: "Tâches, files, priorités et patterns temps réel.", published: true },
+ { id: 4, slug: 'llm-locaux', title: 'Déployer des LLM locaux', blurb: "Choisir, servir et intégrer des modèles open-weights.", published: false },
+ { id: 6, slug: 'iot-az', title: 'IoT de A à Z', blurb: "Du capteur connecté au dashboard Grafana en production.", published: false },
+ { id: 7, slug: 'docker-selfhosting', title: 'Docker et Self-Hosting', blurb: "Conteneurs, Compose, Traefik et mise en production.", published: false },
+];
+
+export const bySlug = (slug: string) => COURSES.find((c) => c.slug === slug);
+```
+
+- [ ] **Step 3.2:** Write `src/lib/cache.test.ts` FIRST:
+
+```ts
+import { describe, it, expect, vi } from 'vitest';
+import { swrCache } from './cache';
+
+describe('swrCache', () => {
+ it('caches within ttl', async () => {
+ const fn = vi.fn().mockResolvedValue('a');
+ const get = swrCache(fn, 1000);
+ expect(await get('k')).toBe('a');
+ expect(await get('k')).toBe('a');
+ expect(fn).toHaveBeenCalledTimes(1);
+ });
+
+ it('serves stale on refresh failure', async () => {
+ vi.useFakeTimers();
+ const fn = vi.fn().mockResolvedValueOnce('fresh').mockRejectedValue(new Error('down'));
+ const get = swrCache(fn, 1000);
+ expect(await get('k')).toBe('fresh');
+ vi.advanceTimersByTime(2000);
+ expect(await get('k')).toBe('fresh'); // stale served, refresh failed silently
+ vi.useRealTimers();
+ });
+
+ it('propagates error when cache is cold', async () => {
+ const fn = vi.fn().mockRejectedValue(new Error('down'));
+ const get = swrCache(fn, 1000);
+ await expect(get('k')).rejects.toThrow('down');
+ });
+});
+```
+
+- [ ] **Step 3.3:** Run: `docker run --rm --network=host -v "$PWD":/app -w /app node:22-alpine sh -c 'npm ci --silent && npx vitest run src/lib/cache.test.ts'` — Expected: FAIL (module missing).
+
+- [ ] **Step 3.4:** `src/lib/cache.ts`:
+
+```ts
+type Entry = { value: T; at: number };
+
+/** Stale-while-revalidate in-memory cache: within ttl → cached; past ttl →
+ * try refresh, fall back to stale value if refresh fails; cold → propagate. */
+export function swrCache(fetcher: (key: string) => Promise, ttlMs: number) {
+ const store = new Map>();
+ return async (key: string): Promise => {
+ const hit = store.get(key);
+ const now = Date.now();
+ if (hit && now - hit.at < ttlMs) return hit.value;
+ try {
+ const value = await fetcher(key);
+ store.set(key, { value, at: now });
+ return value;
+ } catch (err) {
+ if (hit) {
+ console.error(`[cache] refresh failed for ${key}, serving stale:`, err);
+ return hit.value;
+ }
+ throw err;
+ }
+ };
+}
+```
+
+- [ ] **Step 3.5:** Run the cache tests — Expected: 3 PASS.
+
+- [ ] **Step 3.6:** Write `src/lib/moodle.test.ts` FIRST, parsing the REAL fixture (adjust expected counts to the fixture's actual content — kicad-makers has 4 books, 13/16/18/19 chapters):
+
+```ts
+import { describe, it, expect } from 'vitest';
+import { readFileSync } from 'node:fs';
+import { parseCourseContents } from './moodle';
+
+const fixture = JSON.parse(readFileSync('fixtures/course-contents-3.json', 'utf8'));
+
+describe('parseCourseContents', () => {
+ it('extracts 4 book modules in order', () => {
+ const mods = parseCourseContents(fixture);
+ expect(mods).toHaveLength(4);
+ expect(mods[0].title).toMatch(/Module 1/);
+ expect(mods[3].title).toMatch(/Module 4/);
+ });
+
+ it('extracts chapters with titles and file urls', () => {
+ const mods = parseCourseContents(fixture);
+ expect(mods[0].chapters.length).toBeGreaterThan(5);
+ for (const ch of mods[0].chapters) {
+ expect(ch.title).toBeTruthy();
+ expect(ch.fileUrl).toContain('pluginfile.php');
+ }
+ });
+});
+```
+
+- [ ] **Step 3.7:** Run it — Expected: FAIL. Then implement `src/lib/moodle.ts`:
+
+```ts
+import { swrCache } from './cache';
+
+export interface Chapter { title: string; fileUrl: string; }
+export interface BookModule { id: number; title: string; chapters: Chapter[]; }
+
+const BASE = process.env.MOODLE_BASE_URL || 'https://moodle.saillant.cc';
+const TOKEN = process.env.MOODLE_WS_TOKEN || '';
+const TTL = Number(process.env.MOODLE_CACHE_TTL_MS || 5 * 60_000);
+
+async function ws(fn: string, params: Record): Promise {
+ const body = new URLSearchParams({
+ wstoken: TOKEN, wsfunction: fn, moodlewsrestformat: 'json', ...params,
+ });
+ const res = await fetch(`${BASE}/webservice/rest/server.php`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body, signal: AbortSignal.timeout(8000),
+ });
+ if (!res.ok) throw new Error(`moodle ws ${fn}: HTTP ${res.status}`);
+ const data = await res.json();
+ if (data && typeof data === 'object' && 'exception' in data)
+ throw new Error(`moodle ws ${fn}: ${(data as { message?: string }).message}`);
+ return data;
+}
+
+/** Shape source of truth: fixtures/course-contents-3.json (real instance).
+ * Sections → modules (modname 'book') → contents (files, one structured
+ * index per chapter). Adapt here if the fixture differs. */
+export function parseCourseContents(sections: any[]): BookModule[] {
+ const books: BookModule[] = [];
+ for (const section of sections) {
+ for (const mod of section.modules ?? []) {
+ if (mod.modname !== 'book') continue;
+ const chapters: Chapter[] = [];
+ for (const c of mod.contents ?? []) {
+ if (c.filename !== 'index.html') continue;
+ // content entries carry the chapter title in `content` items'
+ // structure; Moodle exposes one index.html per chapter whose
+ // `filepath` is "//" and whose chapter title comes from
+ // the parallel `tags`/structure entry — verified against fixture.
+ chapters.push({ title: c.chaptertitle ?? c.filepath?.replaceAll('/', '') ?? 'Chapitre', fileUrl: c.fileurl });
+ }
+ books.push({ id: mod.id, title: mod.name, chapters });
+ }
+ }
+ return books;
+}
+
+const contentsCached = swrCache(async (courseId: string) => {
+ const sections = (await ws('core_course_get_contents', { courseid: courseId })) as any[];
+ return parseCourseContents(sections);
+}, TTL);
+
+export const getCourseBooks = (courseId: number) => contentsCached(String(courseId));
+
+const chapterCached = swrCache(async (fileUrl: string) => {
+ const sep = fileUrl.includes('?') ? '&' : '?';
+ const res = await fetch(`${fileUrl}${sep}token=${TOKEN}`, { signal: AbortSignal.timeout(8000) });
+ if (!res.ok) throw new Error(`chapter fetch: HTTP ${res.status}`);
+ return res.text();
+}, TTL);
+
+export const getChapterHtml = (fileUrl: string) => chapterCached(fileUrl);
+```
+
+**IMPORTANT for the implementer:** the chapter-title extraction above is a best guess written before seeing the fixture. Open `fixtures/course-contents-3.json`, find where each chapter's human title actually lives (Moodle 4.5 book contents usually expose `content` entries with `filepath: "//"` and the title in the entry's `"content"` sibling list — or only in the book's `customdata`/structure JSON), and make `parseCourseContents` + the test reflect REALITY. The committed test must assert real titles (e.g. `Introduction`, `1.1 Introduction et installation`). If titles are only in `customdata.structure` (JSON string), parse that.
+
+- [ ] **Step 3.8:** Run all tests: `npx vitest run` (in the docker runner) — Expected: PASS.
+
+- [ ] **Step 3.9: Commit** — `git add src/lib src/config fixtures && git commit -m 'feat: bff moodle client with swr cache'`
+
+---
+
+### Task 4: HTML sanitizer (TDD)
+
+**Files:**
+- Create: `src/lib/sanitize.ts`
+- Test: `src/lib/sanitize.test.ts`
+
+- [ ] **Step 4.1:** Test first:
+
+```ts
+import { describe, it, expect } from 'vitest';
+import { sanitizeHtml } from './sanitize';
+
+describe('sanitizeHtml', () => {
+ it('keeps structural/prose tags', () => {
+ const html = 'T a b c
y ';
+ expect(sanitizeHtml(html)).toBe(html);
+ });
+ it('strips script/style/iframe and event handlers', () => {
+ expect(sanitizeHtml('a
')).toBe('a
');
+ });
+ it('keeps safe links and images, strips javascript: urls', () => {
+ expect(sanitizeHtml('l m '))
+ .toBe('l m ');
+ });
+});
+```
+
+- [ ] **Step 4.2:** Run → FAIL. Implement `src/lib/sanitize.ts` (zero-dep allowlist, regex-tokenizer over tags — content is trusted-ish course HTML, this is defense in depth):
+
+```ts
+const ALLOWED: Record = {
+ h1: [], h2: [], h3: [], h4: [], p: [], br: [], hr: [],
+ strong: [], b: [], em: [], i: [], u: [], s: [], mark: [], small: [], sub: [], sup: [],
+ ul: [], ol: ['start'], li: [], blockquote: [], pre: [], code: [],
+ table: [], thead: [], tbody: [], tr: [], th: ['colspan', 'rowspan'], td: ['colspan', 'rowspan'],
+ a: ['href', 'title'], img: ['src', 'alt', 'title', 'width', 'height'],
+ figure: [], figcaption: [], div: [], span: [], dl: [], dt: [], dd: [],
+};
+const DROP_WITH_CONTENT = new Set(['script', 'style', 'iframe', 'object', 'embed', 'noscript']);
+const SAFE_URL = /^(https?:|\/|#|mailto:)/i;
+
+export function sanitizeHtml(html: string): string {
+ // 1) remove dangerous elements with their content
+ let out = html;
+ for (const tag of DROP_WITH_CONTENT) {
+ out = out.replace(new RegExp(`<${tag}[\\s\\S]*?${tag}>`, 'gi'), '');
+ out = out.replace(new RegExp(`<${tag}[^>]*/?>`, 'gi'), '');
+ }
+ // 2) walk remaining tags, keep allowlisted ones with allowlisted attrs
+ return out.replace(/<\/?([a-zA-Z][a-zA-Z0-9]*)((?:\s+[^<>]*?)?)(\/?)>/g, (m, name, attrs, selfClose) => {
+ const tag = name.toLowerCase();
+ if (!(tag in ALLOWED)) return '';
+ if (m.startsWith('')) return `${tag}>`;
+ const allowed = ALLOWED[tag];
+ let kept = '';
+ const attrRe = /([a-zA-Z-]+)\s*=\s*("([^"]*)"|'([^']*)'|(\S+))/g;
+ let a: RegExpExecArray | null;
+ while ((a = attrRe.exec(attrs)) !== null) {
+ const attr = a[1].toLowerCase();
+ const val = a[3] ?? a[4] ?? a[5] ?? '';
+ if (!allowed.includes(attr)) continue;
+ if ((attr === 'href' || attr === 'src') && !SAFE_URL.test(val)) continue;
+ kept += ` ${attr}="${val.replace(/"/g, '"')}"`;
+ }
+ return `<${tag}${kept}${selfClose ? '' : ''}>`;
+ });
+}
+```
+Note: the first test asserts byte-identity for clean input — if the implementation normalizes (e.g. drops a self-closing slash), relax the test to semantic equality instead of weakening the sanitizer.
+
+- [ ] **Step 4.3:** Run → PASS. **Commit** — `git add src/lib/sanitize* && git commit -m 'feat: allowlist html sanitizer'`
+
+---
+
+### Task 5: Catalogue page
+
+**Files:**
+- Modify: `src/pages/index.astro` (replace placeholder)
+
+- [ ] **Step 5.1:** Implement:
+
+```astro
+---
+import Base from '../layouts/Base.astro';
+import Nav from '../components/Nav.astro';
+import { COURSES } from '../config/courses';
+const published = COURSES.filter((c) => c.published);
+const upcoming = COURSES.filter((c) => !c.published);
+---
+
+
+
+ Formations
+
+ Lecture libre, sans compte. Un compte n'est nécessaire que pour les quiz et le certificat.
+
+
+
+
+ En préparation
+
+ {upcoming.map((c) => (
+
+
+
{c.title}
+
{c.blurb}
+
Bientôt
+
+ ))}
+
+
+
+```
+
+- [ ] **Step 5.2:** Build → exit 0. **Commit** — `git add src/pages/index.astro && git commit -m 'feat: catalogue page'`
+
+---
+
+### Task 6: Hub page with SSR sommaire (no 3D yet)
+
+**Files:**
+- Create: `src/pages/cours/[slug]/index.astro`, `src/components/Sommaire.astro`
+- Create: `src/pages/404.astro`
+- Create: `src/middleware.ts` (legacy redirects)
+
+- [ ] **Step 6.1:** `src/components/Sommaire.astro` — the always-rendered HTML TOC:
+
+```astro
+---
+import type { BookModule } from '../lib/moodle';
+interface Props { slug: string; modules: BookModule[]; }
+const { slug, modules } = Astro.props;
+---
+
+ {modules.map((m, mi) => (
+
+ ))}
+
+```
+
+- [ ] **Step 6.2:** `src/pages/cours/[slug]/index.astro`:
+
+```astro
+---
+import Base from '../../../layouts/Base.astro';
+import Nav from '../../../components/Nav.astro';
+import Sommaire from '../../../components/Sommaire.astro';
+import { bySlug } from '../../../config/courses';
+import { getCourseBooks } from '../../../lib/moodle';
+
+const course = bySlug(Astro.params.slug ?? '');
+if (!course || !course.published) return Astro.redirect('/404');
+
+let modules;
+try {
+ modules = await getCourseBooks(course.id);
+} catch {
+ return new Response('Formation momentanément indisponible — réessayez dans quelques minutes.', { status: 503 });
+}
+const hubData = {
+ slug: course.slug,
+ modules: modules.map((m, mi) => ({
+ title: m.title,
+ chapters: m.chapters.map((ch, ci) => ({ title: ch.title, url: `/cours/${course.slug}/module-${mi + 1}/chapitre-${ci + 1}` })),
+ })),
+};
+---
+
+
+
+
+ Formation
+ {course.title}
+ {course.blurb}
+
+
+
+
+
+```
+(The `atom-mount` div stays empty/hidden until Task 8 wires the 3D island; the sommaire is the page.)
+
+- [ ] **Step 6.3:** `src/pages/404.astro`:
+
+```astro
+---
+import Base from '../layouts/Base.astro';
+import Nav from '../components/Nav.astro';
+---
+
+
+
+ 404
+ Cette page n'existe pas (ou plus).
+ Voir les formations
+
+
+```
+
+- [ ] **Step 6.4:** `src/middleware.ts` — legacy Moodle URLs:
+
+```ts
+import { defineMiddleware } from 'astro:middleware';
+import { COURSES } from './config/courses';
+
+export const onRequest = defineMiddleware((ctx, next) => {
+ const { pathname, searchParams } = ctx.url;
+ if (pathname === '/course/view.php') {
+ const id = Number(searchParams.get('id'));
+ const course = COURSES.find((c) => c.id === id);
+ return ctx.redirect(course ? `/cours/${course.slug}` : '/', 301);
+ }
+ return next();
+});
+```
+
+- [ ] **Step 6.5:** Build → exit 0. **Commit** — `git add src && git commit -m 'feat: hub page with ssr sommaire + redirects'`
+
+---
+
+### Task 7: Reading page
+
+**Files:**
+- Create: `src/pages/cours/[slug]/[module]/[chapitre].astro`
+
+- [ ] **Step 7.1:** Implement (light page, prose column, breadcrumb, prev/next, margin position indicator as a simple SVG — the 3D stays on the hub):
+
+```astro
+---
+import Base from '../../../../layouts/Base.astro';
+import Nav from '../../../../components/Nav.astro';
+import { bySlug } from '../../../../config/courses';
+import { getCourseBooks, getChapterHtml } from '../../../../lib/moodle';
+import { sanitizeHtml } from '../../../../lib/sanitize';
+
+const course = bySlug(Astro.params.slug ?? '');
+const mMatch = /^module-(\d+)$/.exec(Astro.params.module ?? '');
+const cMatch = /^chapitre-(\d+)$/.exec(Astro.params.chapitre ?? '');
+if (!course || !course.published || !mMatch || !cMatch) return Astro.redirect('/404');
+
+let modules;
+try { modules = await getCourseBooks(course.id); }
+catch { return new Response('Formation momentanément indisponible.', { status: 503 }); }
+
+const mi = Number(mMatch[1]) - 1;
+const ci = Number(cMatch[1]) - 1;
+const mod = modules[mi];
+const chapter = mod?.chapters[ci];
+if (!mod || !chapter) return Astro.redirect('/404');
+
+let html;
+try { html = sanitizeHtml(await getChapterHtml(chapter.fileUrl)); }
+catch { return new Response('Chapitre momentanément indisponible.', { status: 503 }); }
+
+const prev = ci > 0 ? `/cours/${course.slug}/module-${mi + 1}/chapitre-${ci}`
+ : mi > 0 ? `/cours/${course.slug}/module-${mi}/chapitre-${modules[mi - 1].chapters.length}` : null;
+const next = ci < mod.chapters.length - 1 ? `/cours/${course.slug}/module-${mi + 1}/chapitre-${ci + 2}`
+ : mi < modules.length - 1 ? `/cours/${course.slug}/module-${mi + 2}/chapitre-1` : null;
+---
+
+
+
+
+
+
+
+
+```
+
+- [ ] **Step 7.2:** Build → exit 0. **Commit** — `git add src/pages && git commit -m 'feat: chapter reading page'`
+
+---
+
+### Task 8: The three.js atom hub island
+
+**Files:**
+- Create: `src/scripts/atom-hub.ts` (the three.js module)
+- Modify: `src/pages/cours/[slug]/index.astro` (wire the island)
+
+- [ ] **Step 8.1:** `src/scripts/atom-hub.ts` — complete module:
+
+```ts
+import * as THREE from 'three';
+import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
+import { CSS2DRenderer, CSS2DObject } from 'three/addons/renderers/CSS2DRenderer.js';
+
+interface HubChapter { title: string; url: string; }
+interface HubModule { title: string; chapters: HubChapter[]; }
+interface HubData { slug: string; modules: HubModule[]; }
+
+const COPPER = 0xf97316;
+const COPPER_SOFT = 0xfdba74;
+
+export function mountAtomHub(mount: HTMLElement, data: HubData): void {
+ const W = mount.clientWidth;
+ const H = Math.min(620, Math.max(420, window.innerHeight * 0.6));
+
+ const scene = new THREE.Scene();
+ const camera = new THREE.PerspectiveCamera(50, W / H, 0.1, 100);
+ camera.position.set(0, 2.2, 7.5);
+
+ const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
+ renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
+ renderer.setSize(W, H);
+ mount.appendChild(renderer.domElement);
+
+ const labels = new CSS2DRenderer();
+ labels.setSize(W, H);
+ labels.domElement.style.cssText = 'position:absolute;top:0;left:0;pointer-events:none;';
+ mount.style.position = 'relative';
+ mount.appendChild(labels.domElement);
+
+ // Nucleus: glowing copper sphere
+ const nucleus = new THREE.Mesh(
+ new THREE.SphereGeometry(0.42, 32, 32),
+ new THREE.MeshBasicMaterial({ color: COPPER_SOFT })
+ );
+ scene.add(nucleus);
+ const glowTex = makeGlowTexture();
+ const glow = new THREE.Sprite(new THREE.SpriteMaterial({ map: glowTex, color: COPPER, transparent: true, opacity: 0.85, depthWrite: false }));
+ glow.scale.setScalar(2.6);
+ scene.add(glow);
+
+ // Orbits: one tilted ellipse per module, electrons = chapters
+ const electronMeshes: THREE.Mesh[] = [];
+ const electronGeo = new THREE.SphereGeometry(0.09, 16, 16);
+ const electronMat = new THREE.MeshBasicMaterial({ color: COPPER_SOFT });
+
+ data.modules.forEach((mod, mi) => {
+ const group = new THREE.Group();
+ group.rotation.x = 0.95 - mi * 0.42;
+ group.rotation.z = mi * (Math.PI / data.modules.length);
+ const rx = 1.9 + mi * 0.75;
+ const rz = rx * 0.62;
+
+ const curve = new THREE.EllipseCurve(0, 0, rx, rz, 0, Math.PI * 2);
+ const pts = curve.getPoints(96).map((p) => new THREE.Vector3(p.x, 0, p.y));
+ const orbit = new THREE.LineLoop(
+ new THREE.BufferGeometry().setFromPoints(pts),
+ new THREE.LineBasicMaterial({ color: COPPER, transparent: true, opacity: 0.32 })
+ );
+ group.add(orbit);
+
+ // Module label anchored on the orbit's far point
+ const labelEl = document.createElement('div');
+ labelEl.textContent = mod.title.replace(/^Module \d+ ?: ?/, `M${mi + 1} · `);
+ labelEl.style.cssText = 'color:#fdba74;font-size:12px;background:rgba(11,15,23,.7);border:1px solid rgba(249,115,22,.35);border-radius:99px;padding:2px 10px;white-space:nowrap;';
+ const label = new CSS2DObject(labelEl);
+ label.position.set(rx, 0, 0);
+ group.add(label);
+
+ mod.chapters.forEach((ch, ci) => {
+ const e = new THREE.Mesh(electronGeo, electronMat.clone());
+ const angle = (ci / mod.chapters.length) * Math.PI * 2;
+ e.position.set(Math.cos(angle) * rx, 0, Math.sin(angle) * rz);
+ e.userData = { url: ch.url, title: ch.title, baseAngle: angle, rx, rz, speed: 0.05 + mi * 0.012 };
+ const eGlow = new THREE.Sprite(new THREE.SpriteMaterial({ map: glowTex, color: COPPER, transparent: true, opacity: 0.55, depthWrite: false }));
+ eGlow.scale.setScalar(0.5);
+ e.add(eGlow);
+ group.add(e);
+ electronMeshes.push(e);
+ });
+ scene.add(group);
+ });
+
+ // Tooltip
+ const tip = document.createElement('div');
+ tip.style.cssText = 'position:absolute;pointer-events:none;display:none;color:#fff;font-size:13px;background:rgba(11,15,23,.92);border:1px solid rgba(249,115,22,.5);border-radius:10px;padding:6px 12px;z-index:10;max-width:280px;';
+ mount.appendChild(tip);
+
+ const controls = new OrbitControls(camera, renderer.domElement);
+ controls.enableDamping = true;
+ controls.enablePan = false;
+ controls.minDistance = 4;
+ controls.maxDistance = 12;
+ controls.autoRotate = true;
+ controls.autoRotateSpeed = 0.5;
+
+ const raycaster = new THREE.Raycaster();
+ const pointer = new THREE.Vector2(-2, -2);
+ let hovered: THREE.Mesh | null = null;
+
+ renderer.domElement.addEventListener('pointermove', (ev) => {
+ const r = renderer.domElement.getBoundingClientRect();
+ pointer.set(((ev.clientX - r.left) / r.width) * 2 - 1, -((ev.clientY - r.top) / r.height) * 2 + 1);
+ tip.style.left = `${ev.clientX - r.left + 14}px`;
+ tip.style.top = `${ev.clientY - r.top + 14}px`;
+ });
+ renderer.domElement.addEventListener('click', () => {
+ if (hovered) location.href = (hovered.userData as { url: string }).url;
+ });
+
+ let raf = 0;
+ const clock = new THREE.Clock();
+ function frame() {
+ raf = requestAnimationFrame(frame);
+ const t = clock.getElapsedTime();
+ controls.update();
+ nucleus.scale.setScalar(1 + Math.sin(t * 1.6) * 0.06);
+ for (const e of electronMeshes) {
+ const d = e.userData as { baseAngle: number; rx: number; rz: number; speed: number };
+ const a = d.baseAngle + t * d.speed;
+ e.position.set(Math.cos(a) * d.rx, 0, Math.sin(a) * d.rz);
+ }
+ raycaster.setFromCamera(pointer, camera);
+ const hit = raycaster.intersectObjects(electronMeshes, false)[0];
+ const target = (hit?.object as THREE.Mesh) ?? null;
+ if (target !== hovered) {
+ if (hovered) hovered.scale.setScalar(1);
+ hovered = target;
+ if (hovered) {
+ hovered.scale.setScalar(1.8);
+ tip.textContent = (hovered.userData as { title: string }).title;
+ tip.style.display = 'block';
+ renderer.domElement.style.cursor = 'pointer';
+ controls.autoRotate = false;
+ } else {
+ tip.style.display = 'none';
+ renderer.domElement.style.cursor = 'grab';
+ controls.autoRotate = true;
+ }
+ }
+ renderer.render(scene, camera);
+ labels.render(scene, camera);
+ }
+
+ // Pause when hidden/offscreen
+ const io = new IntersectionObserver((es) => {
+ if (es[0].isIntersecting && !document.hidden) { if (!raf) frame(); }
+ else { cancelAnimationFrame(raf); raf = 0; }
+ });
+ io.observe(mount);
+ document.addEventListener('visibilitychange', () => {
+ if (document.hidden) { cancelAnimationFrame(raf); raf = 0; } else if (!raf) frame();
+ });
+
+ addEventListener('resize', () => {
+ const w = mount.clientWidth;
+ camera.aspect = w / H;
+ camera.updateProjectionMatrix();
+ renderer.setSize(w, H);
+ labels.setSize(w, H);
+ });
+
+ frame();
+}
+
+function makeGlowTexture(): THREE.Texture {
+ const c = document.createElement('canvas');
+ c.width = c.height = 64;
+ const g = c.getContext('2d')!;
+ const grad = g.createRadialGradient(32, 32, 0, 32, 32, 32);
+ grad.addColorStop(0, 'rgba(253,186,116,1)');
+ grad.addColorStop(0.35, 'rgba(249,115,22,0.45)');
+ grad.addColorStop(1, 'rgba(249,115,22,0)');
+ g.fillStyle = grad;
+ g.fillRect(0, 0, 64, 64);
+ return new THREE.CanvasTexture(c);
+}
+```
+
+- [ ] **Step 8.2:** Wire it in `src/pages/cours/[slug]/index.astro` — add at the bottom of the file:
+
+```astro
+
+```
+(The sommaire stays in the DOM below the atom — it shrinks visually but remains for scanning/SEO; on mobile/reduced-motion/no-WebGL nothing changes. Dynamic `import()` keeps three out of every other page's JS.)
+
+- [ ] **Step 8.3:** Build → exit 0; verify chunking: `ls dist/client/_astro/ | grep -i atom` and check the three chunk is NOT referenced by `/` or reading pages (grep the built HTML in a quick `node dist/server/entry.mjs` smoke run or inspect `dist/server/manifest`).
+
+- [ ] **Step 8.4: Commit** — `git add src && git commit -m 'feat: three.js atom hub island'`
+
+---
+
+### Task 9: Deploy on Tower + routing + E2E
+
+**Files:**
+- Create: `docker-compose.yml` (committed; runs on Tower)
+- Create (ES host): `/home/electron/factory-4-life/traefik/dynamic/formations.yml`
+
+- [ ] **Step 9.1:** `docker-compose.yml` in the repo:
+
+```yaml
+# Runs on Tower next to Moodle. App reaches Moodle via its public URL
+# (Moodle enforces wwwroot); content cached in-process (SWR).
+services:
+ formations-app:
+ image: formations-app:latest
+ build: .
+ container_name: formations-app
+ restart: unless-stopped
+ ports:
+ - '8096:4321'
+ env_file:
+ - /home/clems/formations-app.env # WSREADER_PASSWORD, MOODLE_WS_TOKEN
+ environment:
+ MOODLE_BASE_URL: 'https://moodle.saillant.cc'
+```
+
+- [ ] **Step 9.2:** Push the repo to Gitea, clone/build on Tower:
+
+```bash
+# on electron-server, repo dir
+git push origin main
+# on Tower
+git clone https://git.saillant.cc/electron-rare/formations-app.git ~/formations-app 2>/dev/null || git -C ~/formations-app pull
+cd ~/formations-app && docker compose up -d --build
+sleep 5 && curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8096/ # expect 200
+curl -s http://localhost:8096/cours/kicad-makers | grep -c 'sommaire' # expect >= 1
+```
+
+- [ ] **Step 9.3:** Traefik route on electron-server — `/home/electron/factory-4-life/traefik/dynamic/formations.yml`:
+
+```yaml
+# formations.saillant.cc -> formations-app on Tower (port 8096)
+# Tailscale IP: the LAN 192.168.0.x is shadowed on ES by a docker bridge.
+http:
+ routers:
+ formations:
+ rule: Host(`formations.saillant.cc`)
+ service: formations
+ entryPoints:
+ - websecure
+ tls:
+ certResolver: letsencrypt
+ services:
+ formations:
+ loadBalancer:
+ servers:
+ - url: http://100.78.6.122:8096
+```
+Verify: `curl -sk -o /dev/null -w '%{http_code}\n' --resolve formations.saillant.cc:443:127.0.0.1 https://formations.saillant.cc/` on ES → 200.
+
+- [ ] **Step 9.4:** CF tunnel ingress (recipe from `reference_cloudflared_tunnel_saillant`): GET the tunnel config (`accounts/cea4d5c2a2ff4545e8a2e06143c2134e/cfd_tunnel/2c6b04a3-9cac-4336-9dbd-9f1d432b08d8/configurations`, auth headers from `docker inspect traefik` → `CF_API_EMAIL`/`CF_API_KEY`), insert `{"service":"https://localhost:443","hostname":"formations.saillant.cc","originRequest":{"noTLSVerify":true}}` before the catch-all, PUT back. DNS: check `formations.saillant.cc` resolves (a wildcard CNAME exists; if not, POST the CNAME → `2c6b04a3-9cac-4336-9dbd-9f1d432b08d8.cfargotunnel.com`, proxied).
+
+- [ ] **Step 9.5:** E2E public:
+
+```bash
+curl -s -o /dev/null -w '%{http_code}\n' https://formations.saillant.cc/ # 200
+curl -s https://formations.saillant.cc/cours/kicad-makers | grep -c 'Module' # >= 4
+curl -s https://formations.saillant.cc/cours/kicad-makers/module-1/chapitre-1 | grep -ci 'kicad' # >= 1
+curl -s -o /dev/null -w '%{http_code}\n' 'https://formations.saillant.cc/course/view.php?id=3' # 301
+curl -s -o /dev/null -w '%{http_code}\n' https://formations.saillant.cc/cours/inconnu # 302 (-> /404)
+```
+
+- [ ] **Step 9.6: Commit & push** — `git add docker-compose.yml && git commit -m 'feat: tower deployment compose' && git push origin main`
+
+- [ ] **Step 9.7:** Owner manual check: atom hub navigation (desktop), tooltip + click-through, mobile sommaire fallback, reduced-motion, Lighthouse on a reading page (LCP < 2.5 s).
+
+---
+
+## Self-review notes
+
+- Spec coverage: provisioning+fixtures (T1), stack/scaffold (T2), BFF+cache+SWR (T3), sanitizer (T4), catalogue (T5), hub SSR sommaire + 404 + legacy redirects (T6), reading page (T7), three.js hub with WebGL/reduced-motion/mobile fallbacks + lazy chunk (T8), Tower deploy + traefik + CF ingress + E2E incl. 301 (T9). Error handling per spec: SWR stale serving (T3), 503 friendly text (T6/T7), atom failure → sommaire (T8 catch).
+- Fixture-first honesty: chapter-title extraction in T3 is explicitly marked "adapt to the real fixture"; the test pins reality.
+- Types consistent: `BookModule`/`Chapter` (moodle.ts) used by Sommaire/pages; `HubData` mirrors the `data-hub` JSON built in T6 and consumed in T8.
+- vitest is the only dev-dep addition; three pinned exact; no other runtime deps.