Compare commits
12
Commits
e1576d2360
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c004da7b20 | ||
|
|
84e5c70c0d | ||
|
|
777e74f04b | ||
|
|
2f5065bf37 | ||
|
|
354c8cd9ef | ||
|
|
10f7798afb | ||
|
|
3a52131d1e | ||
|
|
4b45521f01 | ||
|
|
da748c2cae | ||
|
|
4a5cbb950b | ||
|
|
600c07e358 | ||
|
|
9a4942daad |
@@ -0,0 +1,14 @@
|
||||
# Contexte Docker = racine du dépôt (voir server/Dockerfile)
|
||||
node_modules
|
||||
dist-portal
|
||||
dist-*
|
||||
artifacts
|
||||
.git
|
||||
.github
|
||||
e2e
|
||||
playwright-report
|
||||
test-results
|
||||
blob-report
|
||||
playwright/.cache
|
||||
.cursor
|
||||
*.log
|
||||
@@ -1,29 +1,41 @@
|
||||
name: Talks slides — image & chart
|
||||
|
||||
# Déclenché uniquement si l’image Docker ou le chart Helm change (évite les builds sur contenu slides seul).
|
||||
# Build image + chart quand le site statique (portail, decks, toolchain) ou l’infra change.
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop, cicd]
|
||||
branches: [main, master, develop, cicd]
|
||||
tags: ['v*']
|
||||
paths:
|
||||
- 'server/**'
|
||||
- 'talks-slides-dist/**'
|
||||
- '.gitea/workflows/**'
|
||||
- 'portal/**'
|
||||
- 'slides/**'
|
||||
- 'tools/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- '.dockerignore'
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
branches: [main, master, develop]
|
||||
paths:
|
||||
- 'server/**'
|
||||
- 'talks-slides-dist/**'
|
||||
- '.gitea/workflows/**'
|
||||
- 'portal/**'
|
||||
- 'slides/**'
|
||||
- 'tools/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- '.dockerignore'
|
||||
|
||||
env:
|
||||
# GIT_DEFAULT_HASH: sha256
|
||||
DOCKER_HOST: 'unix:///var/run/docker.sock'
|
||||
DOCKER_CERT_PATH: '/certs/client'
|
||||
IMAGE_REGISTRY: git.specificat.io
|
||||
IMAGE_NAME: specificat.io/talks-slides
|
||||
IMAGE_NAME: arnault/talks-slides
|
||||
# Même registre OCI Helm que les autres charts (ex. knowledge-mcp).
|
||||
HELM_OCI_REPOSITORY: oci://git.specificat.io/specificat.io
|
||||
HELM_OCI_REPOSITORY: oci://git.specificat.io/arnault
|
||||
HELM_DIR: ./talks-slides-dist
|
||||
|
||||
jobs:
|
||||
@@ -65,7 +77,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
IMAGE="${IMAGE_REGISTRY}/${IMAGE_NAME}:${VERSION}"
|
||||
docker build -f server/Dockerfile -t "${IMAGE}" server/
|
||||
docker build -f server/Dockerfile -t "${IMAGE}" .
|
||||
docker push "${IMAGE}"
|
||||
if [[ "${{ github.ref }}" == "refs/heads/main" ]]; then
|
||||
docker tag "${IMAGE}" "${IMAGE_REGISTRY}/${IMAGE_NAME}:latest"
|
||||
@@ -75,7 +87,7 @@ jobs:
|
||||
if: github.event_name == 'pull_request'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker build -f server/Dockerfile -t talks-slides:ci server/
|
||||
docker build -f server/Dockerfile -t talks-slides:ci .
|
||||
|
||||
helm:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -87,7 +99,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -fsSL -o helm.tgz --user "${{ secrets.CI_USER }}:${{ secrets.CI_TOKEN }}" \
|
||||
"https://git.specificat.io/api/packages/Specificat.io/generic/helm/4.1.1/helm-v4.1.1-linux-amd64.tar.gz"
|
||||
"https://git.specificat.io/api/packages/arnault/generic/helm/4.1.1/helm-v4.1.1-linux-amd64.tar.gz"
|
||||
tar -xzf helm.tgz
|
||||
sudo mv linux-amd64/helm /usr/local/bin/helm
|
||||
helm version
|
||||
|
||||
+13
@@ -1 +1,14 @@
|
||||
# Slidev peut laisser un index.html à côté de slides.md (dev) — ne pas versionner
|
||||
slides/**/index.html
|
||||
|
||||
node_modules/
|
||||
dist/
|
||||
dist-*/
|
||||
artifacts/
|
||||
portal/.vitepress/cache/
|
||||
.DS_Store
|
||||
*.log
|
||||
test-results/
|
||||
playwright-report/
|
||||
blob-report/
|
||||
playwright/.cache/
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# Talks — Makefile unique pour build, run, gestion des présentations
|
||||
# Variables : SITE_BASE, SLUG, FROM, TO
|
||||
|
||||
NPM ?= npm
|
||||
NODE ?= node
|
||||
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
.PHONY: help install sync run build clean test \
|
||||
create-presentation delete-presentation clone-presentation rename-presentation \
|
||||
dev-portal dev-slides
|
||||
|
||||
help:
|
||||
@echo "Talks — commandes principales"
|
||||
@echo ""
|
||||
@echo " make run Sync, build tous les decks, VitePress dev + watch (sources = vérité)"
|
||||
@echo " make build sync + vitepress build → dist-portal + build chaque dist-<slug>/"
|
||||
@echo " make sync Régénère le portail depuis slides/*/meta.json"
|
||||
@echo " make clean Supprime dist-portal, dist-*, .slide-wip, cache VitePress, artifacts"
|
||||
@echo " make test Tests Node + Playwright"
|
||||
@echo ""
|
||||
@echo "Présentations (slugs : a-z 0-9 -) :"
|
||||
@echo " make create-presentation SLUG=mon-talk"
|
||||
@echo " make delete-presentation SLUG=mon-talk"
|
||||
@echo " make clone-presentation FROM=src TO=dst"
|
||||
@echo " make rename-presentation FROM=old TO=new"
|
||||
@echo ""
|
||||
@echo " make install npm install"
|
||||
@echo " make dev-portal Portail seul (sync + VitePress dev, base=/ ; pas de build decks)"
|
||||
@echo " make dev-slides SLUG=… slidev interactif sur un deck"
|
||||
@echo ""
|
||||
@echo "Sous-chemin (Pages) : make sync build SITE_BASE=/mon-repo/"
|
||||
|
||||
install:
|
||||
$(NPM) install
|
||||
|
||||
sync:
|
||||
SITE_BASE=$(SITE_BASE) $(NODE) tools/sync-portal.mjs
|
||||
|
||||
run:
|
||||
$(NPM) run start
|
||||
|
||||
build: sync
|
||||
SITE_BASE=$(SITE_BASE) $(NPM) run build
|
||||
|
||||
clean:
|
||||
rm -rf dist-portal .slide-wip portal/.vitepress/cache artifacts
|
||||
@sh -c 'for d in dist-*; do [ -d "$$d" ] && rm -rf "$$d"; done' || true
|
||||
|
||||
create-presentation:
|
||||
@test -n "$(SLUG)" || (echo "usage: make create-presentation SLUG=mon-talk"; exit 1)
|
||||
$(NODE) tools/presentation-cli.mjs create $(SLUG)
|
||||
|
||||
delete-presentation:
|
||||
@test -n "$(SLUG)" || (echo "usage: make delete-presentation SLUG=mon-talk"; exit 1)
|
||||
$(NODE) tools/presentation-cli.mjs delete $(SLUG)
|
||||
|
||||
clone-presentation:
|
||||
@test -n "$(FROM)" && test -n "$(TO)" || (echo "usage: make clone-presentation FROM=a TO=b"; exit 1)
|
||||
$(NODE) tools/presentation-cli.mjs clone $(FROM) $(TO)
|
||||
|
||||
rename-presentation:
|
||||
@test -n "$(FROM)" && test -n "$(TO)" || (echo "usage: make rename-presentation FROM=a TO=b"; exit 1)
|
||||
$(NODE) tools/presentation-cli.mjs rename $(FROM) $(TO)
|
||||
|
||||
dev-portal:
|
||||
$(NPM) run dev:portal
|
||||
|
||||
SLUG ?= kubernetes-operators
|
||||
dev-slides:
|
||||
slidev slides/$(SLUG)/slides.md --open
|
||||
|
||||
test:
|
||||
$(NPM) run test
|
||||
@@ -0,0 +1,132 @@
|
||||
# Talks techniques
|
||||
|
||||
Portail statique (**VitePress**) + decks (**Slidev**). Le portail sort dans **`dist/portal/`** ; les decks Slidev sont buildés dans **`artifacts/slides/`** (hors `dist/`, pour ne pas être effacés par Vite), puis **`assemble-dist`** les copie dans **`dist/portal/slides/`** pour le déploiement (`npx serve dist/portal`).
|
||||
|
||||
---
|
||||
|
||||
## Démarrage rapide
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Ouvre le portail en local : les decks buildés sont servis sous `/slides/` par le **plugin Vite** (`portal/.vitepress/vite-plugin-slides-fs.mjs`) depuis `dist/slides/` — pas de second serveur HTTP. Pour le portail seul (sans decks) : `npm run dev:portal`.
|
||||
|
||||
Pour prévisualiser le **résultat de production** après un build :
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm run preview
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Développement
|
||||
|
||||
| Commande | Description |
|
||||
|----------|-------------|
|
||||
| `npm run dev` | Sync + build slides → `dist/slides/` + VitePress (plugin sert `/slides/` depuis ce dossier) |
|
||||
| `npm run dev:portal` | Sync + VitePress seul — pas de decks sous `/slides/` en local |
|
||||
| `npm run dev:slides:kubernetes-operators` | Deck Slidev avec rechargement à chaud |
|
||||
| `npm run dev:slides:spring-ai` | Idem pour l’autre exemple |
|
||||
| `npm run sync` | Régénère uniquement le portail (liste, fiches, « À la une ») à partir de `slides/*/meta.json` |
|
||||
|
||||
En **`npm run dev`**, un seul processus VitePress ; les builds Slidev vont sur le disque (`dist/slides/`) et le plugin les expose. Pour éditer un deck avec le mode Slidev interactif, utilisez `npm run dev:slides:…`.
|
||||
|
||||
### Thème et QR codes
|
||||
|
||||
L’apparence reprend la charte de [talk.specificat.io](https://talk.specificat.io/) (fond sombre, accent cyan, cartes). Les QR codes sont générés côté client avec la librairie [`qrcode`](https://www.npmjs.com/package/qrcode) (canvas).
|
||||
|
||||
- URL du dépôt Git affichée sur l’accueil : `talkRepoUrl` (et textes associés) dans `portal/.vitepress/config.mjs` → `themeConfig`.
|
||||
- Chaque fiche présentation inclut un QR vers le deck `/slides/<slug>/`.
|
||||
|
||||
### Tests (base solide avant corrections ciblées)
|
||||
|
||||
```bash
|
||||
npx playwright install chromium # une fois (navigateur pour les E2E)
|
||||
npm test # tout : Node + Playwright (make test)
|
||||
```
|
||||
|
||||
| Script | Contenu |
|
||||
|--------|---------|
|
||||
| `npm run test:node` | Tous les tests **Node** (`node:test`) : chemins `SITE_BASE`, **contrats** de génération (`scripts/presentation-contracts.mjs`), cohérence dépôt après sync, sync sur dépôt temporaire (`TALKS_ROOT`), layout `dist/` si présent |
|
||||
| `npm run test:e2e` | **Playwright** : build + `serve dist/portal` — parcours UI, **HTTP 200**, matrice par slug |
|
||||
| `npm run test:e2e:ui` | Mode UI Playwright |
|
||||
| `npm run test:unit` | Alias de `test:node` |
|
||||
|
||||
**Fichiers** : `tests/site-base.test.mjs`, `tests/presentation-contracts.test.mjs`, `tests/repo-consistency.test.mjs`, `tests/sync-tmp-repo.test.mjs`, `tests/dist-layout.test.mjs`, `e2e/flows.spec.js`, `e2e/http-routes.spec.js`, `e2e/presentations-matrix.spec.js`.
|
||||
|
||||
La logique du portail généré est dans **`scripts/presentation-contracts.mjs`** (une seule source pour `sync-portal.mjs` et les tests).
|
||||
|
||||
**CI** : `.github/workflows/ci.yml` (Node + E2E), `.github/workflows/build.yml` (build + test `dist/`).
|
||||
|
||||
**Aller plus vite en local** : `npx serve dist/portal -p 4174 -L` puis `PW_SKIP_WEBSERVER=1 npm run test:e2e`.
|
||||
|
||||
---
|
||||
|
||||
## Build production
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Enchaîne : sync → **VitePress** → `dist/portal/` → **Slidev** → `artifacts/slides/<slug>/` (hors `dist/`, donc pas effacé par le build VitePress) → **`assemble-dist`** copie vers `dist/portal/slides/`.
|
||||
|
||||
| Commande | Quand l’utiliser |
|
||||
|----------|------------------|
|
||||
| `npm run build` | Complet : sync (`prebuild`) + portail + decks + assemble |
|
||||
| `npm run build:portal` | Idem `build` (via Makefile, sync explicite) |
|
||||
| `npm run build:vitepress-only` | **Uniquement** VitePress → `dist/portal/` |
|
||||
| `npm run build:slides` | Decks seuls → `dist/slides/` |
|
||||
|
||||
Après build : **`dist/portal/`** contient le site prêt à servir. La sortie brute Slidev est dans **`artifacts/slides/`** (gitignoré) jusqu’à fusion ; surcharger avec **`SLIDES_BUILD_OUT`** si besoin.
|
||||
|
||||
Servir **`dist/portal`** (ex. `npx serve dist/portal`).
|
||||
|
||||
---
|
||||
|
||||
## Déploiement sous un sous-chemin
|
||||
|
||||
Exemple : site hébergé en `https://pages.example.org/mon-repo/`.
|
||||
|
||||
```bash
|
||||
export SITE_BASE=/mon-repo/
|
||||
npm run sync
|
||||
npm run build
|
||||
```
|
||||
|
||||
Même variable pour VitePress, les liens générés et les assets Slidev. À la racine du domaine, ne rien exporter (comportement par défaut).
|
||||
|
||||
---
|
||||
|
||||
## Ajouter une présentation
|
||||
|
||||
1. Créer `slides/<slug>/meta.json` et `slides/<slug>/slides.md`.
|
||||
2. Optionnel : ajouter un script `dev:slides:<slug>` dans `package.json`.
|
||||
3. `npm run sync` puis `npm run build` (le `prebuild` lance déjà le sync).
|
||||
|
||||
Champs utiles dans `meta.json` : `title`, `description`, `tags`, `order`, `featured` (sinon la home prend les premières entrées par `order`).
|
||||
|
||||
---
|
||||
|
||||
## Structure du dépôt
|
||||
|
||||
```text
|
||||
scripts/ # sync-portal, build-slides, assemble-dist, préfixe SITE_BASE
|
||||
portal/ # sources VitePress + vite-plugin-slides-fs (dev)
|
||||
slides/<slug>/ # slides.md + meta.json par deck
|
||||
dist/portal/ # portail buildé (+ slides copiés après assemble)
|
||||
artifacts/slides/ # sortie Slidev avant fusion (hors dist/, non versionné)
|
||||
```
|
||||
|
||||
Les fichiers sous `portal/presentations/` et `portal/.vitepress/data/presentations.json` sont **régénérés** par `sync`. La zone « À la une » dans `portal/index.md` est entre `<!-- FEATURED_START -->` et `<!-- FEATURED_END -->`.
|
||||
|
||||
---
|
||||
|
||||
## Notes techniques
|
||||
|
||||
- **VitePress** : ne pas définir `outDir` dans `vite.build` — utiliser `outDir` au **premier niveau** de la config (sinon le build SSR casse).
|
||||
- **Slidev** : le paquet `@slidev/theme-default` est requis pour un build non interactif.
|
||||
|
||||
Stack : Node.js **18+** (LTS 20 ou 22 recommandées), `npm`.
|
||||
@@ -1,165 +0,0 @@
|
||||
/* Aligné sur content/kubernetes-hell-to-heaven/css/custom.css (specificat.io) */
|
||||
:root {
|
||||
--bg-dark: #050709;
|
||||
--bg-medium: #0d1115;
|
||||
--bg-light: #1b1f22;
|
||||
--bg-gradient-start: #10151a;
|
||||
--text-primary: #f5f5f5;
|
||||
--text-muted: rgba(245, 245, 245, 0.9);
|
||||
--text-subtle: rgba(245, 245, 245, 0.75);
|
||||
--accent-cyan: #2bc9c6;
|
||||
--accent-cyan-light: #e9fefd;
|
||||
--accent-cyan-bg: rgba(43, 201, 198, 0.08);
|
||||
--accent-cyan-border: rgba(43, 201, 198, 0.55);
|
||||
--accent-copper: #d19c5a;
|
||||
--border-subtle: rgba(255, 255, 255, 0.12);
|
||||
--border-very-subtle: rgba(255, 255, 255, 0.07);
|
||||
--panel-glow: 0 0 0 1px rgba(43, 201, 198, 0.12);
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100%;
|
||||
margin: 0;
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Text", sans-serif;
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-dark);
|
||||
background-image: radial-gradient(
|
||||
circle at 10% 0%,
|
||||
var(--bg-gradient-start) 0%,
|
||||
var(--bg-dark) 52%,
|
||||
var(--bg-light) 100%
|
||||
),
|
||||
radial-gradient(
|
||||
ellipse 100% 80% at 50% -20%,
|
||||
rgba(43, 201, 198, 0.12),
|
||||
transparent 55%
|
||||
);
|
||||
}
|
||||
|
||||
body::after {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(
|
||||
ellipse 95% 75% at 50% 50%,
|
||||
transparent 35%,
|
||||
rgba(5, 7, 9, 0.35) 100%
|
||||
);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.page {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: clamp(1.5rem, 4vw, 2.75rem) clamp(1.25rem, 4vw, 2rem) 3rem;
|
||||
}
|
||||
|
||||
.masthead {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.masthead h1 {
|
||||
margin: 0 0 0.35rem;
|
||||
font-size: clamp(1.75rem, 4vw, 2.25rem);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.masthead p {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
color: var(--text-subtle);
|
||||
max-width: 42em;
|
||||
}
|
||||
|
||||
.talks-grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@media (min-width: 560px) {
|
||||
.talks-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.talk-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1.15rem 1.25rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
background: rgba(13, 17, 21, 0.55);
|
||||
box-shadow: var(--panel-glow), inset 0 1px 0 0 rgba(255, 255, 255, 0.03);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.12s ease;
|
||||
}
|
||||
|
||||
.talk-card:hover,
|
||||
.talk-card:focus-visible {
|
||||
border-color: var(--accent-cyan-border);
|
||||
box-shadow: var(--panel-glow), 0 0 0 1px rgba(43, 201, 198, 0.18),
|
||||
inset 0 1px 0 0 rgba(255, 255, 255, 0.05);
|
||||
transform: translateY(-1px);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.talk-card:focus-visible {
|
||||
outline: 2px solid var(--accent-cyan);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.talk-card h2 {
|
||||
margin: 0 0 0.4rem;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.talk-card p {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.45;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.talk-card .meta {
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-subtle);
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
margin-top: 2.5rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border-very-subtle);
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-subtle);
|
||||
}
|
||||
|
||||
.site-footer a {
|
||||
color: var(--accent-cyan);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.site-footer a:hover,
|
||||
.site-footer a:focus-visible {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Talks — Specificat</title>
|
||||
<link rel="stylesheet" href="css/home.css" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="page">
|
||||
<header class="masthead">
|
||||
<h1>Talks</h1>
|
||||
<p>Présentations (Reveal.js) — même charte visuelle que le site Specificat.</p>
|
||||
</header>
|
||||
|
||||
<main class="talks-grid" aria-label="Liste des présentations">
|
||||
<a class="talk-card" href="kubernetes-hell-to-heaven/">
|
||||
<h2>Kubernetes Operators</h2>
|
||||
<p>De l’enfer au paradis — Kubernetes, opérateurs et boucle de réconciliation.</p>
|
||||
<span class="meta">Dossier · kubernetes-hell-to-heaven</span>
|
||||
</a>
|
||||
</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
Contenu servi depuis le dépôt <em>Talks</em> (dossier <code>content/</code>).
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,286 +0,0 @@
|
||||
/* Thème aligné sur https://specificat.io/ (variables officielles du site) */
|
||||
:root {
|
||||
--bg-dark: #050709;
|
||||
--bg-medium: #0d1115;
|
||||
--bg-light: #1b1f22;
|
||||
--bg-gradient-start: #10151a;
|
||||
--text-primary: #f5f5f5;
|
||||
--text-muted: rgba(245, 245, 245, 0.9);
|
||||
--text-subtle: rgba(245, 245, 245, 0.75);
|
||||
--accent-cyan: #2bc9c6;
|
||||
--accent-cyan-light: #e9fefd;
|
||||
--accent-cyan-bg: rgba(43, 201, 198, 0.08);
|
||||
--accent-cyan-border: rgba(43, 201, 198, 0.55);
|
||||
--accent-copper: #d19c5a;
|
||||
--border-subtle: rgba(255, 255, 255, 0.12);
|
||||
--border-very-subtle: rgba(255, 255, 255, 0.07);
|
||||
--panel-glow: 0 0 0 1px rgba(43, 201, 198, 0.12);
|
||||
}
|
||||
|
||||
/* Fond slide = même logique que le body du site (dégradé radial + profondeur) */
|
||||
.reveal .slide-background-content {
|
||||
background-color: var(--bg-dark);
|
||||
background-image: radial-gradient(
|
||||
circle at 10% 0%,
|
||||
var(--bg-gradient-start) 0%,
|
||||
var(--bg-dark) 52%,
|
||||
var(--bg-light) 100%
|
||||
),
|
||||
radial-gradient(
|
||||
ellipse 100% 80% at 50% -20%,
|
||||
rgba(43, 201, 198, 0.12),
|
||||
transparent 55%
|
||||
);
|
||||
}
|
||||
|
||||
.reveal .slide-background-content::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(
|
||||
ellipse 95% 75% at 50% 50%,
|
||||
transparent 35%,
|
||||
rgba(5, 7, 9, 0.35) 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* Canvas Reveal : typo et couleur de texte comme le site */
|
||||
.reveal {
|
||||
font-size: 32px;
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Text", sans-serif;
|
||||
color: var(--text-primary);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Réserve le bas du cadre pour le footer (évite de marcher sur le dernier texte / la progress bar) */
|
||||
.reveal .slides {
|
||||
box-sizing: border-box;
|
||||
padding-bottom: 2.25rem !important;
|
||||
}
|
||||
|
||||
/* Pied de page deck — sous la barre de progression Reveal (souvent z-index 10) */
|
||||
.deck-footer {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 8;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35em 0.5em;
|
||||
padding: 0.35rem 2.5rem 0.55rem;
|
||||
font-size: clamp(10px, 1.5vw, 12px);
|
||||
line-height: 1.3;
|
||||
color: var(--text-subtle);
|
||||
pointer-events: none;
|
||||
border-top: 1px solid var(--border-very-subtle);
|
||||
background: linear-gradient(to top, rgba(5, 7, 9, 0.65), transparent);
|
||||
}
|
||||
|
||||
.deck-footer a {
|
||||
pointer-events: auto;
|
||||
color: var(--accent-cyan);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.deck-footer a:hover,
|
||||
.deck-footer a:focus-visible {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.deck-footer-sep {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.deck-footer-rights {
|
||||
opacity: 0.85;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.reveal.overview .deck-footer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.reveal .slides section {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.reveal h1 {
|
||||
font-size: 1.6em;
|
||||
text-transform: none;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.reveal h2 {
|
||||
font-size: 1.15em;
|
||||
margin-top: 0.6em;
|
||||
color: var(--accent-cyan);
|
||||
text-transform: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.reveal h3 {
|
||||
font-size: 0.95em;
|
||||
color: var(--text-subtle);
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.reveal a {
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.reveal ul,
|
||||
.reveal ol {
|
||||
display: block;
|
||||
text-align: left;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.reveal table {
|
||||
margin: 0.5em auto;
|
||||
font-size: 0.75em;
|
||||
border-color: var(--border-subtle);
|
||||
}
|
||||
|
||||
.reveal table th,
|
||||
.reveal table td {
|
||||
padding: 0.35em 0.6em;
|
||||
}
|
||||
|
||||
.reveal pre {
|
||||
width: 100%;
|
||||
box-shadow: var(--panel-glow), 0 1px 0 0 rgba(255, 255, 255, 0.03) inset;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 12px;
|
||||
font-size: 0.45em;
|
||||
max-height: 480px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.reveal pre code {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
/* Citations : bordure cyan + fond comme les panneaux du site */
|
||||
.reveal blockquote {
|
||||
width: 90%;
|
||||
margin: 0.5em auto;
|
||||
font-family: Georgia, "Times New Roman", serif;
|
||||
font-style: italic;
|
||||
color: var(--text-muted);
|
||||
background: var(--accent-cyan-bg);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-left: 3px solid var(--accent-cyan-border);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--panel-glow);
|
||||
padding: 0.45em 0.85em;
|
||||
}
|
||||
|
||||
.reveal aside.notes {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Bloc diagramme : même esprit « panel » que specificat.io (bordure discrète, fond légèrement relevé) */
|
||||
.reveal .mermaid {
|
||||
margin: 0.5em auto;
|
||||
padding: 0.55em 0.85em;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
background: rgba(13, 17, 21, 0.55);
|
||||
box-shadow: var(--panel-glow), inset 0 1px 0 0 rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
/* Évite les libellés écrasés quand le conteneur est encore étroit au 1er rendu */
|
||||
.reveal .mermaid {
|
||||
min-width: min(100%, 240px);
|
||||
}
|
||||
|
||||
/*
|
||||
* Mermaid met souvent width="100%" sur le SVG → il prend toute la largeur du slide, puis
|
||||
* height: auto recalcule une hauteur énorme (viewBox étroit mais très haut). D’où des SVG à ~1000px de haut.
|
||||
* On borne par la hauteur et on laisse width: auto recalculer la largeur au ratio du viewBox.
|
||||
*/
|
||||
.reveal .mermaid svg {
|
||||
width: auto !important;
|
||||
max-width: 100% !important;
|
||||
max-height: min(52vh, 520px) !important;
|
||||
height: auto !important;
|
||||
display: block;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
/* Libellés Mermaid (SVG) : couleur explicite — le thème base ne peint pas toujours les <text> */
|
||||
.reveal .mermaid svg text {
|
||||
fill: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
/* Barre de progression et numéros de slide : accent cyan */
|
||||
.reveal .progress span {
|
||||
background: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.reveal .slide-number {
|
||||
color: var(--text-subtle);
|
||||
font-size: 0.65em;
|
||||
}
|
||||
|
||||
/*
|
||||
* Slide « boucle » (n° 6) : graphe cyclique Mermaid = très haut ; graphe linéaire + libellés courts.
|
||||
* Pas de scale() : ça ne réduit pas la hauteur de layout (Reveal mesure scrollHeight).
|
||||
*/
|
||||
.reveal .slides section.slide-boucle {
|
||||
padding-top: 0.5em !important;
|
||||
padding-bottom: 0.5em !important;
|
||||
}
|
||||
|
||||
.reveal section.slide-boucle .slide-boucle-inner {
|
||||
width: 100%;
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.reveal section.slide-boucle .slide-boucle-title {
|
||||
font-size: clamp(1rem, 4.2vw, 1.35rem);
|
||||
margin: 0 0 0.35em;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
/* Mermaid sur la slide « boucle » : hauteur mini correcte (éviter l’écrasement à ~78px qui tuait les libellés) */
|
||||
.reveal section.slide-boucle .mermaid {
|
||||
margin: 0.25em auto 0.15em;
|
||||
line-height: normal;
|
||||
padding: 0.45em 0.65em;
|
||||
min-width: min(100%, 320px);
|
||||
}
|
||||
|
||||
.reveal section.slide-boucle .mermaid svg {
|
||||
max-height: min(34vh, 220px) !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
.reveal section.slide-boucle .slide-boucle-foot {
|
||||
margin: 0.4em 0 0;
|
||||
font-size: clamp(0.7rem, 2.8vw, 0.88rem);
|
||||
line-height: 1.35;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.reveal section.slide-boucle .slide-boucle-foot strong {
|
||||
color: var(--accent-cyan);
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -1,804 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Kubernetes Operators — De l’enfer au paradis</title>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/reveal.js/4.6.1/reveal.min.css" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/reveal.js/4.6.1/theme/moon.min.css" />
|
||||
<link rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/reveal.js/4.6.1/plugin/highlight/monokai.min.css" />
|
||||
<link rel="stylesheet" href="css/custom.css" />
|
||||
<!--
|
||||
Reveal.js 4.6.1 — plugins : Notes orateur + surlignage code.
|
||||
S : fenêtre notes (même origine, servir en HTTP, pas file://).
|
||||
? : aide raccourcis · Échap : aperçu · F : plein écran
|
||||
-->
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="reveal">
|
||||
<div class="slides">
|
||||
<section>
|
||||
<h1>Kubernetes Operators — De l’enfer… au paradis</h1>
|
||||
<blockquote>
|
||||
<p>Kubernetes n’est <strong>pas</strong> compliqué.<br />
|
||||
Mais <strong>mal compris</strong>… c’est un enfer.</p>
|
||||
</blockquote>
|
||||
<aside class="notes">
|
||||
<p>On va parler de Kubernetes… mais surtout de pourquoi les opérateurs changent complètement la manière de
|
||||
travailler — et pourquoi c’est <strong>puissant</strong> mais <strong>dangereux</strong> si on se plante sur
|
||||
le mental model.</p>
|
||||
<p>Illustration suggérée : diagramme moderne et minimal, flat design, lisible (éviter le poster CNCF
|
||||
illisible).</p>
|
||||
</aside>
|
||||
</section>
|
||||
<section>
|
||||
<h1>Kubernetes : délire d’Ops ?</h1>
|
||||
<blockquote>
|
||||
<p>On vous l’a vendu comme un truc d’infra</p>
|
||||
</blockquote>
|
||||
<ul>
|
||||
<li>Complexe</li>
|
||||
<li>Réservé aux DevOps</li>
|
||||
<li>Magie noire</li>
|
||||
</ul>
|
||||
<aside class="notes">
|
||||
<p>On vous a vendu Kubernetes comme un délire d’infra, réservé à des gens chelous.<br />
|
||||
En réalité… c’est beaucoup plus proche de ce que vous faites déjà.</p>
|
||||
<p><em>Chaos scripts</em> — dev entouré de scripts, crons, alertes, terminal chargé, ambiance tendue
|
||||
(contraste avec la suite).</p>
|
||||
</aside>
|
||||
</section>
|
||||
<section>
|
||||
<h1>Kubernetes = API + état + boucle</h1>
|
||||
<ul>
|
||||
<li>API REST</li>
|
||||
<li>Base d’état</li>
|
||||
<li>Boucle de réconciliation</li>
|
||||
<li>Pas de mystère : des mécanismes explicites</li>
|
||||
</ul>
|
||||
<aside class="notes">
|
||||
<p>Kubernetes, c’est juste une API, une base d’état, et une boucle qui corrige la réalité.<br />
|
||||
Pas de magie — des patterns qu’on retrouve ailleurs en dev.</p>
|
||||
</aside>
|
||||
</section>
|
||||
<section>
|
||||
<h1>De vos outils au noyau Linux</h1>
|
||||
<pre><code class="language-mermaid">flowchart LR
|
||||
A[Clients] --> B[kube-api]
|
||||
B --> C[etcd]
|
||||
B --> D[Controllers]
|
||||
D --> E[kubelet]
|
||||
E --> F[containerd]
|
||||
F --> G[Kernel Linux]
|
||||
</code></pre>
|
||||
<ul>
|
||||
<li><strong>kube-api</strong> — point d’entrée</li>
|
||||
<li><strong>etcd</strong> — source de vérité</li>
|
||||
<li><strong>kubelet</strong> — agent sur chaque nœud</li>
|
||||
<li><strong>container runtime</strong> (ex. containerd) — exécution des conteneurs</li>
|
||||
</ul>
|
||||
<aside class="notes">
|
||||
<p>Tout ça, ce sont des clients : Helm, kubectl, Flux… ils appellent l’API.<br />
|
||||
L’API parle à etcd, les controllers poussent le travail vers les kubelets, et le runtime lance les
|
||||
conteneurs.</p>
|
||||
<p>Schéma ASCII de secours :</p>
|
||||
<pre><code>Clients → kube-api → etcd
|
||||
↓
|
||||
controllers → kubelet → containerd → kernel
|
||||
</code></pre>
|
||||
</aside>
|
||||
</section>
|
||||
<section>
|
||||
<h1>Kubernetes ne fait pas tourner vos apps</h1>
|
||||
<ul>
|
||||
<li>Il <strong>orchestre</strong></li>
|
||||
<li>Il <strong>délègue</strong></li>
|
||||
<li>Il <strong>observe</strong></li>
|
||||
</ul>
|
||||
<aside class="notes">
|
||||
<p>Ce n’est pas Kubernetes qui « exécute votre métier » comme un process magique : il coordonne, délègue aux
|
||||
agents, et observe l’état.<br />
|
||||
La suite : le cœur, c’est la boucle de réconciliation.</p>
|
||||
<p>(Cette slide remplace l’ancien zoom « kube-api → etcd » seul : le détail etcd est déjà dans la vue
|
||||
d’ensemble slide 4.)</p>
|
||||
</aside>
|
||||
</section>
|
||||
<section class="slide-boucle">
|
||||
<div class="slide-boucle-inner">
|
||||
<h1 class="slide-boucle-title">État voulu → réel : la boucle</h1>
|
||||
<pre><code class="language-mermaid">flowchart LR
|
||||
A[Désiré] --> B[API] --> C[Controllers] --> D[Réel]
|
||||
</code></pre>
|
||||
<p class="slide-boucle-foot">
|
||||
<strong>↻</strong> réinjecte vers les contrôleurs · <em>chaque tour : observer, décider, agir</em>
|
||||
</p>
|
||||
</div>
|
||||
<aside class="notes">
|
||||
<p>Des agents exécutent les instructions côté nœud (kubelet, runtime), mais le fil conducteur, c’est : état
|
||||
voulu, comparaison, correction en boucle.</p>
|
||||
</aside>
|
||||
</section>
|
||||
<section>
|
||||
<h1>Sous containerd : cgroups et namespaces</h1>
|
||||
<pre><code class="language-mermaid">flowchart TD
|
||||
F[containerd] --> K[Linux Kernel]
|
||||
</code></pre>
|
||||
<ul>
|
||||
<li><strong>cgroups</strong></li>
|
||||
<li><strong>namespaces</strong></li>
|
||||
</ul>
|
||||
<aside class="notes">
|
||||
<p>Et derrière containerd… le noyau Linux. Cgroups, namespaces…</p>
|
||||
<p><em>(pause)</em></p>
|
||||
<p>Et ça… vous n’avez pas envie d’y manipuler les fichiers à la main au quotidien.</p>
|
||||
<p><em>Kernel danger zone</em> — mécanique dense, style industriel sombre (le « trou du souffleur » sous
|
||||
Kubernetes).</p>
|
||||
</aside>
|
||||
</section>
|
||||
<section>
|
||||
<h1>Kubernetes vous évite de vivre dans le noyau</h1>
|
||||
<ul>
|
||||
<li>Manipuler les <strong>cgroups</strong> à la main</li>
|
||||
<li>Gérer <strong>isolation</strong>, <strong>CPU</strong>, <strong>mémoire</strong> au niveau noyau</li>
|
||||
</ul>
|
||||
<blockquote>
|
||||
<p>À côté de ça, un cluster qui râle, c’est presque du repos.</p>
|
||||
</blockquote>
|
||||
<aside class="notes">
|
||||
<p>Si Kubernetes existe, c’est pour vous éviter de vivre dans ces couches-là.<br />
|
||||
À côté du noyau, Kubernetes est presque rassurant.</p>
|
||||
</aside>
|
||||
</section>
|
||||
<section>
|
||||
<h1>CNI, CSI, CRI : de l’infra branchable</h1>
|
||||
<ul>
|
||||
<li><strong>CNI</strong> → réseau (ex : Istio, Cilium ,Calico, Flannel, etc.)</li>
|
||||
<li><strong>CSI</strong> → stockage (ex : Ceph, Longhorn , Amazon,etc.)</li>
|
||||
<li><strong>CRI</strong> → runtime (ex : containerd, CRI-O,etc.)</li>
|
||||
<li>Ici : brancher l’infra — pas encoder la logique métier (<em>≠</em> opérateur)</li>
|
||||
</ul>
|
||||
<aside class="notes">
|
||||
<p>On peut brancher réseau, stockage, runtime… mais ce n’est pas notre sujet du jour — et surtout, ne les
|
||||
confondez pas avec la logique métier.</p>
|
||||
</aside>
|
||||
</section>
|
||||
<section>
|
||||
<h1>Brancher l’infra ou coder le métier ?</h1>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Rôle</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>CNI / CSI / CRI</strong></td>
|
||||
<td>Infrastructure</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Opérateur</strong></td>
|
||||
<td>Logique métier</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<aside class="notes">
|
||||
<p>D’un côté : des briques d’infra branchables.<br />
|
||||
De l’autre : du <strong>code</strong> qui encode <strong>comment</strong> votre système doit se comporter
|
||||
dans le cluster.</p>
|
||||
<p>Icônes utiles :</p>
|
||||
<ul>
|
||||
<li><a href="https://github.com/kubernetes/community/tree/master/icons">Icônes Kubernetes (officiel)</a>
|
||||
</li>
|
||||
<li><a href="https://landscape.cncf.io/">CNCF Landscape</a></li>
|
||||
<li><a href="https://simpleicons.org/">Simple Icons (SVG)</a></li>
|
||||
</ul>
|
||||
</aside>
|
||||
</section>
|
||||
<section>
|
||||
<h1>Qui écrit la logique du cluster ?</h1>
|
||||
<aside class="notes">
|
||||
<p>Qui décide de ce qu’il faut faire quand l’état réel diverge de ce qu’on veut ?</p>
|
||||
<p><em>(pause)</em></p>
|
||||
</aside>
|
||||
</section>
|
||||
<section>
|
||||
<h1>La logique vit dans les controllers</h1>
|
||||
<p><em>Ils ferment la boucle : observation, décision, action.</em></p>
|
||||
<aside class="notes">
|
||||
<p>La vraie intelligence de Kubernetes… elle est dans les controllers.<br />
|
||||
Ce sont eux qui ferment la boucle : observation, décision, action.</p>
|
||||
</aside>
|
||||
</section>
|
||||
<section>
|
||||
<h1>Les opérateurs : des controllers spécialisés</h1>
|
||||
<aside class="notes">
|
||||
<p>Les opérateurs, ce sont des controllers… mais focalisés sur un domaine métier ou un logiciel précis (base,
|
||||
queue, etc.).<br />
|
||||
Même mécanisme, autre niveau d’abstraction.</p>
|
||||
</aside>
|
||||
</section>
|
||||
<section>
|
||||
<h1>Automatiser le métier… en code dans le cluster</h1>
|
||||
<aside class="notes">
|
||||
<p>Un opérateur, c’est du code qui dit au cluster comment faire vivre un service au-delà du simple « Pod +
|
||||
Service » : upgrades, backup, réplication, etc.</p>
|
||||
</aside>
|
||||
</section>
|
||||
<section>
|
||||
<h1>Sans opérateur : scripts, crons, humains dans la boucle</h1>
|
||||
<ul>
|
||||
<li>Scripts bash</li>
|
||||
<li>Cron jobs</li>
|
||||
<li>Interventions humaines</li>
|
||||
<li>État incohérent</li>
|
||||
</ul>
|
||||
<aside class="notes">
|
||||
<p>Avant : bricolage, des humains dans la boucle, et un état qui part dans tous les sens dès qu’il y a une
|
||||
alerte à 3 h du matin.</p>
|
||||
</aside>
|
||||
</section>
|
||||
<section>
|
||||
<h1>Automatisation éclatée : la logique reste dans les têtes</h1>
|
||||
<ul>
|
||||
<li>Runbooks Confluence</li>
|
||||
<li>Scripts bash « magiques »</li>
|
||||
<li>Cron jobs oubliés</li>
|
||||
<li>Interventions humaines</li>
|
||||
</ul>
|
||||
<aside class="notes">
|
||||
<p>Avant les opérateurs, l’automatisation existe… mais elle est <strong>fragmentée</strong>.<br />
|
||||
Chaque problème a sa solution bricolée.</p>
|
||||
<p>Et surtout :<br />
|
||||
<strong>la logique est dans la tête des gens, pas dans le système.</strong>
|
||||
</p>
|
||||
</aside>
|
||||
</section>
|
||||
<section>
|
||||
<h1>Drift, pas de reproductibilité, 3 h du matin</h1>
|
||||
<ul>
|
||||
<li>Drift de configuration</li>
|
||||
<li>Actions non reproductibles</li>
|
||||
<li>Dépendance aux humains</li>
|
||||
<li>Incidents à 3 h du matin</li>
|
||||
</ul>
|
||||
<aside class="notes">
|
||||
<p>Le problème, ce n’est pas juste « moins automatisé ».</p>
|
||||
<p>C’est :</p>
|
||||
<ul>
|
||||
<li>pas <strong>reproductible</strong></li>
|
||||
<li>pas <strong>observable</strong> au sens cluster</li>
|
||||
<li>pas <strong>fiable</strong> dans le temps</li>
|
||||
</ul>
|
||||
<p>Surtout : impossible de <strong>raisonner globalement</strong> sur l’état du système.</p>
|
||||
</aside>
|
||||
</section>
|
||||
<section>
|
||||
<h1>Vous déclarez ; le système réconcilie</h1>
|
||||
<pre><code class="language-yaml">kind: Database
|
||||
spec:
|
||||
size: 3
|
||||
</code></pre>
|
||||
<aside class="notes">
|
||||
<p>Après : on décrit l’état voulu… et le contrôleur / l’opérateur fait le travail répétitif et les
|
||||
transitions.</p>
|
||||
<p><em>Paradis opérateur</em> — ingénieur calme, infra automatisée, UI claire, atmosphère sereine (contraste
|
||||
avec slide enfer).</p>
|
||||
</aside>
|
||||
</section>
|
||||
<section>
|
||||
<h1>Spec, opérateur, cluster : ça tourne en boucle</h1>
|
||||
<pre><code class="language-mermaid">flowchart LR
|
||||
A[Spec] --> B[Operator]
|
||||
B --> C[Cluster]
|
||||
C --> B
|
||||
</code></pre>
|
||||
<p><em>Dès que le réel diverge, la réconciliation repart.</em></p>
|
||||
<aside class="notes">
|
||||
<p>Le système réobserve, recompare, recorrige. C’est la même philosophie que le reste de Kubernetes, appliquée
|
||||
à votre domaine.</p>
|
||||
<p>Quelques <strong>exemples réels</strong> : d’abord des outils qui <strong>étendent</strong> des ressources
|
||||
natives avec des <strong>annotations</strong>, puis des opérateurs qui posent de <strong>vrais CRD</strong>
|
||||
— et un projet maison pour ancrer le discours.</p>
|
||||
</aside>
|
||||
</section>
|
||||
<section>
|
||||
<h1>Reflector & Reloader : la puissance des annotations</h1>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Outil</th>
|
||||
<th>Idée</th>
|
||||
<th>Déclencheur typique</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>Reflector</strong> (EmberStack)</td>
|
||||
<td>Copie / réplique des <strong>Secrets</strong> et <strong>ConfigMaps</strong> (autres namespaces,
|
||||
miroirs)</td>
|
||||
<td>Annotations sur la ressource source ou la copie</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Reloader</strong> (Stakater)</td>
|
||||
<td><strong>Redémarre</strong> les Pods quand un Secret / ConfigMap référencé change</td>
|
||||
<td>Annotation sur le <strong>Deployment</strong> / <strong>StatefulSet</strong> / etc.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<ul>
|
||||
<li>Reflector : annotations du type <code>reflector.v1.k8s.emberstack.com/*</code> pour autoriser la réflexion
|
||||
et cibler où copier.</li>
|
||||
<li>Reloader : <code>reloader.stakater.com/auto: "true"</code> (ou annotations plus fines par config) pour
|
||||
lier redéploiement ↔ mise à jour de config.</li>
|
||||
</ul>
|
||||
<aside class="notes">
|
||||
<p>Ce n’est pas toujours un « gros » opérateur avec CRD : parfois c’est un <strong>contrôleur</strong> qui
|
||||
<strong>regarde</strong> des objets standards et réagit à des <strong>annotations</strong>.<br />
|
||||
Très utile pour TLS partagé, configs dupliquées, ou « je change un secret → je veux un rollout » sans
|
||||
pipeline ad hoc.
|
||||
</p>
|
||||
<ul>
|
||||
<li><a href="https://github.com/emberstack/kubernetes-reflector">kubernetes-reflector</a></li>
|
||||
<li><a href="https://github.com/stakater/Reloader">Reloader</a></li>
|
||||
</ul>
|
||||
</aside>
|
||||
</section>
|
||||
<section>
|
||||
<h1>Cert-manager, Postgres, Keycloak : la spec devient une entité</h1>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Opérateur</th>
|
||||
<th>CRD (exemples)</th>
|
||||
<th>Ce que ça <strong>matérialise</strong></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>cert-manager</strong></td>
|
||||
<td><code>Certificate</code>, <code>Issuer</code>, <code>ClusterIssuer</code></td>
|
||||
<td>Secrets TLS, chaînes ACME / PKI</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Postgres Operator</strong> (Zalando)</td>
|
||||
<td><code>postgresql</code> (<code>acid.zalan.do</code>)</td>
|
||||
<td>Cluster PostgreSQL (Patroni, volumes, users…)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Keycloak Operator</strong></td>
|
||||
<td><code>Keycloak</code>, <code>KeycloakRealm</code>, <code>KeycloakClient</code>, …</td>
|
||||
<td>Instances Keycloak, royaumes, clients</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p>La spec décrit l’état métier (cert, base, realm) ; le contrôleur crée / met à jour Deployments, Services,
|
||||
Secrets, etc.</p>
|
||||
<aside class="notes">
|
||||
<p>On est dans le pattern « Database, size: 3 » mais en prod : certificats, bases gérées, IdP.<br />
|
||||
Trois domaines, <strong>même mécanisme</strong> : CRD + boucle de réconciliation.</p>
|
||||
<p>Liens :</p>
|
||||
<ul>
|
||||
<li><a href="https://cert-manager.io/">cert-manager</a></li>
|
||||
<li><a href="https://github.com/zalando/postgres-operator">Zalando Postgres operator</a></li>
|
||||
<li><a href="https://www.keycloak.org/operator/installation">Keycloak Operator</a></li>
|
||||
</ul>
|
||||
</aside>
|
||||
</section>
|
||||
<section>
|
||||
<h1>Un certificat déclaratif, tout le cycle TLS en dessous</h1>
|
||||
<pre><code class="language-yaml">apiVersion: cert-manager.io/v1
|
||||
kind: Certificate
|
||||
metadata:
|
||||
name: api-example
|
||||
spec:
|
||||
dnsNames:
|
||||
- api.example.com
|
||||
</code></pre>
|
||||
<p>La suite est dérivée automatiquement :</p>
|
||||
<ul>
|
||||
<li><code>CertificateRequest</code></li>
|
||||
<li>Challenge ACME</li>
|
||||
<li>Secret TLS</li>
|
||||
</ul>
|
||||
<aside class="notes">
|
||||
<p>Tu déclares <strong>juste</strong> un certificat.</p>
|
||||
<p>Et derrière : génération de clé, challenge ACME, renouvellement automatique.</p>
|
||||
<p><strong>Aucun humain dans la boucle</strong> pour le cycle de vie du cert.</p>
|
||||
</aside>
|
||||
</section>
|
||||
<section>
|
||||
<h1>Certificats : du Certbot au renouvellement automatique</h1>
|
||||
<p><em>Avant — sans opérateur</em></p>
|
||||
<ul>
|
||||
<li>Certbot manuel</li>
|
||||
<li>Scripts cron</li>
|
||||
<li>Cert expiré = prod down</li>
|
||||
</ul>
|
||||
<p><em>Avec cert-manager</em></p>
|
||||
<ul>
|
||||
<li>Déclaratif</li>
|
||||
<li>Auto-renouvellement</li>
|
||||
<li>Observabilité native (<code>Certificate</code>, conditions, events)</li>
|
||||
</ul>
|
||||
<aside class="notes">
|
||||
<p>cert-manager, c’est typiquement : <strong>une complexité énorme</strong> encapsulée dans un opérateur.</p>
|
||||
<p><strong>Là, ça vaut clairement le coup</strong> — parce que le domaine est clair, répétitif et critique.
|
||||
</p>
|
||||
</aside>
|
||||
</section>
|
||||
<section>
|
||||
<h1>Postgres répliqué sans bricoler le failover à la main</h1>
|
||||
<pre><code class="language-yaml">apiVersion: acid.zalan.do/v1
|
||||
kind: postgresql
|
||||
metadata:
|
||||
name: my-cluster
|
||||
spec:
|
||||
numberOfInstances: 3
|
||||
</code></pre>
|
||||
<p>Derrière cette spec, le contrôleur s’occupe de :</p>
|
||||
<ul>
|
||||
<li>Réplication</li>
|
||||
<li>Failover</li>
|
||||
<li>Backup</li>
|
||||
<li>Users / rôles</li>
|
||||
</ul>
|
||||
<aside class="notes">
|
||||
<p>Ça, c’est intéressant parce que ce n’est <strong>pas</strong> trivial : ce n’est <strong>pas</strong>
|
||||
stateless, ce n’est <strong>pas</strong> safe à bricoler à la main sur du long terme.</p>
|
||||
<p>Un opérateur mature sur ce terrain, c’est du <strong>métier base de données</strong> version déclarative.
|
||||
</p>
|
||||
</aside>
|
||||
</section>
|
||||
<section>
|
||||
<h1>Quand un opérateur est légitime</h1>
|
||||
<ul>
|
||||
<li>Système <strong>complexe</strong></li>
|
||||
<li><strong>Règles</strong> claires et stables</li>
|
||||
<li><strong>État</strong> critique pour l’activité</li>
|
||||
<li>Opérations <strong>répétitives</strong></li>
|
||||
</ul>
|
||||
<aside class="notes">
|
||||
<p>Un opérateur est pertinent quand tu peux formaliser des règles du type <strong>« si X alors Y »</strong>,
|
||||
que tu dois les appliquer <strong>souvent</strong>, et que tu veux <strong>réduire l’erreur
|
||||
humaine</strong>.</p>
|
||||
<p>Si tu ne peux pas écrire ça proprement… <strong>pause</strong>… <strong>ça, c’est une très mauvaise
|
||||
idée</strong> en opérateur.</p>
|
||||
</aside>
|
||||
</section>
|
||||
<section>
|
||||
<h1>Keycloak Credential Manager — le même schéma, chez moi</h1>
|
||||
<ul>
|
||||
<li>Objectif : <strong>réconcilier</strong> des <strong>credentials Keycloak</strong> (clients, secrets,
|
||||
rotation…) avec ce qui est <strong>déclaré dans le cluster</strong> (CRD, Secrets annotés, ou les deux —
|
||||
selon ton design).</li>
|
||||
<li>Intérêt conf : montrer le <strong>même schéma</strong> que les opérateurs « connus » : <em>observe →
|
||||
compare → corrige</em> sur <strong>ton</strong> domaine métier.</li>
|
||||
</ul>
|
||||
<aside class="notes">
|
||||
<p>Je vous montre ça pas pour vendre un produit, mais pour prouver que <strong>la logique</strong> est la même
|
||||
: une API Kubernetes, un état désiré, et du code qui parle à Keycloak à la place d’un humain et d’un
|
||||
runbook.</p>
|
||||
<ul>
|
||||
<li><strong>Lien repo</strong> : <code>https://…</code> (README, une spec d’exemple, capture d’un
|
||||
<code>kubectl get</code>).
|
||||
</li>
|
||||
<li><strong>Une phrase</strong> sur le périmètre exact : ex. clients OAuth uniquement, rotation de secrets,
|
||||
sync depuis External Secrets, etc.</li>
|
||||
</ul>
|
||||
<p>Schéma minimal : <code>CRD / Secret</code> → <strong>Keycloak Credential Manager</strong> → API Keycloak,
|
||||
style diagramme propre (flat design).</p>
|
||||
</aside>
|
||||
</section>
|
||||
<section>
|
||||
<h1>Ce qu’un opérateur bien choisi vous apporte</h1>
|
||||
<ul>
|
||||
<li>Automatiser du <strong>répétitif</strong></li>
|
||||
<li><strong>Encapsuler</strong> de la complexité</li>
|
||||
<li><strong>Stabiliser</strong> un système</li>
|
||||
</ul>
|
||||
<aside class="notes">
|
||||
<p>On remplace des opérations manuelles par du code — quand c’est le bon outil pour du vrai répétitif et des
|
||||
règles claires.</p>
|
||||
<p>On vient de voir du <strong>concret</strong> (annotations, CRD, projet maison). La question :
|
||||
<strong>quand</strong> est-ce un bon outil ?
|
||||
</p>
|
||||
<p>…<strong>mais</strong> les opérateurs peuvent aussi devenir un enfer.</p>
|
||||
</aside>
|
||||
</section>
|
||||
<section>
|
||||
<h1>Quand ne pas écrire d’opérateur</h1>
|
||||
<ul>
|
||||
<li>Logique <strong>floue</strong> ou en perpétuelle négociation métier</li>
|
||||
<li>Processus <strong>instables</strong> (la règle change toutes les semaines)</li>
|
||||
<li>Dépendances <strong>externes</strong> imprévisibles</li>
|
||||
<li><strong>One-shot</strong> : automatisation ponctuelle sans cycle de vie</li>
|
||||
</ul>
|
||||
<aside class="notes">
|
||||
<p>Si tu ne peux pas écrire clairement <strong>« si X alors Y »</strong> avec des conditions observables… tu
|
||||
n’as <strong>rien à faire</strong> dans un opérateur.</p>
|
||||
<p>Sinon tu recodes un outil métier opaque <strong>dans</strong> le cluster.</p>
|
||||
</aside>
|
||||
</section>
|
||||
<section>
|
||||
<h1>Cinq signaux qu’un opérateur part en vrille</h1>
|
||||
<ul>
|
||||
<li>Spec <strong>incompréhensible</strong> sans lire le code</li>
|
||||
<li>Effets <strong>implicites</strong> (créations ailleurs sans que ce soit visible)</li>
|
||||
<li><strong>Couplage</strong> externe fort (APIs fragiles, ordre d’appels magique)</li>
|
||||
<li><strong>Debug</strong> impossible sans les mainteneurs à côté de toi</li>
|
||||
<li><strong>État</strong> non observable (pas de conditions / status utiles)</li>
|
||||
</ul>
|
||||
<aside class="notes">
|
||||
<p>Un opérateur doit être <strong>lisible</strong>, <strong>prévisible</strong>, <strong>observable</strong>.
|
||||
</p>
|
||||
<p>Sinon, ce n’est pas de la plateforme : c’est une <strong>bombe à retardement</strong> avec un
|
||||
<code>kubectl</code> dessus.
|
||||
</p>
|
||||
</aside>
|
||||
</section>
|
||||
<section>
|
||||
<h1>Débugger un opérateur : describe, events, status, logs</h1>
|
||||
<ul>
|
||||
<li><code>kubectl describe</code> (resource + events)</li>
|
||||
<li><strong>Events</strong> du namespace</li>
|
||||
<li><strong>Status / conditions</strong> sur la CRD</li>
|
||||
<li><strong>Logs</strong> du contrôleur</li>
|
||||
</ul>
|
||||
<aside class="notes">
|
||||
<p>Le debug devient <strong>indirect</strong>, <strong>distribué</strong>, parfois <strong>opaque</strong>.
|
||||
</p>
|
||||
<p>Si ton opérateur est mal conçu — pas de status clair, pas d’events utiles — <strong>bonne chance</strong>
|
||||
pour le post-mortem à 3 h du matin.</p>
|
||||
</aside>
|
||||
</section>
|
||||
<section>
|
||||
<h1>Un opérateur, c’est un produit logiciel — pas un script</h1>
|
||||
<ul>
|
||||
<li><strong>Code</strong> et revues</li>
|
||||
<li><strong>Tests</strong> (unitaires, intégration, e2e si possible)</li>
|
||||
<li><strong>Maintenance</strong> dans la durée</li>
|
||||
<li><strong>Compatibilité</strong> avec les versions Kubernetes</li>
|
||||
<li><strong>Migrations</strong> de CRD / spec</li>
|
||||
</ul>
|
||||
<aside class="notes">
|
||||
<p>Un opérateur, ce n’est <strong>pas</strong> un script du vendredi.</p>
|
||||
<p>C’est un <strong>produit logiciel</strong> avec un <strong>cycle de vie</strong> — et une dette si tu
|
||||
l’abandonnes.</p>
|
||||
<p>Ensuite : la synthèse <strong>« ça part en vrille »</strong> — smells qu’on a déjà nommés, mais côté vécu
|
||||
d’équipe.</p>
|
||||
</aside>
|
||||
</section>
|
||||
<section>
|
||||
<h1>Quand l’opérateur devient l’enfer</h1>
|
||||
<ul>
|
||||
<li>Logique <strong>implicite</strong></li>
|
||||
<li><strong>Effets de bord</strong></li>
|
||||
<li><strong>Debugging</strong> difficile</li>
|
||||
<li><strong>Couplage</strong> fort</li>
|
||||
</ul>
|
||||
<p>En pratique, ça donne souvent :</p>
|
||||
<ul>
|
||||
<li>Tout mettre en opérateur</li>
|
||||
<li>Logique métier <strong>floue</strong></li>
|
||||
<li>Effets <strong>cachés</strong></li>
|
||||
</ul>
|
||||
<blockquote>
|
||||
<p>Un opérateur mal conçu, c’est un microservice sous LSD.</p>
|
||||
</blockquote>
|
||||
<aside class="notes">
|
||||
<p>Un mauvais opérateur, c’est de la complexité distribuée avec un debugger qui vous hait.<br />
|
||||
Le piège classique : « formation Kubernetes par la trappe » parce qu’on empile des comportements opaques.
|
||||
</p>
|
||||
</aside>
|
||||
</section>
|
||||
<section>
|
||||
<h1>Kubernetes, plateforme de programmation</h1>
|
||||
<ul>
|
||||
<li>Kubernetes vous évite <strong>l’enfer de l’infrastructure</strong> bas niveau.</li>
|
||||
<li>Les bons opérateurs vous évitent <strong>l’enfer du manuel</strong> — les mauvais vous y renvoient
|
||||
autrement.</li>
|
||||
</ul>
|
||||
<aside class="notes">
|
||||
<p>Kubernetes n’est pas qu’un outil d’infra : c’est une surface où l’on encode du
|
||||
<strong>comportement</strong>.<br />
|
||||
Les opérateurs sont l’extension naturelle… à condition de rester honnêtes sur la complexité.
|
||||
</p>
|
||||
<p>Fil de la conf (rappel) :</p>
|
||||
<ol>
|
||||
<li>Kubernetes n’est pas magique </li>
|
||||
<li>API + état + boucle ; kernel = vrai « enfer » bas niveau </li>
|
||||
<li>Controllers / opérateurs : où vit la logique </li>
|
||||
<li><strong>Avant</strong> : vécu (runbooks, scripts, drift) → <strong>pourquoi</strong> ça casse </li>
|
||||
<li><strong>Après</strong> + boucle ; exemples Reflector/Reloader, panorama CRD, creux cert-manager &
|
||||
Postgres </li>
|
||||
<li>Projet perso ; <strong>bon usage</strong> puis <strong>quand ne pas</strong>, smells, debug,
|
||||
<strong>coût</strong>
|
||||
</li>
|
||||
<li>Pièges ; plateforme de programmation ; <strong>checklist</strong> ; dernière phrase + conclusion brutale
|
||||
</li>
|
||||
</ol>
|
||||
</aside>
|
||||
</section>
|
||||
<section>
|
||||
<h1>Avant d’écrire un opérateur : la checklist</h1>
|
||||
<ul>
|
||||
<li>Répétitif</li>
|
||||
<li>Règles claires</li>
|
||||
<li>Déclaratif possible</li>
|
||||
<li>Idempotent</li>
|
||||
<li>Observable</li>
|
||||
</ul>
|
||||
<p>Si une case importante manque, reculez : pas d’opérateur (ou pas tout de suite).</p>
|
||||
<aside class="notes">
|
||||
<p>C’est probablement <strong>la</strong> slide la plus importante pour une salle de devs.</p>
|
||||
<p>Kubernetes donne <strong>envie</strong> d’en mettre partout. <strong>Mauvaise idée</strong> par défaut.</p>
|
||||
<p>👉 Tu valides la checklist, ou tu <strong>recules</strong>.</p>
|
||||
<p>Les <strong>pauses</strong> et les <strong>regards</strong> sur la salle après « Mauvaise idée » font
|
||||
autant le travail qu’un diagramme.</p>
|
||||
</aside>
|
||||
</section>
|
||||
<section>
|
||||
<h1>Votre logique, exécutée par le cluster au lieu d’un humain</h1>
|
||||
<aside class="notes">
|
||||
<p>Tu vises deux réactions :</p>
|
||||
<ul>
|
||||
<li>« OK, Kubernetes je comprends enfin le cadre. »</li>
|
||||
<li>« OK, les opérateurs : puissants, mais à manier avec précision. »</li>
|
||||
</ul>
|
||||
<p><strong>Éviter le piège</strong> : faire une « formation Kubernetes » sans le vouloir — rester sur le
|
||||
message <em>comportement / automatisation / risques</em>.</p>
|
||||
<p>Un opérateur, c’est votre logique métier… mais exécutée par le cluster au lieu d’un humain.<br />
|
||||
À vous de garder cette logique <strong>lisible</strong>, <strong>testable</strong>, et
|
||||
<strong>bornée</strong>.
|
||||
</p>
|
||||
</aside>
|
||||
</section>
|
||||
<section>
|
||||
<h1>Kubernetes simplifie l’infra — les opérateurs, ça peut simplifier ou empirer</h1>
|
||||
<aside class="notes">
|
||||
<p>Kubernetes vous évite une partie du chaos du bas niveau.</p>
|
||||
<p>Mais les opérateurs : soit ils <strong>encapsulent</strong> intelligemment une complexité maîtrisée, soit
|
||||
ils recréent un enfer <strong>plus sophistiqué</strong>.</p>
|
||||
<p>Ce que la salle retient : les <strong>pauses</strong>, les <strong>« très mauvaise idée »</strong>
|
||||
honnêtes, plus que chaque ligne du deck.</p>
|
||||
</aside>
|
||||
</section>
|
||||
</div>
|
||||
<footer class="deck-footer">
|
||||
<a href="https://specificat.io/" rel="noopener noreferrer" target="_blank">Specificat.io</a>
|
||||
<span class="deck-footer-sep" aria-hidden="true">·</span>
|
||||
<span class="deck-footer-rights">All rights reserved</span>
|
||||
</footer>
|
||||
</div>
|
||||
<!-- Ordre : cœur Reveal → plugins officiels (notes, highlight) → Mermaid -->
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/reveal.js/4.6.1/reveal.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/reveal.js/4.6.1/plugin/notes/notes.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/reveal.js/4.6.1/plugin/highlight/highlight.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
if (typeof RevealNotes === "undefined" || typeof RevealHighlight === "undefined") {
|
||||
console.error("Reveal : plugins Notes ou Highlight introuvables (vérifiez le réseau / CDN).");
|
||||
}
|
||||
|
||||
/* Thème Mermaid aligné sur https://specificat.io/ (variables en hex — requis par Mermaid) */
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: "base",
|
||||
securityLevel: "loose",
|
||||
/* foreignObject + thème base = libellés vides chez certains navigateurs ; SVG text est fiable */
|
||||
htmlLabels: false,
|
||||
fontFamily:
|
||||
'system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", sans-serif',
|
||||
themeVariables: {
|
||||
darkMode: true,
|
||||
background: "#050709",
|
||||
fontSize: "15px",
|
||||
primaryColor: "#0d1115",
|
||||
primaryTextColor: "#f5f5f5",
|
||||
primaryBorderColor: "#2bc9c6",
|
||||
secondaryColor: "#1b1f22",
|
||||
secondaryTextColor: "#f5f5f5",
|
||||
secondaryBorderColor: "#3d6d6a",
|
||||
tertiaryColor: "#050709",
|
||||
tertiaryTextColor: "#c8d0d4",
|
||||
tertiaryBorderColor: "#2bc9c6",
|
||||
lineColor: "#4dbfb8",
|
||||
textColor: "#f5f5f5",
|
||||
mainBkg: "#0d1115",
|
||||
nodeBorder: "#2bc9c6",
|
||||
nodeTextColor: "#f5f5f5",
|
||||
clusterBkg: "#0a1014",
|
||||
clusterBorder: "#2bc9c6",
|
||||
defaultLinkColor: "#4dbfb8",
|
||||
edgeLabelBackground: "#0d1115",
|
||||
titleColor: "#f5f5f5",
|
||||
nodeSpacing: 10,
|
||||
rankSpacing: 10
|
||||
},
|
||||
flowchart: {
|
||||
useMaxWidth: true,
|
||||
curve: "natural",
|
||||
},
|
||||
});
|
||||
|
||||
function upgradeMermaidBlocks(container) {
|
||||
container.querySelectorAll("pre code.language-mermaid").forEach(function (code) {
|
||||
var pre = code.parentElement;
|
||||
var div = document.createElement("div");
|
||||
div.className = "mermaid";
|
||||
var raw = code.textContent.replace(/\r\n/g, "\n");
|
||||
div.setAttribute("data-mermaid-src", raw);
|
||||
div.textContent = raw;
|
||||
pre.parentNode.replaceChild(div, pre);
|
||||
});
|
||||
}
|
||||
|
||||
/** Mermaid dans une slide display:none a souvent largeur 0 → SVG vide ou taille absurde : on ne rend que la slide visible. */
|
||||
function resetMermaidIfBroken(el) {
|
||||
if (!el.getAttribute("data-processed")) return;
|
||||
var svg = el.querySelector("svg");
|
||||
if (svg && svg.getBoundingClientRect().height > 8) return;
|
||||
var src = el.getAttribute("data-mermaid-src");
|
||||
if (!src) return;
|
||||
el.removeAttribute("data-processed");
|
||||
el.innerHTML = "";
|
||||
el.textContent = src;
|
||||
}
|
||||
|
||||
function runMermaidInSlide(slide) {
|
||||
if (!slide || typeof mermaid.run !== "function") return;
|
||||
slide.querySelectorAll(".mermaid").forEach(resetMermaidIfBroken);
|
||||
var pending = slide.querySelectorAll(".mermaid:not([data-processed])");
|
||||
if (!pending.length) return;
|
||||
requestAnimationFrame(function () {
|
||||
requestAnimationFrame(function () {
|
||||
mermaid.run({ nodes: pending, suppressErrors: true }).catch(function () { });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function applyHighlight() {
|
||||
if (typeof hljs === "undefined") return;
|
||||
document.querySelectorAll(".reveal .slides pre code").forEach(function (el) {
|
||||
if (!el.classList.contains("language-mermaid")) hljs.highlightElement(el);
|
||||
});
|
||||
}
|
||||
|
||||
Reveal.initialize({
|
||||
hash: true,
|
||||
slideNumber: "c/t",
|
||||
progress: true,
|
||||
center: true,
|
||||
transition: "slide",
|
||||
backgroundTransition: "fade",
|
||||
// Notes orateur en premier (touche S) ; puis coloration des blocs de code
|
||||
plugins: [RevealNotes, RevealHighlight],
|
||||
}).then(function () {
|
||||
applyHighlight();
|
||||
document.querySelectorAll(".reveal .slides section").forEach(function (sec) {
|
||||
upgradeMermaidBlocks(sec);
|
||||
});
|
||||
runMermaidInSlide(Reveal.getCurrentSlide());
|
||||
});
|
||||
|
||||
Reveal.on("slidechanged", function (event) {
|
||||
runMermaidInSlide(event.currentSlide);
|
||||
/* Slide « boucle » : sans ça, flex + centrage Reveal donnent souvent une 1ʳᵉ mesure de largeur fausse ; resize appelle déjà layout() — on le déclenche ici. */
|
||||
if (event.currentSlide && event.currentSlide.classList.contains("slide-boucle")) {
|
||||
requestAnimationFrame(function () {
|
||||
requestAnimationFrame(function () {
|
||||
if (typeof Reveal.layout === "function") Reveal.layout();
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,69 @@
|
||||
// @ts-check
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Portail (build statique)', () => {
|
||||
test('accueil : titre, intro, liens rapides, grille présentations', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await expect(page.getByRole('heading', { name: 'Talks', level: 1 })).toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: 'À propos de ce site' })).toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: 'Liens rapides' })).toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: 'Présentations' })).toBeVisible()
|
||||
|
||||
const qrCanvases = page.locator('.qr-section canvas')
|
||||
// Un seul QR sur l’accueil quand talkRepoUrl est vide (QR dépôt optionnel).
|
||||
await expect(qrCanvases).toHaveCount(1)
|
||||
|
||||
const cards = page.locator('.talks-section--home .talk-card')
|
||||
await expect(cards).toHaveCount(2)
|
||||
})
|
||||
|
||||
test('navigation : liste → fiche → href slides cohérents', async ({ page }) => {
|
||||
await page.goto('/presentations/')
|
||||
await expect(page.getByRole('heading', { name: 'Présentations', level: 1 })).toBeVisible()
|
||||
await expect(page.locator('article.talk-card--list')).toHaveCount(2)
|
||||
|
||||
await page
|
||||
.locator('article.talk-card--list')
|
||||
.filter({ hasText: 'Kubernetes Operators' })
|
||||
.getByRole('link', { name: 'Kubernetes Operators' })
|
||||
.click()
|
||||
await expect(page).toHaveURL(/\/presentations\/kubernetes-operators\/?$/)
|
||||
|
||||
const slidesLink = page.getByRole('link', { name: 'Voir les slides' })
|
||||
await expect(slidesLink).toHaveAttribute('href', /\/slides\/kubernetes-operators\/1\/?$/)
|
||||
})
|
||||
|
||||
test('fiche kubernetes-operators : clic « Voir les slides » ouvre la 1ère planche Slidev', async ({ page }) => {
|
||||
await page.goto('/presentations/kubernetes-operators/')
|
||||
await expect(page.getByRole('heading', { name: 'Kubernetes Operators', level: 1 })).toBeVisible()
|
||||
|
||||
await page.getByRole('link', { name: 'Voir les slides' }).click()
|
||||
|
||||
await expect(page).toHaveURL(/\/slides\/kubernetes-operators\/1\/?$/)
|
||||
await expect(page.locator('#app')).toBeAttached()
|
||||
await expect(page).toHaveTitle(/Kubernetes Operators/i)
|
||||
})
|
||||
|
||||
test('fiche : boutons slides + bloc QR deck', async ({ page }) => {
|
||||
await page.goto('/presentations/spring-ai/')
|
||||
await expect(page.getByRole('heading', { name: 'Spring AI', level: 1 })).toBeVisible()
|
||||
await expect(page.getByRole('link', { name: 'Voir les slides' })).toHaveAttribute(
|
||||
'href',
|
||||
/\/slides\/spring-ai\/1\/?$/,
|
||||
)
|
||||
await expect(page.getByRole('heading', { name: 'Partager les slides' })).toBeVisible()
|
||||
await expect(page.locator('.qr-deck canvas')).toHaveCount(1)
|
||||
})
|
||||
|
||||
test('deck Slidev servi sous /slides/<slug>/1', async ({ page }) => {
|
||||
await page.goto('/slides/kubernetes-operators/1')
|
||||
await expect(page.locator('#app')).toBeAttached()
|
||||
await expect(page).toHaveTitle(/Kubernetes Operators/i)
|
||||
})
|
||||
|
||||
test('lien accueil → liste complète', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.getByRole('link', { name: /Voir toutes les présentations/ }).click()
|
||||
await expect(page).toHaveURL(/\/presentations\/?$/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
// @ts-check
|
||||
import { readFileSync } from 'fs'
|
||||
import { join as pathJoin } from 'path'
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
const presJson = pathJoin(process.cwd(), 'portal', '.vitepress', 'data', 'presentations.json')
|
||||
const SLUGS = JSON.parse(readFileSync(presJson, 'utf8')).map((p) => p.slug)
|
||||
|
||||
const ROUTES = ['/', '/presentations/', ...SLUGS.map((s) => `/presentations/${s}/`)]
|
||||
|
||||
test.describe('HTTP / routes statiques', () => {
|
||||
for (const route of ROUTES) {
|
||||
test(`GET ${route} → 200`, async ({ request }) => {
|
||||
const res = await request.get(route)
|
||||
expect(res.ok(), await res.text()).toBeTruthy()
|
||||
})
|
||||
}
|
||||
|
||||
test('GET slides decks → 200', async ({ request }) => {
|
||||
for (const slug of SLUGS) {
|
||||
const res = await request.get(`/slides/${slug}/1`)
|
||||
expect(res.ok(), `slides ${slug}: ${await res.text()}`).toBeTruthy()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
// @ts-check
|
||||
import { readFileSync } from 'fs'
|
||||
import { join as pathJoin } from 'path'
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
const presJson = pathJoin(process.cwd(), 'portal', '.vitepress', 'data', 'presentations.json')
|
||||
const SLUGS = JSON.parse(readFileSync(presJson, 'utf8')).map((p) => p.slug)
|
||||
|
||||
test.describe('Matrice présentations (chaque slug)', () => {
|
||||
for (const slug of SLUGS) {
|
||||
test(`fiche ${slug} : titre, liens, QR`, async ({ page }) => {
|
||||
await page.goto(`/presentations/${slug}/`)
|
||||
await expect(page.locator('h1')).toBeVisible()
|
||||
const title = await page.locator('h1').textContent()
|
||||
expect(title.length).toBeGreaterThan(0)
|
||||
|
||||
const deckPath = new RegExp(`/slides/${slug}/1/?$`)
|
||||
await expect(page.getByRole('link', { name: 'Voir les slides' })).toHaveAttribute('href', deckPath)
|
||||
await expect(page.getByRole('link', { name: 'Ouvrir dans un nouvel onglet' })).toHaveAttribute('href', deckPath)
|
||||
await expect(page.locator('.qr-deck canvas')).toHaveCount(1)
|
||||
})
|
||||
|
||||
test(`deck Slidev ${slug} : #app + titre`, async ({ page }) => {
|
||||
await page.goto(`/slides/${slug}/1`)
|
||||
await expect(page.locator('#app')).toBeAttached()
|
||||
const t = await page.title()
|
||||
expect(t.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test(`liste : carte ${slug} avec actions Fiche / Slides`, async ({ page }) => {
|
||||
await page.goto('/presentations/')
|
||||
const card = page
|
||||
.locator('article.talk-card--list')
|
||||
.filter({ has: page.locator(`a[href="/presentations/${slug}/"], a[href$="/presentations/${slug}/"]`) })
|
||||
await expect(card.getByRole('link', { name: 'Fiche' })).toHaveAttribute('href', new RegExp(`/presentations/${slug}/`))
|
||||
await expect(card.getByRole('link', { name: 'Slides' })).toHaveAttribute('href', new RegExp(`/slides/${slug}/1`))
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -1,55 +0,0 @@
|
||||
# Exemple Flux — à intégrer dans le dépôt cluster (adapter namespaces / secrets).
|
||||
# Chart poussé par la CI : helm push talks-slides-chart-<version>.tgz oci://git.specificat.io/specificat.io
|
||||
---
|
||||
apiVersion: source.toolkit.fluxcd.io/v1
|
||||
kind: OCIRepository
|
||||
metadata:
|
||||
name: talks-slides-chart
|
||||
namespace: flux-system
|
||||
spec:
|
||||
interval: 10m
|
||||
url: oci://git.specificat.io/specificat.io/talks-slides-chart
|
||||
secretRef:
|
||||
name: gitea-registry-oci
|
||||
ref:
|
||||
semver: ">=0.0.1"
|
||||
---
|
||||
apiVersion: helm.toolkit.fluxcd.io/v2
|
||||
kind: HelmRelease
|
||||
metadata:
|
||||
name: talks-slides
|
||||
namespace: product
|
||||
spec:
|
||||
interval: 10m
|
||||
targetNamespace: product
|
||||
chartRef:
|
||||
kind: OCIRepository
|
||||
name: talks-slides-chart
|
||||
namespace: flux-system
|
||||
install:
|
||||
remediation:
|
||||
retries: 3
|
||||
upgrade:
|
||||
remediation:
|
||||
retries: 3
|
||||
values:
|
||||
slides:
|
||||
image:
|
||||
repository: git.specificat.io/specificat.io/talks-slides
|
||||
tag: latest # {"$imagepolicy": "product:talks-slides"}
|
||||
imagePullSecrets:
|
||||
- name: prd-gitea-registry-secret
|
||||
ingress:
|
||||
enabled: true
|
||||
className: nginx
|
||||
hosts:
|
||||
- host: slides.specificat.io
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
# TLS via cert-manager (annotation + bloc tls pour le secret à créer)
|
||||
certManager:
|
||||
enabled: true
|
||||
clusterIssuer: letsencrypt-prod
|
||||
tls:
|
||||
secretName: ""
|
||||
@@ -1,51 +0,0 @@
|
||||
# Optionnel : Image Automation pour l’image applicative (pas le chart).
|
||||
# Adapte sourceRef GitRepository, update.path et la branche à ton dépôt cluster.
|
||||
# Alternative souvent plus simple : ne pousser que l’image en CI et laisser le chart
|
||||
# en OCI avec semver ; Flux réconcilie le chart ; le contenu des slides est déjà
|
||||
# mis à jour par git pull dans le conteneur (pas besoin de redéployer à chaque commit).
|
||||
# Prérequis : image-reflector-controller + image-automation-controller.
|
||||
---
|
||||
apiVersion: image.toolkit.fluxcd.io/v1beta2
|
||||
kind: ImageRepository
|
||||
metadata:
|
||||
name: talks-slides
|
||||
namespace: product
|
||||
spec:
|
||||
image: git.specificat.io/specificat.io/talks-slides
|
||||
interval: 1m
|
||||
secretRef:
|
||||
name: prd-gitea-registry-secret
|
||||
---
|
||||
apiVersion: image.toolkit.fluxcd.io/v1beta2
|
||||
kind: ImagePolicy
|
||||
metadata:
|
||||
name: talks-slides
|
||||
namespace: product
|
||||
spec:
|
||||
imageRepositoryRef:
|
||||
name: talks-slides
|
||||
policy:
|
||||
semver:
|
||||
range: ">=0.0.1-0"
|
||||
---
|
||||
apiVersion: image.toolkit.fluxcd.io/v1beta2
|
||||
kind: ImageUpdateAutomation
|
||||
metadata:
|
||||
name: talks-slides
|
||||
namespace: product
|
||||
spec:
|
||||
interval: 1m
|
||||
sourceRef:
|
||||
kind: GitRepository
|
||||
name: flux-system
|
||||
namespace: flux-system
|
||||
git:
|
||||
commit:
|
||||
author:
|
||||
name: Flux
|
||||
email: flux@specificat.io
|
||||
push:
|
||||
branch: main
|
||||
update:
|
||||
path: ./clusters/product
|
||||
strategy: Setters
|
||||
Generated
+12945
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "tech-talks-portal",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"description": "Portail VitePress + decks Slidev",
|
||||
"scripts": {
|
||||
"sync": "node tools/sync-portal.mjs",
|
||||
"start": "node tools/run.mjs",
|
||||
"build": "node tools/sync-portal.mjs && vitepress build portal && node tools/build-all-decks.mjs",
|
||||
"preview:static": "node tools/static-preview.mjs",
|
||||
"dev:portal": "node tools/dev-portal.mjs",
|
||||
"dev:slides:kubernetes-operators": "slidev slides/kubernetes-operators/slides.md --open",
|
||||
"dev:slides:spring-ai": "slidev slides/spring-ai/slides.md --open",
|
||||
"test": "npm run test:node && npm run test:e2e",
|
||||
"test:node": "node --test tests/site-base.test.mjs tests/presentation-contracts.test.mjs tests/repo-consistency.test.mjs tests/sync-tmp-repo.test.mjs tests/dist-layout.test.mjs tests/vitepress-slides-route.test.mjs",
|
||||
"test:unit": "npm run test:node",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@slidev/cli": "^52.14.2",
|
||||
"@slidev/theme-default": "^0.25.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"chokidar": "^4.0.3",
|
||||
"serve": "^14.2.6",
|
||||
"sirv": "^3.0.2",
|
||||
"vitepress": "^1.6.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"dependencies": {
|
||||
"qrcode": "^1.5.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
const port = Number(process.env.PW_PORT || 4174)
|
||||
const baseURL = `http://127.0.0.1:${port}`
|
||||
|
||||
const skipWebServer = !!process.env.PW_SKIP_WEBSERVER
|
||||
|
||||
export default defineConfig({
|
||||
testDir: 'e2e',
|
||||
timeout: 60_000,
|
||||
expect: { timeout: 15_000 },
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: [['list'], ['html', { open: 'never' }]],
|
||||
use: {
|
||||
baseURL,
|
||||
trace: 'on-first-retry',
|
||||
...devices['Desktop Chrome'],
|
||||
},
|
||||
projects: [{ name: 'chromium', use: {} }],
|
||||
webServer: skipWebServer
|
||||
? undefined
|
||||
: {
|
||||
command: `npm run build && PREVIEW_PORT=${port} node tools/static-preview.mjs`,
|
||||
url: baseURL,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 300_000,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { withBase } from 'vitepress'
|
||||
import QRCode from 'qrcode'
|
||||
|
||||
const props = defineProps<{ path: string }>()
|
||||
const canvas = ref<HTMLCanvasElement | null>(null)
|
||||
|
||||
const href = computed(() => withBase(props.path))
|
||||
|
||||
const displayHost = computed(() => {
|
||||
if (typeof window === 'undefined') return href.value
|
||||
const abs = new URL(href.value, window.location.origin).href
|
||||
return abs.replace(/^https?:\/\//, '')
|
||||
})
|
||||
|
||||
const fullHref = computed(() => {
|
||||
if (typeof window === 'undefined') return href.value
|
||||
return new URL(href.value, window.location.origin).href
|
||||
})
|
||||
|
||||
async function draw() {
|
||||
if (!canvas.value || typeof window === 'undefined') return
|
||||
const url = new URL(href.value, window.location.origin).href
|
||||
await QRCode.toCanvas(canvas.value, url, {
|
||||
width: 200,
|
||||
margin: 1,
|
||||
color: { dark: '#0d1115ff', light: '#ffffffff' },
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(draw)
|
||||
watch(href, draw)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="qr-deck">
|
||||
<a class="qr-link" :href="href">{{ displayHost }}</a>
|
||||
<div class="qrcode-wrap">
|
||||
<canvas ref="canvas" role="img" :aria-label="`QR code vers ${fullHref}`" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useData } from 'vitepress'
|
||||
import QRCode from 'qrcode'
|
||||
|
||||
const { theme } = useData()
|
||||
|
||||
const repoUrl = computed(() => (theme.value as Record<string, string>).talkRepoUrl || '')
|
||||
const repoTitle = computed(() => (theme.value as Record<string, string>).talkRepoTitle || 'Dépôt de code')
|
||||
const repoDesc = computed(
|
||||
() => (theme.value as Record<string, string>).talkRepoDesc || 'Sources, CI et configuration du site.',
|
||||
)
|
||||
const siteTitle = computed(() => (theme.value as Record<string, string>).talkSiteTitle || 'Ce site (slides)')
|
||||
const siteDesc = computed(
|
||||
() =>
|
||||
(theme.value as Record<string, string>).talkSiteDesc ||
|
||||
'URL à projeter ou à partager pendant une présentation.',
|
||||
)
|
||||
|
||||
const siteUrlText = ref('…')
|
||||
const siteUrlHref = ref('#')
|
||||
const canvasRepo = ref<HTMLCanvasElement | null>(null)
|
||||
const canvasSite = ref<HTMLCanvasElement | null>(null)
|
||||
|
||||
const opts = {
|
||||
width: 200,
|
||||
margin: 1,
|
||||
color: { dark: '#0d1115ff', light: '#ffffffff' },
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const u = new URL(window.location.href)
|
||||
u.hash = ''
|
||||
const siteUrl = u.href
|
||||
siteUrlHref.value = siteUrl
|
||||
siteUrlText.value = siteUrl.replace(/^https?:\/\//, '')
|
||||
|
||||
if (canvasRepo.value && repoUrl.value) {
|
||||
await QRCode.toCanvas(canvasRepo.value, repoUrl.value, opts)
|
||||
}
|
||||
if (canvasSite.value) {
|
||||
await QRCode.toCanvas(canvasSite.value, siteUrl, opts)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="qr-section" aria-labelledby="qr-heading">
|
||||
<h2 id="qr-heading" class="section-title">Liens rapides</h2>
|
||||
<div class="qr-grid">
|
||||
<article v-if="repoUrl" class="qr-card">
|
||||
<h3>{{ repoTitle }}</h3>
|
||||
<p class="qr-card-desc">{{ repoDesc }}</p>
|
||||
<a class="qr-link" :href="repoUrl" target="_blank" rel="noopener noreferrer">{{ repoUrl.replace(/^https?:\/\//, '') }}</a>
|
||||
<div class="qrcode-wrap" aria-hidden="true">
|
||||
<canvas ref="canvasRepo" />
|
||||
</div>
|
||||
</article>
|
||||
<article class="qr-card">
|
||||
<h3>{{ siteTitle }}</h3>
|
||||
<p class="qr-card-desc">{{ siteDesc }}</p>
|
||||
<a class="qr-link" id="qr-site-link" :href="siteUrlHref">{{ siteUrlText }}</a>
|
||||
<div class="qrcode-wrap" aria-hidden="true">
|
||||
<canvas ref="canvasSite" />
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,46 @@
|
||||
import { defineConfig } from 'vitepress'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { vitepressAppBase } from '../../tools/site-base.mjs'
|
||||
import { vitePluginSlideDists } from './vite-plugin-slide-dists.mjs'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const repoRoot = path.resolve(__dirname, '../..')
|
||||
|
||||
const vpBase = vitepressAppBase()
|
||||
|
||||
export default defineConfig({
|
||||
lang: 'fr-FR',
|
||||
title: 'Talks — Specificat',
|
||||
description: 'Présentations techniques (Slidev) — portail VitePress',
|
||||
base: vpBase,
|
||||
appearance: 'force-dark',
|
||||
cleanUrls: true,
|
||||
srcDir: '.',
|
||||
/** Build portail seul — les decks sont dans dist-<slug>/ à la racine du dépôt. */
|
||||
outDir: path.resolve(repoRoot, 'dist-portal'),
|
||||
vite: {
|
||||
// Écoute sur toutes les interfaces (ex. 127.0.0.1 + ::1). Sinon Node peut
|
||||
// ne lier que ::1 et http://127.0.0.1:5173/… refuse la connexion (pas le même que localhost).
|
||||
server: {
|
||||
host: true,
|
||||
},
|
||||
plugins: [vitePluginSlideDists({ base: vpBase })],
|
||||
},
|
||||
themeConfig: {
|
||||
nav: [
|
||||
{ text: 'Accueil', link: '/' },
|
||||
{ text: 'Présentations', link: '/presentations/' },
|
||||
],
|
||||
search: { provider: 'local' },
|
||||
sidebar: false,
|
||||
footer: {
|
||||
message: 'Talks — VitePress + Slidev.',
|
||||
},
|
||||
talkRepoUrl: '',
|
||||
talkRepoTitle: '',
|
||||
talkRepoDesc: '',
|
||||
talkSiteTitle: 'Ce site (slides)',
|
||||
talkSiteDesc: 'URL à projeter ou partager pendant une présentation.',
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
[
|
||||
{
|
||||
"slug": "kubernetes-operators",
|
||||
"title": "Kubernetes Operators — De l’enfer au paradis",
|
||||
"description": "Mental model (API, etcd, boucle), controllers et opérateurs, exemples concrets (Reflector, Reloader, cert-manager, Postgres) et pièges à éviter.",
|
||||
"tags": [
|
||||
"kubernetes",
|
||||
"operators",
|
||||
"devops",
|
||||
"controllers"
|
||||
],
|
||||
"order": 1,
|
||||
"featured": true,
|
||||
"folder": "kubernetes-operators"
|
||||
},
|
||||
{
|
||||
"slug": "spring-ai",
|
||||
"title": "Spring AI",
|
||||
"description": "Intégrer des modèles LLM dans l’écosystème Spring avec des API cohérentes.",
|
||||
"tags": [
|
||||
"java",
|
||||
"spring",
|
||||
"llm",
|
||||
"ai"
|
||||
],
|
||||
"order": 2,
|
||||
"featured": true,
|
||||
"folder": "spring-ai"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,401 @@
|
||||
/**
|
||||
* Look & feel aligné sur talk.specificat.io (palette sombre + accent cyan).
|
||||
* Variables VitePress ajustées pour cohérence avec la nav et le contenu.
|
||||
*/
|
||||
|
||||
:root {
|
||||
--talk-bg-dark: #050709;
|
||||
--talk-bg-medium: #0d1115;
|
||||
--talk-bg-light: #1b1f22;
|
||||
--talk-bg-gradient-start: #10151a;
|
||||
--talk-text-primary: #f5f5f5;
|
||||
--talk-text-muted: rgba(245, 245, 245, 0.9);
|
||||
--talk-text-subtle: rgba(245, 245, 245, 0.75);
|
||||
--talk-accent-cyan: #2bc9c6;
|
||||
--talk-accent-cyan-bg: rgba(43, 201, 198, 0.08);
|
||||
--talk-accent-cyan-border: rgba(43, 201, 198, 0.55);
|
||||
--talk-border-subtle: rgba(255, 255, 255, 0.12);
|
||||
--talk-border-very-subtle: rgba(255, 255, 255, 0.07);
|
||||
--talk-panel-glow: 0 0 0 1px rgba(43, 201, 198, 0.12);
|
||||
|
||||
--vp-c-bg: var(--talk-bg-dark);
|
||||
--vp-c-bg-alt: var(--talk-bg-medium);
|
||||
--vp-c-bg-elv: var(--talk-bg-light);
|
||||
--vp-c-text-1: var(--talk-text-primary);
|
||||
--vp-c-text-2: var(--talk-text-muted);
|
||||
--vp-c-text-3: var(--talk-text-subtle);
|
||||
--vp-c-divider: var(--talk-border-subtle);
|
||||
--vp-c-brand-1: var(--talk-accent-cyan);
|
||||
--vp-c-brand-2: var(--talk-accent-cyan);
|
||||
--vp-c-brand-3: var(--talk-accent-cyan);
|
||||
--vp-c-brand-soft: var(--talk-accent-cyan-bg);
|
||||
--vp-button-brand-bg: transparent;
|
||||
--vp-button-brand-border: var(--talk-accent-cyan);
|
||||
--vp-button-brand-text: var(--talk-accent-cyan);
|
||||
--vp-nav-bg-color: rgba(5, 7, 9, 0.85);
|
||||
--vp-sidebar-bg-color: var(--talk-bg-medium);
|
||||
--vp-code-bg: var(--talk-accent-cyan-bg);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--vp-c-bg: var(--talk-bg-dark);
|
||||
--vp-c-bg-alt: var(--talk-bg-medium);
|
||||
}
|
||||
|
||||
html {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100%;
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'SF Pro Text', sans-serif;
|
||||
color: var(--talk-text-primary);
|
||||
background-color: var(--talk-bg-dark);
|
||||
background-image:
|
||||
radial-gradient(circle at 10% 0%, var(--talk-bg-gradient-start) 0%, var(--talk-bg-dark) 52%, var(--talk-bg-light) 100%),
|
||||
radial-gradient(ellipse 100% 80% at 50% -20%, rgba(43, 201, 198, 0.12), transparent 55%);
|
||||
}
|
||||
|
||||
body::after {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(ellipse 95% 75% at 50% 50%, transparent 35%, rgba(5, 7, 9, 0.35) 100%);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
#app,
|
||||
.VPContent,
|
||||
.VPDoc,
|
||||
.VPDoc .content-container {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.VPNav {
|
||||
border-bottom: 1px solid var(--talk-border-very-subtle);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
/* --- Page d’accueil (structure legacy) --- */
|
||||
.talk-page {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
|
||||
.masthead {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.masthead h1 {
|
||||
margin: 0 0 0.35rem;
|
||||
font-size: clamp(1.75rem, 4vw, 2.25rem);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.masthead-lead {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
color: var(--talk-text-subtle);
|
||||
max-width: 42em;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.masthead-lead strong {
|
||||
color: var(--talk-accent-cyan);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.masthead a {
|
||||
color: var(--talk-accent-cyan);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.masthead a:hover {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--talk-text-primary);
|
||||
}
|
||||
|
||||
.intro {
|
||||
margin-bottom: 2.25rem;
|
||||
}
|
||||
|
||||
.intro p {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.98rem;
|
||||
line-height: 1.6;
|
||||
color: var(--talk-text-muted);
|
||||
max-width: 52em;
|
||||
}
|
||||
|
||||
.intro p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.intro code {
|
||||
font-size: 0.9em;
|
||||
padding: 0.12em 0.35em;
|
||||
border-radius: 6px;
|
||||
background: var(--talk-accent-cyan-bg);
|
||||
border: 1px solid var(--talk-border-very-subtle);
|
||||
}
|
||||
|
||||
/* --- QR (accueil) --- */
|
||||
.qr-section {
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.qr-grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.qr-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.qr-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 1.25rem 1.35rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--talk-border-subtle);
|
||||
background: rgba(13, 17, 21, 0.55);
|
||||
box-shadow: var(--talk-panel-glow), inset 0 1px 0 0 rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.qr-card h3 {
|
||||
margin: 0 0 0.35rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--talk-accent-cyan);
|
||||
}
|
||||
|
||||
.qr-card-desc {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.45;
|
||||
color: var(--talk-text-subtle);
|
||||
}
|
||||
|
||||
.qr-link {
|
||||
display: inline-block;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 500;
|
||||
color: var(--talk-accent-cyan);
|
||||
text-decoration: none;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.qr-link:hover,
|
||||
.qr-link:focus-visible {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.qr-link:focus-visible {
|
||||
outline: 2px solid var(--talk-accent-cyan);
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.qrcode-wrap {
|
||||
padding: 0.5rem;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
/* Cartes accueil : .qr-card est en align-items: flex-start */
|
||||
.qr-card .qrcode-wrap {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.qrcode-wrap canvas,
|
||||
.qrcode-wrap img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.qr-deck {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
margin: 1rem 0 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.qr-deck .qr-link {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.home-more {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
/* --- Grille présentations (cartes type legacy) --- */
|
||||
.talks-section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.talks-grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@media (min-width: 560px) {
|
||||
.talks-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.talk-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1.15rem 1.25rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--talk-border-subtle);
|
||||
background: rgba(13, 17, 21, 0.55);
|
||||
box-shadow: var(--talk-panel-glow), inset 0 1px 0 0 rgba(255, 255, 255, 0.03);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
box-shadow 0.15s ease,
|
||||
transform 0.12s ease;
|
||||
}
|
||||
|
||||
a.talk-card:hover,
|
||||
a.talk-card:focus-visible {
|
||||
border-color: var(--talk-accent-cyan-border);
|
||||
box-shadow:
|
||||
var(--talk-panel-glow),
|
||||
0 0 0 1px rgba(43, 201, 198, 0.18),
|
||||
inset 0 1px 0 0 rgba(255, 255, 255, 0.05);
|
||||
transform: translateY(-1px);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
a.talk-card:focus-visible {
|
||||
outline: 2px solid var(--talk-accent-cyan);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.talk-card h2 {
|
||||
margin: 0 0 0.4rem;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--talk-accent-cyan);
|
||||
}
|
||||
|
||||
.talk-card p {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.45;
|
||||
color: var(--talk-text-muted);
|
||||
}
|
||||
|
||||
.talk-card .meta {
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--talk-text-subtle);
|
||||
}
|
||||
|
||||
/* Liste présentations : carte avec actions */
|
||||
.talk-card--list h2 a {
|
||||
color: var(--talk-accent-cyan);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.talk-card--list h2 a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
article.talk-card--list {
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
article.talk-card--list:hover {
|
||||
border-color: var(--talk-accent-cyan-border);
|
||||
box-shadow:
|
||||
var(--talk-panel-glow),
|
||||
0 0 0 1px rgba(43, 201, 198, 0.12);
|
||||
}
|
||||
|
||||
.talk-card__actions {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Boutons (fiches + détail) */
|
||||
.vp-button {
|
||||
display: inline-block;
|
||||
padding: 0.4rem 0.85rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
border: 1px solid var(--talk-accent-cyan);
|
||||
color: var(--talk-accent-cyan);
|
||||
background: transparent;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.vp-button:hover {
|
||||
background: var(--talk-accent-cyan-bg);
|
||||
}
|
||||
|
||||
.vp-button--alt {
|
||||
border-color: var(--talk-border-subtle);
|
||||
color: var(--talk-text-muted);
|
||||
}
|
||||
|
||||
.pres-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin: 1rem 0 0;
|
||||
}
|
||||
|
||||
/* Titres pages intérieures */
|
||||
.vp-doc h1 {
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
/* Footer VitePress */
|
||||
.VPFooter {
|
||||
border-top: 1px solid var(--talk-border-very-subtle);
|
||||
color: var(--talk-text-subtle);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Local search / algolia si activé */
|
||||
.VPLocalSearchBackdrop {
|
||||
z-index: 100;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { Theme } from 'vitepress'
|
||||
import DefaultTheme from 'vitepress/theme'
|
||||
import QrHomeSection from '../components/QrHomeSection.vue'
|
||||
import QrEncode from '../components/QrEncode.vue'
|
||||
import './custom.css'
|
||||
|
||||
export default {
|
||||
extends: DefaultTheme,
|
||||
enhanceApp({ app }) {
|
||||
app.component('QrHomeSection', QrHomeSection)
|
||||
app.component('QrEncode', QrEncode)
|
||||
},
|
||||
} satisfies Theme
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Dev : sert chaque dist-<slug>/ sous /slides/<slug>/ (sources = dossiers à la racine du dépôt).
|
||||
* La racine `/slides/<slug>/` (et `/slides/<slug>`) redirige en 302 vers `/slides/<slug>/1` (route Slidev).
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import sirv from 'sirv'
|
||||
import { listPresentationSlugs } from '../../tools/list-slugs.mjs'
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
|
||||
|
||||
function slidesUrlPrefix(vpBase) {
|
||||
if (!vpBase || vpBase === '/') return '/slides'
|
||||
return `${String(vpBase).replace(/\/+$/, '')}/slides`
|
||||
}
|
||||
|
||||
/** Requêtes que le middleware ne doit pas confondre avec la SPA Slidev (sinon HTML → erreur MIME sur les modules). */
|
||||
function isViteOrSourceLikeRequest(rawUrl) {
|
||||
let u
|
||||
try {
|
||||
u = new URL(rawUrl, 'http://vitepress.local')
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
const q = u.searchParams
|
||||
if (q.has('import') || q.has('raw') || q.has('url') || q.has('worker') || q.has('sharedworker')) {
|
||||
return true
|
||||
}
|
||||
const p = u.pathname
|
||||
return /\.(md|mdx|vue|ts|mts|cts|tsx|jsx)($|\?)/i.test(p)
|
||||
}
|
||||
|
||||
/**
|
||||
* Après échec sirv : ne pas laisser VitePress répondre en HTML pour des scripts / modules.
|
||||
* Pour une navigation document, servir le index.html du deck (router Slidev en history).
|
||||
*/
|
||||
function send404(res) {
|
||||
res.statusCode = 404
|
||||
res.setHeader('Content-Type', 'text/plain; charset=utf-8')
|
||||
res.end('Not found')
|
||||
}
|
||||
|
||||
function wantsAssetNotHtml(req) {
|
||||
const dest = req.headers['sec-fetch-dest']
|
||||
if (dest === 'script' || dest === 'style' || dest === 'image' || dest === 'font') return true
|
||||
const accept = req.headers.accept || ''
|
||||
if (accept.includes('text/javascript') || accept.includes('application/javascript')) return true
|
||||
return false
|
||||
}
|
||||
|
||||
/** Racine du deck Slidev (`/` ou `/index.html`) : le routeur attend `/1` pour la première planche. */
|
||||
function isSlideDeckEntrySubpath(sub) {
|
||||
const n = sub.replace(/\/+$/, '') || '/'
|
||||
return n === '/' || n === '/index.html'
|
||||
}
|
||||
|
||||
export function vitePluginSlideDists({ base: vpBase }) {
|
||||
const prefix = slidesUrlPrefix(vpBase)
|
||||
const escaped = prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const re = new RegExp(`^${escaped}\\/([^/]+)(.*)$`)
|
||||
/** @type {Map<string, ReturnType<typeof sirv>>} */
|
||||
const sirvBySlug = new Map()
|
||||
/** @type {Map<string, string>} */
|
||||
const indexHtmlBySlug = new Map()
|
||||
const warnedMissing = new Set()
|
||||
|
||||
return {
|
||||
name: 'talks-slide-dists',
|
||||
enforce: 'pre',
|
||||
apply: 'serve',
|
||||
configureServer(server) {
|
||||
server.middlewares.use((req, res, next) => {
|
||||
const raw = req.originalUrl || req.url || '/'
|
||||
const pathPart = raw.split('?')[0]
|
||||
const m = pathPart.match(re)
|
||||
if (!m) return next()
|
||||
const slug = m[1]
|
||||
const subRaw = m[2] || '/'
|
||||
if (!listPresentationSlugs(repoRoot).includes(slug)) return next()
|
||||
|
||||
const distDir = path.join(repoRoot, `dist-${slug}`)
|
||||
if (!fs.existsSync(path.join(distDir, 'index.html'))) {
|
||||
if (!warnedMissing.has(slug)) {
|
||||
warnedMissing.add(slug)
|
||||
server.config.logger.warn(
|
||||
`[talks-slide-dists] dist-${slug}/ manquant ou sans index.html — exécutez « make run » ou « npm run build » puis réessayez.`,
|
||||
)
|
||||
}
|
||||
return next()
|
||||
}
|
||||
|
||||
const sub = subRaw.startsWith('/') ? subRaw : `/${subRaw}`
|
||||
const query = raw.includes('?') ? `?${raw.split('?')[1]}` : ''
|
||||
|
||||
if (
|
||||
(req.method === 'GET' || req.method === 'HEAD') &&
|
||||
isSlideDeckEntrySubpath(sub) &&
|
||||
!isViteOrSourceLikeRequest(raw)
|
||||
) {
|
||||
res.statusCode = 302
|
||||
res.setHeader('Location', `${prefix}/${slug}/1${query}`)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
|
||||
if (isViteOrSourceLikeRequest(raw)) {
|
||||
return send404(res)
|
||||
}
|
||||
|
||||
const origUrl = req.url
|
||||
delete req._parsedUrl
|
||||
req.url = sub + query
|
||||
|
||||
let handler = sirvBySlug.get(slug)
|
||||
if (!handler) {
|
||||
handler = sirv(distDir, { dev: true, etag: true, maxAge: 0 })
|
||||
sirvBySlug.set(slug, handler)
|
||||
}
|
||||
|
||||
handler(req, res, () => {
|
||||
req.url = origUrl
|
||||
delete req._parsedUrl
|
||||
|
||||
if (isViteOrSourceLikeRequest(raw)) {
|
||||
return send404(res)
|
||||
}
|
||||
if (wantsAssetNotHtml(req)) {
|
||||
return send404(res)
|
||||
}
|
||||
if (req.method === 'GET') {
|
||||
let html = indexHtmlBySlug.get(slug)
|
||||
if (html === undefined) {
|
||||
const p = path.join(distDir, 'index.html')
|
||||
html = fs.readFileSync(p, 'utf8')
|
||||
indexHtmlBySlug.set(slug, html)
|
||||
}
|
||||
res.statusCode = 200
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8')
|
||||
res.end(html)
|
||||
return
|
||||
}
|
||||
next()
|
||||
})
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
layout: doc
|
||||
aside: false
|
||||
outline: false
|
||||
---
|
||||
|
||||
<div class="talk-page">
|
||||
|
||||
<header class="masthead">
|
||||
<h1>Talks</h1>
|
||||
<p class="masthead-lead">
|
||||
Présentations techniques au format
|
||||
<strong><a href="https://sli.dev" target="_blank" rel="noopener noreferrer">Slidev</a></strong>
|
||||
— portail
|
||||
<a href="https://vitepress.dev" target="_blank" rel="noopener noreferrer">VitePress</a>,
|
||||
déployable en statique (CI, Pages, Nginx).
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section class="intro" aria-labelledby="intro-heading">
|
||||
<h2 id="intro-heading" class="section-title">À propos de ce site</h2>
|
||||
<p>
|
||||
Ce site regroupe les slides utilisées pour nos conférences et ateliers : navigation clavier, mode plein écran,
|
||||
export PDF selon les thèmes Slidev. Le contenu vit dans le dépôt Git <em>Talks</em> ; les mises à jour sous
|
||||
<code>slides/</code> et le portail <code>portal/</code> peuvent être déployées automatiquement.
|
||||
</p>
|
||||
<p>
|
||||
En salle ou en visio, partagez l’URL des slides ou le dépôt source — les QR codes ci-dessous facilitent l’accès
|
||||
depuis un téléphone.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<QrHomeSection />
|
||||
|
||||
<!-- FEATURED_START -->
|
||||
|
||||
<section class="talks-section talks-section--home" aria-labelledby="featured-heading">
|
||||
<h2 id="featured-heading" class="section-title">Présentations</h2>
|
||||
<div class="talks-grid">
|
||||
<a class="talk-card" href="/presentations/kubernetes-operators/">
|
||||
<h2>Kubernetes Operators — De l’enfer au paradis</h2>
|
||||
<p>Mental model (API, etcd, boucle), controllers et opérateurs, exemples concrets (Reflector, Reloader, cert-manager, Postgres) et pièges à éviter.</p>
|
||||
<span class="meta">Présentation · kubernetes-operators</span>
|
||||
</a>
|
||||
<a class="talk-card" href="/presentations/spring-ai/">
|
||||
<h2>Spring AI</h2>
|
||||
<p>Intégrer des modèles LLM dans l’écosystème Spring avec des API cohérentes.</p>
|
||||
<span class="meta">Présentation · spring-ai</span>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- FEATURED_END -->
|
||||
|
||||
<section class="home-more" aria-label="Navigation">
|
||||
<p><a class="qr-link" href="/presentations/">Voir toutes les présentations →</a></p>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
title: Présentations
|
||||
outline: deep
|
||||
---
|
||||
|
||||
# Présentations
|
||||
|
||||
Decks disponibles — fiche portail et build Slidev statique.
|
||||
|
||||
<div class="talks-grid">
|
||||
<article class="talk-card talk-card--list">
|
||||
<h2><a href="/presentations/kubernetes-operators/">Kubernetes Operators — De l’enfer au paradis</a></h2>
|
||||
<p>Mental model (API, etcd, boucle), controllers et opérateurs, exemples concrets (Reflector, Reloader, cert-manager, Postgres) et pièges à éviter.</p>
|
||||
<p class="meta">`kubernetes` · `operators` · `devops` · `controllers`</p>
|
||||
<div class="talk-card__actions">
|
||||
<a class="vp-button" href="/presentations/kubernetes-operators/">Fiche</a>
|
||||
<a class="vp-button vp-button--alt vp-raw" href="/slides/kubernetes-operators/1" rel="noopener">Slides</a>
|
||||
</div>
|
||||
</article>
|
||||
<article class="talk-card talk-card--list">
|
||||
<h2><a href="/presentations/spring-ai/">Spring AI</a></h2>
|
||||
<p>Intégrer des modèles LLM dans l’écosystème Spring avec des API cohérentes.</p>
|
||||
<p class="meta">`java` · `spring` · `llm` · `ai`</p>
|
||||
<div class="talk-card__actions">
|
||||
<a class="vp-button" href="/presentations/spring-ai/">Fiche</a>
|
||||
<a class="vp-button vp-button--alt vp-raw" href="/slides/spring-ai/1" rel="noopener">Slides</a>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
title: Kubernetes Operators — De l’enfer au paradis
|
||||
outline: deep
|
||||
---
|
||||
|
||||
# Kubernetes Operators — De l’enfer au paradis
|
||||
|
||||
**Tags :** `kubernetes` · `operators` · `devops` · `controllers`
|
||||
|
||||
## Résumé
|
||||
|
||||
Mental model (API, etcd, boucle), controllers et opérateurs, exemples concrets (Reflector, Reloader, cert-manager, Postgres) et pièges à éviter.
|
||||
|
||||
## Accès
|
||||
|
||||
<p class="pres-actions">
|
||||
<a class="vp-button vp-raw" href="/slides/kubernetes-operators/1">Voir les slides</a>
|
||||
<a class="vp-button vp-button--alt vp-raw" href="/slides/kubernetes-operators/1" target="_blank" rel="noopener noreferrer">Ouvrir dans un nouvel onglet</a>
|
||||
</p>
|
||||
|
||||
## Partager les slides
|
||||
|
||||
Depuis la salle, scannez le code pour ouvrir les slides sur mobile.
|
||||
|
||||
<QrEncode path="/slides/kubernetes-operators/1" />
|
||||
|
||||
::: info À propos
|
||||
Les slides sont servies en SPA statique (Slidev). Les chemins d’assets suivent le `base` défini au build.
|
||||
:::
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
title: Spring AI
|
||||
outline: deep
|
||||
---
|
||||
|
||||
# Spring AI
|
||||
|
||||
**Tags :** `java` · `spring` · `llm` · `ai`
|
||||
|
||||
## Résumé
|
||||
|
||||
Intégrer des modèles LLM dans l’écosystème Spring avec des API cohérentes.
|
||||
|
||||
## Accès
|
||||
|
||||
<p class="pres-actions">
|
||||
<a class="vp-button vp-raw" href="/slides/spring-ai/1">Voir les slides</a>
|
||||
<a class="vp-button vp-button--alt vp-raw" href="/slides/spring-ai/1" target="_blank" rel="noopener noreferrer">Ouvrir dans un nouvel onglet</a>
|
||||
</p>
|
||||
|
||||
## Partager les slides
|
||||
|
||||
Depuis la salle, scannez le code pour ouvrir les slides sur mobile.
|
||||
|
||||
<QrEncode path="/slides/spring-ai/1" />
|
||||
|
||||
::: info À propos
|
||||
Les slides sont servies en SPA statique (Slidev). Les chemins d’assets suivent le `base` défini au build.
|
||||
:::
|
||||
@@ -1,2 +0,0 @@
|
||||
# Contexte minimal : seuls Dockerfile, nginx/ et refresh.sh sont nécessaires.
|
||||
# Le dépôt applicatif est cloné dans l'image (voir Dockerfile).
|
||||
+28
-35
@@ -1,44 +1,37 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# BuildKit / buildx : cache apk + permissions posées au build (moins de travail / capabilities au runtime).
|
||||
FROM nginx:alpine
|
||||
# Image statique : build npm (VitePress + Slidev) puis nginx non-root.
|
||||
FROM node:22-bookworm-slim AS builder
|
||||
WORKDIR /app
|
||||
|
||||
RUN --mount=type=cache,target=/var/cache/apk \
|
||||
apk add --no-cache git
|
||||
COPY package.json package-lock.json ./
|
||||
# npm 10 (image officielle) et npm 11 (lock généré localement) divergent sur la validation du lock ; `install` reste déterministe via package-lock.
|
||||
RUN npm install
|
||||
|
||||
WORKDIR /usr/share/nginx/html
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Image figée au clone ; le conteneur met à jour via refresh.sh (git pull origin main).
|
||||
# Seul le dossier content/ est extrait (sparse checkout) : léger et aligné sur la racine Nginx.
|
||||
# L’image nginx:alpine place déjà des fichiers ici — il faut vider le répertoire avant git clone vers « . ».
|
||||
ARG TALKS_REPO_URL=https://git.specificat.io/arnault/Talks.git
|
||||
ARG TALKS_BRANCH=main
|
||||
ARG TALKS_SPARSE_DIR=content
|
||||
# Même arborescence que tools/static-preview.mjs : portail à la racine, decks sous slides/<slug>/
|
||||
RUN set -eux \
|
||||
&& mkdir -p /opt/site \
|
||||
&& cp -a dist-portal/. /opt/site/ \
|
||||
&& for d in dist-*; do \
|
||||
[ -d "$d" ] || continue; \
|
||||
[ "$d" = "dist-portal" ] && continue; \
|
||||
slug="${d#dist-}"; \
|
||||
mkdir -p "/opt/site/slides/${slug}"; \
|
||||
cp -a "${d}/." "/opt/site/slides/${slug}/"; \
|
||||
done
|
||||
|
||||
RUN find . -mindepth 1 -delete \
|
||||
&& git clone --filter=blob:none --sparse --branch "${TALKS_BRANCH}" --single-branch "${TALKS_REPO_URL}" . \
|
||||
&& git sparse-checkout init --cone \
|
||||
&& git sparse-checkout set "${TALKS_SPARSE_DIR}" \
|
||||
&& git config --global --add safe.directory /usr/share/nginx/html
|
||||
FROM nginxinc/nginx-unprivileged:stable-alpine
|
||||
|
||||
COPY nginx/default.conf /etc/nginx/conf.d/default.conf
|
||||
COPY refresh.sh /refresh.sh
|
||||
ARG UID=101
|
||||
ARG GID=101
|
||||
|
||||
# Caches et logs : créés ici avec le même schéma que l’entrypoint nginx (évite le chown au démarrage).
|
||||
# Propriétaire nginx (cf. /etc/nginx/nginx.conf user) → l’entrypoint ne refait pas chown si tout est déjà cohérent.
|
||||
RUN chmod +x /refresh.sh \
|
||||
&& mkdir -p \
|
||||
/var/cache/nginx/client_temp \
|
||||
/var/cache/nginx/proxy_temp \
|
||||
/var/cache/nginx/fastcgi_temp \
|
||||
/var/cache/nginx/uwsgi_temp \
|
||||
/var/cache/nginx/scgi_temp \
|
||||
/var/log/nginx \
|
||||
&& chown -R nginx:nginx \
|
||||
/var/cache/nginx \
|
||||
/var/log/nginx \
|
||||
/usr/share/nginx/html
|
||||
USER root
|
||||
COPY --from=builder /opt/site /usr/share/nginx/html
|
||||
COPY server/nginx/default.conf /etc/nginx/conf.d/default.conf
|
||||
RUN chown -R "${UID}:${GID}" /usr/share/nginx/html
|
||||
|
||||
# Port non privilégié (pas de CAP_NET_BIND_SERVICE) ; le Service K8s mappe souvent 80 → 8080.
|
||||
USER ${UID}
|
||||
EXPOSE 8080
|
||||
|
||||
CMD sh -c "/refresh.sh & exec nginx -g 'daemon off;'"
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
server {
|
||||
listen 8080;
|
||||
server_name localhost;
|
||||
# Racine web = contenu statique sparse (dossier content/ du dépôt).
|
||||
root /usr/share/nginx/html/content;
|
||||
index index.html index.htm;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Portail VitePress (fichiers statiques + clean URLs)
|
||||
location / {
|
||||
add_header Cache-Control "no-cache";
|
||||
try_files $uri $uri/ =404;
|
||||
try_files $uri $uri/ $uri.html =404;
|
||||
}
|
||||
|
||||
# Slidev : /slides/<slug> ou /slides/<slug>/ → première planche (comme static-preview.mjs)
|
||||
# $http_host conserve le port client (reverse proxy, docker -p host:8080).
|
||||
location ~ ^/slides/([^/]+)/?$ {
|
||||
return 302 $scheme://$http_host/slides/$1/1$is_args$args;
|
||||
}
|
||||
|
||||
# Decks Slidev : assets et routes SPA (/1, /2, …)
|
||||
location ~ ^/slides/(?<slug>[^/]+)(?<rest>/.*)$ {
|
||||
try_files $uri $uri/ /slides/$slug/index.html;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Répertoire git : /usr/share/nginx/html (sparse checkout limité à content/).
|
||||
cd /usr/share/nginx/html || exit 1
|
||||
|
||||
while true; do
|
||||
echo "Pulling latest changes from origin/main (sparse content/)..."
|
||||
git pull origin main || echo "git pull failed, will retry in 300s"
|
||||
sleep 300
|
||||
done
|
||||
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Pied de page fixe pour tous les decks Slidev : retour portail + lien Specificat.
|
||||
* Lien « Accueil » en relatif ../../../ pour fonctionner avec --base …/slides/<slug>/
|
||||
* (ex. /slides/foo/12 → racine du site).
|
||||
*
|
||||
* Ne pas cibler .slidev-page en CSS global (padding, box-sizing, etc.) : Slidev centre
|
||||
* la planche avec transform + dimensions ; modifier cette boîte casse le centrage.
|
||||
*/
|
||||
const specificat = 'https://specificat.io/'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer class="deck-footer" aria-label="Navigation du portail">
|
||||
<a class="deck-footer__link" href="../../../">Accueil</a>
|
||||
<span class="deck-footer__sep" aria-hidden="true">·</span>
|
||||
<a
|
||||
class="deck-footer__link"
|
||||
:href="specificat"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>Specificat.io</a>
|
||||
<span class="deck-footer__sep" aria-hidden="true">·</span>
|
||||
<span class="deck-footer__rights">All rights reserved</span>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.deck-footer {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
/*
|
||||
* Slidev place la webcam sur .z-camera (= z-index 15, voir @slidev/client/uno.config.ts).
|
||||
* Un footer plus haut (ex. 30) passait au-dessus et masquait la miniature.
|
||||
*/
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35em 0.5em;
|
||||
padding: 0.35rem 2.5rem 0.55rem;
|
||||
font-size: clamp(10px, 1.5vw, 12px);
|
||||
line-height: 1.3;
|
||||
color: rgba(245, 245, 245, 0.75);
|
||||
pointer-events: none;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.07);
|
||||
background: linear-gradient(to top, rgba(5, 7, 9, 0.72), transparent);
|
||||
}
|
||||
|
||||
.deck-footer__link {
|
||||
pointer-events: auto;
|
||||
color: #2bc9c6;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.deck-footer__link:hover,
|
||||
.deck-footer__link:focus-visible {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.deck-footer__sep {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.deck-footer__rights {
|
||||
opacity: 0.85;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,11 @@
|
||||
/*
|
||||
* Reveal : center: true — le thème Slidev « default » aligne le bloc en haut-gauche.
|
||||
* Si une planche reste en layout default (cache, ancien bundle, etc.), on centre
|
||||
* le bloc comme le layout intégré « center ». Ne cible pas .slidev-layout.center.
|
||||
*/
|
||||
.slidev-layout.default {
|
||||
min-height: 100%;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import QRCode from 'qrcode'
|
||||
|
||||
const REPO = 'https://git.specificat.io/arnault/Talks'
|
||||
|
||||
const canvasRepo = ref<HTMLCanvasElement | null>(null)
|
||||
const canvasHome = ref<HTMLCanvasElement | null>(null)
|
||||
|
||||
/** Accueil portail : même logique que l’ancien Reveal (parent depuis /slides/<slug>/<n>). */
|
||||
function portalHomeHref(): string {
|
||||
if (typeof window === 'undefined') return 'https://specificat.io/'
|
||||
try {
|
||||
const u = new URL(window.location.href)
|
||||
if (u.pathname.includes('/slides/')) {
|
||||
u.pathname = '/'
|
||||
u.hash = ''
|
||||
return u.href
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return `${window.location.origin}/`
|
||||
}
|
||||
|
||||
const opts = {
|
||||
width: 150,
|
||||
margin: 1,
|
||||
color: { dark: '#0d1115ff', light: '#ffffffff' },
|
||||
}
|
||||
|
||||
async function draw() {
|
||||
if (canvasRepo.value)
|
||||
await QRCode.toCanvas(canvasRepo.value, REPO, opts)
|
||||
if (canvasHome.value)
|
||||
await QRCode.toCanvas(canvasHome.value, portalHomeHref(), opts)
|
||||
}
|
||||
|
||||
onMounted(draw)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="closing-qr" aria-label="QR codes dépôt et accueil">
|
||||
<figure class="closing-qr__block">
|
||||
<figcaption class="closing-qr__cap">Dépôt</figcaption>
|
||||
<div class="closing-qr__wrap">
|
||||
<canvas
|
||||
ref="canvasRepo"
|
||||
role="img"
|
||||
width="150"
|
||||
height="150"
|
||||
aria-label="QR code vers le dépôt Git"
|
||||
/>
|
||||
</div>
|
||||
</figure>
|
||||
<figure class="closing-qr__block">
|
||||
<figcaption class="closing-qr__cap">Slides — accueil</figcaption>
|
||||
<div class="closing-qr__wrap">
|
||||
<canvas
|
||||
ref="canvasHome"
|
||||
role="img"
|
||||
width="150"
|
||||
height="150"
|
||||
aria-label="QR code vers l’accueil du portail"
|
||||
/>
|
||||
</div>
|
||||
</figure>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.closing-qr {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
align-items: flex-end;
|
||||
gap: 1.75rem;
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
|
||||
.closing-qr__block {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.closing-qr__cap {
|
||||
margin: 0 0 0.35rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: rgba(245, 245, 245, 0.85);
|
||||
}
|
||||
|
||||
.closing-qr__wrap {
|
||||
display: inline-block;
|
||||
padding: 0.2em;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
line-height: 0;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.closing-qr__wrap canvas {
|
||||
display: block;
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import DeckFooter from '../_shared/DeckFooter.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DeckFooter />
|
||||
</template>
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"slug": "kubernetes-operators",
|
||||
"title": "Kubernetes Operators — De l’enfer au paradis",
|
||||
"description": "Mental model (API, etcd, boucle), controllers et opérateurs, exemples concrets (Reflector, Reloader, cert-manager, Postgres) et pièges à éviter.",
|
||||
"tags": ["kubernetes", "operators", "devops", "controllers"],
|
||||
"order": 1,
|
||||
"featured": true
|
||||
}
|
||||
@@ -0,0 +1,579 @@
|
||||
---
|
||||
theme: default
|
||||
# Par défaut Slidev : record: "dev" → pas d’UI caméra/enregistrement dans `slidev build`. true = inclus en prod.
|
||||
record: true
|
||||
title: Kubernetes Operators — De l'enfer au paradis
|
||||
info: |
|
||||
Mental model Kubernetes, boucles de contrôle, opérateurs, exemples et pièges.
|
||||
highlighter: shiki
|
||||
# Comme Reveal « center: true » : planche centrée (layout intégré Slidev).
|
||||
defaults:
|
||||
layout: center
|
||||
---
|
||||
|
||||
# Kubernetes Operators — De l'enfer… au paradis
|
||||
|
||||
> Kubernetes n'est **pas** compliqué.
|
||||
> Mais **mal compris**… c'est un enfer.
|
||||
|
||||
<!--
|
||||
On parle surtout de pourquoi les opérateurs changent la manière de travailler — puissants mais dangereux si le mental model est faux.
|
||||
-->
|
||||
|
||||
---
|
||||
|
||||
## Kubernetes : délire d'Ops ?
|
||||
|
||||
> On nous l'a surtout vendu comme un truc d'infra.
|
||||
> *Moi aussi, je suis passé par ce réflexe.*
|
||||
|
||||
- « Trop complexe »
|
||||
- Réservé aux spécialistes plateforme
|
||||
- Magie noire / pas « mon » métier
|
||||
|
||||
<!--
|
||||
Souvent présenté comme réservé aux spécialistes — en réalité proche de ce qu'on fait déjà en dev.
|
||||
-->
|
||||
|
||||
---
|
||||
|
||||
## Kubernetes c'est un délire de dev
|
||||
|
||||
> Kubernetes c'est un délire de dev
|
||||
|
||||
<!--
|
||||
Transition : contraste avec la slide précédente.
|
||||
-->
|
||||
|
||||
---
|
||||
|
||||
## Kubernetes = API + état + boucle
|
||||
|
||||
- API REST
|
||||
- Base d'état (etcd)
|
||||
- Boucle de réconciliation → events
|
||||
- Pas de mystère → mécanismes explicites (controllers, kubelet, containerd, etc.)
|
||||
|
||||
<!--
|
||||
Pas de magie — des patterns qu'on retrouve ailleurs en dev.
|
||||
-->
|
||||
|
||||
---
|
||||
|
||||
## De vos outils au noyau Linux
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
B[kube-api]
|
||||
subgraph Clients["Clients"]
|
||||
A01[kubectl]
|
||||
A02[Helm]
|
||||
A03[Kustomize]
|
||||
end
|
||||
A01 --> B
|
||||
A02 --> B
|
||||
A03 --> B
|
||||
B --> C[(etcd)]
|
||||
B --> D[Controllers]
|
||||
D --> B
|
||||
D --> E[kubelet]
|
||||
E --> F[containerd]
|
||||
F --> G[Kernel Linux]
|
||||
```
|
||||
|
||||
- **kube-api** — point d'entrée
|
||||
- **etcd** — source de vérité
|
||||
- **kubelet** — agent sur chaque nœud
|
||||
- **container runtime** (ex. containerd) — exécution des conteneurs
|
||||
|
||||
<!--
|
||||
Helm, kubectl, Flux… appellent l'API. L'API parle à etcd, les controllers poussent vers les kubelets, le runtime lance les conteneurs.
|
||||
-->
|
||||
|
||||
---
|
||||
|
||||
## Kubernetes ne fait pas tourner vos apps : la boucle de contrôle
|
||||
|
||||
Il **orchestre** · il **délègue** · il **observe**
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
DS["Spec · désir"] --> AP[kube-apiserver]
|
||||
AP <-->|objets cluster| ET[(etcd)]
|
||||
AP --> EV["Watch · événements"]
|
||||
EV --> CTRL[Controllers]
|
||||
CTRL -->|réconciliation · requêtes API| AP
|
||||
subgraph NODE["Nœud"]
|
||||
K[kubelet] -->|CRI| CD[containerd]
|
||||
end
|
||||
AP -->|spec Pods · synchro| K
|
||||
K -->|status| AP
|
||||
```
|
||||
|
||||
**↻** Les changements d'objets vivent dans l'API ; le **kubelet** **synchronise** depuis l'API, traduit en **CRI** vers **containerd**. Le **status** remonte par le kubelet.
|
||||
|
||||
<!--
|
||||
Aligné sur la doc Kubernetes : observer, rapprocher le courant du spec ; informers / watches → files d'événements. etcd derrière l'API.
|
||||
-->
|
||||
|
||||
---
|
||||
|
||||
## Sous containerd : cgroups et namespaces
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
F[containerd] --> K[Linux Kernel]
|
||||
```
|
||||
|
||||
- **cgroups**
|
||||
- **namespaces**
|
||||
|
||||
<!--
|
||||
Derrière containerd, le noyau — vous ne voulez pas manipuler les fichiers à la main au quotidien.
|
||||
-->
|
||||
|
||||
---
|
||||
|
||||
## Kubernetes vous évite de vivre dans le noyau
|
||||
|
||||
- Manipuler les **cgroups** à la main
|
||||
- Gérer **isolation**, **CPU**, **mémoire** sur vos nœuds
|
||||
|
||||
> À côté de ça, un cluster qui râle, c'est presque du repos.
|
||||
|
||||
---
|
||||
|
||||
## CNI, CSI, CRI : de l'infra branchable
|
||||
|
||||
- **CNI** → réseau (ex. Istio, Cilium, Calico, Flannel…)
|
||||
- **CSI** → stockage (ex. Ceph, Longhorn, Amazon…)
|
||||
- **CRI** → runtime (ex. containerd, CRI-O…)
|
||||
- Ici : brancher l'infra — pas encoder la logique métier (*≠* opérateur)
|
||||
|
||||
---
|
||||
|
||||
## Brancher l'infra ou coder le métier ?
|
||||
|
||||
| Type | Rôle |
|
||||
|------|------|
|
||||
| **CNI / CSI / CRI** | Infrastructure |
|
||||
| **Opérateur** | Logique métier |
|
||||
|
||||
<!--
|
||||
D'un côté briques branchables ; de l'autre du code qui encode le comportement dans le cluster. Icônes : icônes K8s officielles, CNCF Landscape, Simple Icons.
|
||||
-->
|
||||
|
||||
---
|
||||
|
||||
## Qui écrit la logique du cluster ?
|
||||
|
||||
<!--
|
||||
Qui décide quand l'état réel diverge ? (pause)
|
||||
-->
|
||||
|
||||
---
|
||||
|
||||
## La logique vit dans les controllers
|
||||
|
||||
*Ils ferment la boucle : observation, décision, action.*
|
||||
|
||||
<!--
|
||||
L'intelligence du cluster est dans les controllers.
|
||||
-->
|
||||
|
||||
---
|
||||
|
||||
## Les opérateurs : des controllers spécialisés
|
||||
|
||||
<!--
|
||||
Même mécanisme, focalisé sur un domaine métier ou un logiciel (base, queue…).
|
||||
-->
|
||||
|
||||
---
|
||||
|
||||
## Sans opérateur : scripts, crons, humains dans la boucle
|
||||
|
||||
- Scripts bash
|
||||
- Cron jobs
|
||||
- Interventions humaines
|
||||
- État incohérent
|
||||
|
||||
---
|
||||
|
||||
## Automatisation éclatée : la logique reste dans les têtes
|
||||
|
||||
- Runbooks Confluence
|
||||
- Scripts bash « magiques »
|
||||
- Cron jobs oubliés
|
||||
- Interventions humaines
|
||||
|
||||
**La logique est dans la tête des gens, pas dans le système.**
|
||||
|
||||
---
|
||||
|
||||
## Drift, pas de reproductibilité, 3 h du matin
|
||||
|
||||
- Drift de configuration
|
||||
- Actions non reproductibles
|
||||
- Dépendance aux humains
|
||||
- Incidents à 3 h du matin
|
||||
|
||||
- Pas **reproductible** / pas **observable** cluster / pas **fiable** dans le temps
|
||||
- Impossible de **raisonner globalement** sur l'état
|
||||
|
||||
---
|
||||
|
||||
## Vous déclarez ; le système réconcilie
|
||||
|
||||
```yaml
|
||||
kind: Database
|
||||
spec:
|
||||
size: 3
|
||||
```
|
||||
|
||||
<!--
|
||||
État voulu → le contrôleur fait les transitions répétitives.
|
||||
-->
|
||||
|
||||
---
|
||||
|
||||
## Spec, opérateur, cluster : ça tourne en boucle
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[Spec] --> B[Operator]
|
||||
B --> C[Cluster]
|
||||
C --> B
|
||||
```
|
||||
|
||||
*Dès que le réel diverge, la réconciliation repart.*
|
||||
|
||||
<!--
|
||||
Exemples : annotations sur ressources natives, puis vrais CRD ; projet maison pour ancrer le discours.
|
||||
-->
|
||||
|
||||
---
|
||||
|
||||
## Reflector & Reloader : la puissance des annotations
|
||||
|
||||
| Outil | Idée | Déclencheur typique |
|
||||
|-------|------|---------------------|
|
||||
| **Reflector** (EmberStack) | Copie / réplique **Secrets** et **ConfigMaps** (autres ns, miroirs) | Annotations sur la ressource source ou la copie |
|
||||
| **Reloader** (Stakater) | **Redémarre** les Pods si Secret / ConfigMap référencé change | Annotation sur **Deployment** / **StatefulSet** / etc. |
|
||||
|
||||
- Reflector : `reflector.v1.k8s.emberstack.com/*`
|
||||
- Reloader : `reloader.stakater.com/auto: "true"`
|
||||
|
||||
<!--
|
||||
Pas toujours un gros opérateur CRD — parfois contrôleur sur objets standards + annotations. Liens : kubernetes-reflector, Reloader sur GitHub.
|
||||
-->
|
||||
|
||||
---
|
||||
|
||||
## Exemple Reflector : secret DB → namespace applicatif
|
||||
|
||||
*Le secret « source de vérité » vit avec la base ; Reflector recrée un miroir là où l'app tourne.*
|
||||
|
||||
```yaml
|
||||
# Namespace database — secret maître
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: db-credentials
|
||||
namespace: database
|
||||
annotations:
|
||||
reflector.v1.k8s.emberstack.com/reflection-allowed: "true"
|
||||
reflector.v1.k8s.emberstack.com/reflection-auto-enabled: "true"
|
||||
reflector.v1.k8s.emberstack.com/reflection-auto-namespaces: "app-shop"
|
||||
stringData:
|
||||
DATABASE_URL: "postgres://…"
|
||||
```
|
||||
|
||||
- Reflector maintient un **Secret du même nom** dans `app-shop`, à jour quand la source change.
|
||||
- Alternative : `reflector.v1.k8s.emberstack.com/reflects: "database/db-credentials"`
|
||||
|
||||
---
|
||||
|
||||
## Exemple Reloader : rollout quand le secret miroir change
|
||||
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: shop-api
|
||||
namespace: app-shop
|
||||
annotations:
|
||||
reloader.stakater.com/auto: "true"
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: api
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: db-credentials
|
||||
```
|
||||
|
||||
- Rotation dans `database/db-credentials` → Reflector met à jour `app-shop/db-credentials` → Reloader déclenche un **rollout**.
|
||||
- Sans Reloader, les Pods gardent l'ancienne valeur en mémoire.
|
||||
|
||||
---
|
||||
|
||||
## Cert-manager, Postgres, Keycloak : la spec devient une entité
|
||||
|
||||
| Opérateur | CRD (exemples) | Ce que ça matérialise |
|
||||
|-----------|----------------|----------------------|
|
||||
| **cert-manager** | `Certificate`, `Issuer`, `ClusterIssuer` | Secrets TLS, ACME / PKI |
|
||||
| **Postgres Operator** (Zalando) | `postgresql` (`acid.zalan.do`) | Cluster PG (Patroni, volumes, users…) |
|
||||
| **Keycloak Operator** | `Keycloak`, `KeycloakRealm`, `KeycloakClient`… | Instances, royaumes, clients |
|
||||
|
||||
La spec décrit l'état métier ; le contrôleur crée / met à jour Deployments, Services, Secrets…
|
||||
|
||||
<!--
|
||||
Trois domaines, même mécanisme CRD + réconciliation. Liens cert-manager.io, Zalando postgres-operator, Keycloak operator.
|
||||
-->
|
||||
|
||||
---
|
||||
|
||||
## Un certificat déclaratif, tout le cycle TLS en dessous
|
||||
|
||||
```yaml
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: Certificate
|
||||
metadata:
|
||||
name: api-example
|
||||
spec:
|
||||
dnsNames:
|
||||
- api.example.com
|
||||
```
|
||||
|
||||
Suite dérivée automatiquement : `CertificateRequest`, challenge ACME, Secret TLS.
|
||||
|
||||
---
|
||||
|
||||
## Certificats : du Certbot au renouvellement automatique
|
||||
|
||||
*Avant — sans opérateur*
|
||||
|
||||
- Certbot manuel
|
||||
- Scripts cron
|
||||
- Cert expiré = prod down
|
||||
|
||||
*Avec cert-manager*
|
||||
|
||||
- Déclaratif
|
||||
- Auto-renouvellement
|
||||
- Observabilité native (`Certificate`, conditions, events)
|
||||
|
||||
<!--
|
||||
Complexité énorme encapsulée — pertinent quand le domaine est clair, répétitif et critique.
|
||||
-->
|
||||
|
||||
---
|
||||
|
||||
## Postgres répliqué sans bricoler le failover à la main
|
||||
|
||||
```yaml
|
||||
apiVersion: acid.zalan.do/v1
|
||||
kind: postgresql
|
||||
metadata:
|
||||
name: my-cluster
|
||||
spec:
|
||||
numberOfInstances: 3
|
||||
```
|
||||
|
||||
Derrière : réplication, failover, backup, users / rôles.
|
||||
|
||||
---
|
||||
|
||||
## Quelle techno pour développer votre opérateur ?
|
||||
|
||||
| Piste | Outils typiques | À retenir |
|
||||
|-------|-----------------|-----------|
|
||||
| **Go** (référence) | **Kubebuilder**, `controller-runtime`, client-go | Même patterns upstream ; sous charge : goroutines, files, back-off — peu de RAM en plus. |
|
||||
| **Operator SDK** | CLI Red Hat · Go · Ansible / Helm | Go solide ; Ansible/Helm = wrapper — court si la logique métier épaissit. |
|
||||
| **Autres** | Python **Kopf**, Rust **kube-rs**, Java… | Selon l'équipe ; moins d'exemples core. |
|
||||
|
||||
**Go + controller-runtime** pour un opérateur maison sérieux ; sinon celui que l'équipe **maintient** le mieux.
|
||||
|
||||
*Charge ↑ : un runtime qui gonfle la RAM par événement mange votre quota.*
|
||||
|
||||
---
|
||||
|
||||
## Quand un opérateur est légitime
|
||||
|
||||
- Système **complexe**
|
||||
- **Règles** claires et stables
|
||||
- **État** critique pour l'activité
|
||||
- Opérations **répétitives**
|
||||
|
||||
<!--
|
||||
Si tu ne peux pas formaliser « si X alors Y » proprement — très mauvaise idée en opérateur.
|
||||
-->
|
||||
|
||||
---
|
||||
|
||||
## Keycloak Credential Manager — le même schéma, chez moi
|
||||
|
||||
- Objectif : **réconcilier** des **credentials Keycloak** avec ce qui est **déclaré dans le cluster** (CRD, Secrets annotés…).
|
||||
- Montrer le **même schéma** : *observe → compare → corrige* sur **ton** domaine.
|
||||
- **Incident / compromission** : reload Keycloak → événements côté IdP ; l'opérateur **interroge** (ex. toutes les minutes), repère l'écart et **propage** la correction.
|
||||
|
||||
<!--
|
||||
Pas pour vendre un produit — prouver que la logique est la même : API K8s, état désiré, code à la place du runbook.
|
||||
-->
|
||||
|
||||
---
|
||||
|
||||
## Exemple — `KeycloakClientCredential`
|
||||
|
||||
*CRD* `secrets.carbogame.com/v1alpha1` — `spec.source` (realm, client) → `spec.target` (Secret, rafraîchissement, mapping).
|
||||
|
||||
```yaml
|
||||
apiVersion: secrets.carbogame.com/v1alpha1
|
||||
kind: KeycloakClientCredential
|
||||
metadata:
|
||||
name: gitea-creds
|
||||
namespace: keycloak
|
||||
spec:
|
||||
source:
|
||||
keycloakServer: prod-keycloak
|
||||
realm: operator
|
||||
clientId: gitea
|
||||
target:
|
||||
secretName: gitea-secret-creds
|
||||
namespace:
|
||||
- gitea
|
||||
refreshPeriod: 10m
|
||||
fields:
|
||||
clientId: key
|
||||
clientSecret: secret
|
||||
```
|
||||
|
||||
*En chart Helm, `namespace` et `keycloakServer` sont souvent injectés via templates.*
|
||||
|
||||
---
|
||||
|
||||
## Développer son opérateur : réutiliser les autres
|
||||
|
||||
- **Ne pas tout recoder** : s'appuyer sur des contrôleurs existants pour ce qu'ils font déjà bien.
|
||||
- Ex. **Secrets / ConfigMaps** vers d'autres namespaces : brancher **Reflector** plutôt que dupliquer la logique — une politique de réflexion, **un RBAC** à raisonner.
|
||||
- Votre code se concentre sur **le métier**.
|
||||
|
||||
---
|
||||
|
||||
## Ce qu'un opérateur bien choisi vous apporte
|
||||
|
||||
- Automatiser du **répétitif**
|
||||
- **Encapsuler** de la complexité
|
||||
- **Stabiliser** un système
|
||||
|
||||
… mais les opérateurs peuvent aussi devenir un enfer.
|
||||
|
||||
---
|
||||
|
||||
## Quand ne pas écrire d'opérateur
|
||||
|
||||
- Logique **floue** ou en perpétuelle négociation métier
|
||||
- Processus **instables**
|
||||
- Dépendances **externes** imprévisibles
|
||||
- **One-shot** sans cycle de vie
|
||||
|
||||
---
|
||||
|
||||
## Cinq signaux qu'un opérateur part en vrille
|
||||
|
||||
- Spec **incompréhensible** sans lire le code
|
||||
- Effets **implicites** (créations ailleurs invisibles)
|
||||
- **Couplage** externe fort
|
||||
- **Debug** impossible sans les mainteneurs
|
||||
- **État** non observable (pas de conditions / status utiles)
|
||||
|
||||
> Un opérateur mal conçu, c'est un microservice dont plus personne ne tient les règles du jeu.
|
||||
|
||||
---
|
||||
|
||||
## Débugger un opérateur : describe, events, status, logs
|
||||
|
||||
- `kubectl describe` (resource + events)
|
||||
- **Events** du namespace
|
||||
- **Status / conditions** sur la CRD
|
||||
- **Logs** du contrôleur
|
||||
|
||||
---
|
||||
|
||||
## Un opérateur, c'est un produit logiciel — pas un script
|
||||
|
||||
- **Code** et revues
|
||||
- **Tests** (unitaires, intégration, e2e si possible)
|
||||
- **Maintenance** dans la durée
|
||||
- **Compatibilité** avec les versions Kubernetes
|
||||
- **Migrations** de CRD / spec
|
||||
|
||||
---
|
||||
|
||||
## Quand l'opérateur devient l'enfer
|
||||
|
||||
- Logique **implicite**, **effets de bord**, **debugging** difficile, **couplage** fort
|
||||
|
||||
En pratique :
|
||||
|
||||
- Tout mettre en opérateur
|
||||
- Logique métier **floue**
|
||||
- Effets **cachés**
|
||||
|
||||
---
|
||||
|
||||
## Kubernetes, plateforme de programmation
|
||||
|
||||
- Kubernetes évite **l'enfer de l'infrastructure** bas niveau.
|
||||
- Les **bons** opérateurs évitent **l'enfer du manuel** — les **mauvais** y renvoient autrement.
|
||||
|
||||
<!--
|
||||
Fil : pas magique → API + boucle → controllers / opérateurs → avant/après → exemples → bon usage / pièges → conclusion.
|
||||
-->
|
||||
|
||||
---
|
||||
|
||||
## Avant d'écrire un opérateur : la checklist
|
||||
|
||||
- Répétitif
|
||||
- Règles claires
|
||||
- Déclaratif possible
|
||||
- Idempotent
|
||||
- Observable
|
||||
|
||||
Si une case importante manque : **reculez**.
|
||||
|
||||
---
|
||||
|
||||
## Votre logique, exécutée par le cluster au lieu d'un humain
|
||||
|
||||
- « OK, je comprends enfin le cadre Kubernetes. »
|
||||
- « Les opérateurs : puissants, à manier avec précision. »
|
||||
|
||||
Un opérateur, c'est votre logique métier — **lisible**, **testable**, **bornée**.
|
||||
|
||||
---
|
||||
|
||||
## Kubernetes simplifie l'infra — les opérateurs, ça peut simplifier ou empirer
|
||||
|
||||
Les **bons** **encapsulent** une complexité maîtrisée ; les **mauvais** recréent un enfer **plus sophistiqué**.
|
||||
|
||||
---
|
||||
|
||||
## Merci
|
||||
|
||||
Merci pour votre attention.
|
||||
|
||||
**Des questions ?**
|
||||
|
||||
<ClosingQr />
|
||||
|
||||
- Dépôt : [git.specificat.io/arnault/Talks](https://git.specificat.io/arnault/Talks)
|
||||
- Site : [specificat.io](https://specificat.io/)
|
||||
- Index des slides : portail talks (page d'accueil des présentations)
|
||||
|
||||
<!--
|
||||
Laisser le temps pour les questions ; rappeler l'URL du dépôt à l'oral.
|
||||
-->
|
||||
@@ -0,0 +1 @@
|
||||
@import "../_shared/center-default-like-reveal.css";
|
||||
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import DeckFooter from '../_shared/DeckFooter.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DeckFooter />
|
||||
</template>
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"slug": "spring-ai",
|
||||
"title": "Spring AI",
|
||||
"description": "Intégrer des modèles LLM dans l’écosystème Spring avec des API cohérentes.",
|
||||
"tags": ["java", "spring", "llm", "ai"],
|
||||
"order": 2,
|
||||
"featured": true
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
theme: default
|
||||
record: true
|
||||
title: Spring AI
|
||||
info: |
|
||||
Démo technique — portail talks
|
||||
highlighter: shiki
|
||||
defaults:
|
||||
layout: center
|
||||
---
|
||||
|
||||
# Spring AI
|
||||
|
||||
Intégration **LLM** dans une stack Java familière.
|
||||
|
||||
---
|
||||
|
||||
## Objectifs
|
||||
|
||||
- Abstractions stables (prompts, chat, embeddings)
|
||||
- Choix de backends (OpenAI, Ollama, etc.)
|
||||
- Testabilité et observabilité
|
||||
|
||||
---
|
||||
|
||||
## Exemple de flux
|
||||
|
||||
```text
|
||||
Controller → ChatClient → Model (provider)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Bonnes pratiques
|
||||
|
||||
- Isoler les prompts et versions
|
||||
- Mesurer latence et coûts
|
||||
- Prévoir reprise et timeouts
|
||||
|
||||
---
|
||||
|
||||
## Merci
|
||||
|
||||
[Documentation Spring AI](https://docs.spring.io/spring-ai/reference/)
|
||||
@@ -0,0 +1 @@
|
||||
@import "../_shared/center-default-like-reveal.css";
|
||||
@@ -24,15 +24,14 @@ spec:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
securityContext:
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
{{- toYaml .Values.slides.podSecurityContext | nindent 8 }}
|
||||
containers:
|
||||
- name: nginx
|
||||
image: "{{ .Values.slides.image.repository }}:{{ .Values.slides.image.tag | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: {{ .Values.slides.image.pullPolicy }}
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
# Port 8080 dans le conteneur : pas besoin de NET_BIND_SERVICE (ports privilégiés).
|
||||
# Image : USER nginx + master non-root → pas de SETUID/SETGID ; port 8080 → pas de NET_BIND_SERVICE.
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
readOnlyRootFilesystem: false
|
||||
|
||||
@@ -5,7 +5,7 @@ fullnameOverride: ""
|
||||
|
||||
slides:
|
||||
image:
|
||||
repository: git.specificat.io/specificat.io/talks-slides
|
||||
repository: git.specificat.io/arnault/talks-slides
|
||||
tag: "latest"
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
@@ -17,6 +17,14 @@ slides:
|
||||
# Port d’écoute dans le conteneur (doit correspondre à server/nginx/default.conf, ex. 8080).
|
||||
containerPort: 8080
|
||||
|
||||
# Aligné sur l’utilisateur nginx de l’image (UID/GID 101). L’image utilise USER nginx.
|
||||
podSecurityContext:
|
||||
runAsUser: 101
|
||||
runAsGroup: 101
|
||||
runAsNonRoot: true
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
|
||||
service:
|
||||
type: ClusterIP
|
||||
# Port du Service (Ingress pointe ici) ; le trafic est envoyé vers containerPort sur les pods.
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Après npm run build : dist-portal + dist-<slug>/ à la racine.
|
||||
*/
|
||||
import { test, describe } from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const repoRoot = path.resolve(__dirname, '..')
|
||||
const portal = path.join(repoRoot, 'dist-portal')
|
||||
const hasPortal = fs.existsSync(portal)
|
||||
|
||||
describe('Artefacts build (dist-portal + dist-<slug>)', () => {
|
||||
test('portail + chaque deck', { skip: !hasPortal }, () => {
|
||||
assert.ok(fs.existsSync(path.join(portal, 'index.html')))
|
||||
assert.ok(fs.existsSync(path.join(portal, 'presentations', 'index.html')))
|
||||
|
||||
const data = JSON.parse(
|
||||
fs.readFileSync(path.join(repoRoot, 'portal', '.vitepress', 'data', 'presentations.json'), 'utf8'),
|
||||
)
|
||||
for (const p of data) {
|
||||
const slideRoot = path.join(repoRoot, `dist-${p.slug}`)
|
||||
const slideIndex = path.join(slideRoot, 'index.html')
|
||||
assert.ok(fs.existsSync(slideIndex), `deck manquant: dist-${p.slug}/index.html`)
|
||||
const presIndex = path.join(portal, 'presentations', p.slug, 'index.html')
|
||||
assert.ok(fs.existsSync(presIndex), `fiche manquante: dist-portal/presentations/${p.slug}/index.html`)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,146 @@
|
||||
import { test, describe } from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import {
|
||||
escHtml,
|
||||
escMd,
|
||||
pickFeatured,
|
||||
buildFeaturedHtml,
|
||||
readAllMeta,
|
||||
generateListPageMarkdown,
|
||||
generateDetailPageMarkdown,
|
||||
patchIndexFeaturedContent,
|
||||
FEATURED_START,
|
||||
FEATURED_END,
|
||||
} from '../tools/presentation-contracts.mjs'
|
||||
|
||||
function mockSitePath(p) {
|
||||
return p.startsWith('/') ? p : `/${p}`
|
||||
}
|
||||
|
||||
describe('escHtml / escMd', () => {
|
||||
test('escHtml neutralise < > & "', () => {
|
||||
assert.strictEqual(escHtml('<a href="x">'), '<a href="x">')
|
||||
})
|
||||
|
||||
test('escMd échappe les pipes tableaux markdown', () => {
|
||||
assert.strictEqual(escMd('a|b'), 'a\\|b')
|
||||
})
|
||||
})
|
||||
|
||||
describe('pickFeatured', () => {
|
||||
test('priorité aux entrées featured puis order', () => {
|
||||
const items = [
|
||||
{ slug: 'c', title: 'C', order: 3, featured: false },
|
||||
{ slug: 'a', title: 'A', order: 1, featured: true },
|
||||
{ slug: 'b', title: 'B', order: 2, featured: true },
|
||||
]
|
||||
const got = pickFeatured(items)
|
||||
assert.deepStrictEqual(
|
||||
got.map((x) => x.slug),
|
||||
['a', 'b'],
|
||||
)
|
||||
})
|
||||
|
||||
test('sans featured : trois premiers par order', () => {
|
||||
const items = [
|
||||
{ slug: 'z', title: 'Z', order: 10 },
|
||||
{ slug: 'a', title: 'A', order: 1 },
|
||||
{ slug: 'b', title: 'B', order: 2 },
|
||||
{ slug: 'c', title: 'C', order: 3 },
|
||||
]
|
||||
const got = pickFeatured(items)
|
||||
assert.deepStrictEqual(
|
||||
got.map((x) => x.slug),
|
||||
['a', 'b', 'c'],
|
||||
)
|
||||
})
|
||||
|
||||
test('liste vide', () => {
|
||||
assert.deepStrictEqual(pickFeatured([]), [])
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildFeaturedHtml', () => {
|
||||
test('génère des liens et échappe le titre', () => {
|
||||
const html = buildFeaturedHtml(
|
||||
[{ slug: 'x', title: '<evil>', description: 'd & co', order: 1 }],
|
||||
mockSitePath,
|
||||
)
|
||||
assert.match(html, /href="\/presentations\/x\/"/)
|
||||
assert.match(html, /<evil>/)
|
||||
assert.match(html, /d & co/)
|
||||
})
|
||||
|
||||
test('aucune présentation : commentaire', () => {
|
||||
assert.strictEqual(buildFeaturedHtml([], mockSitePath), '<!-- (aucune présentation) -->')
|
||||
})
|
||||
})
|
||||
|
||||
describe('readAllMeta (répertoire temporaire)', () => {
|
||||
test('ignore dossier sans meta.json', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'talks-slides-'))
|
||||
fs.mkdirSync(path.join(dir, 'empty'), { recursive: true })
|
||||
const okDir = path.join(dir, 'ok')
|
||||
fs.mkdirSync(okDir, { recursive: true })
|
||||
fs.writeFileSync(path.join(okDir, 'meta.json'), JSON.stringify({ title: 'T', description: '', order: 1 }))
|
||||
const list = readAllMeta(dir)
|
||||
assert.strictEqual(list.length, 1)
|
||||
assert.strictEqual(list[0].slug, 'ok')
|
||||
assert.strictEqual(list[0].title, 'T')
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateListPageMarkdown / generateDetailPageMarkdown', () => {
|
||||
const p = {
|
||||
slug: 'demo',
|
||||
title: 'Démo',
|
||||
description: 'Desc',
|
||||
tags: ['a', 'b'],
|
||||
order: 1,
|
||||
}
|
||||
|
||||
test('liste contient fiche + slides + tags', () => {
|
||||
const md = generateListPageMarkdown([p], mockSitePath)
|
||||
assert.match(md, /href="\/presentations\/demo\/"/)
|
||||
assert.match(md, /href="\/slides\/demo\/1"/)
|
||||
assert.match(md, /vp-raw/)
|
||||
assert.match(md, /`a` · `b`/)
|
||||
})
|
||||
|
||||
test('détail contient QrEncode et boutons', () => {
|
||||
const md = generateDetailPageMarkdown(p, mockSitePath)
|
||||
assert.match(md, /<QrEncode path="\/slides\/demo\/1" \/>/)
|
||||
assert.match(md, /class="vp-button vp-raw"/)
|
||||
assert.match(md, /Voir les slides/)
|
||||
assert.match(md, /## Partager les slides/)
|
||||
})
|
||||
|
||||
test('détail sans tags : pas de ligne Tags', () => {
|
||||
const md = generateDetailPageMarkdown({ ...p, tags: [] }, mockSitePath)
|
||||
assert.ok(!md.includes('**Tags :**'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('patchIndexFeaturedContent', () => {
|
||||
test('remplace la zone entre marqueurs', () => {
|
||||
const raw = `head\n${FEATURED_START}\nold\n${FEATURED_END}\ntail`
|
||||
const { ok, content } = patchIndexFeaturedContent(
|
||||
raw,
|
||||
[{ slug: 's', title: 'T', description: 'D', order: 1 }],
|
||||
mockSitePath,
|
||||
)
|
||||
assert.strictEqual(ok, true)
|
||||
assert.ok(content.includes('talk-card'))
|
||||
assert.ok(content.includes('head'))
|
||||
assert.ok(content.includes('tail'))
|
||||
assert.ok(!content.includes('old'))
|
||||
})
|
||||
|
||||
test('marqueurs absents : ok false', () => {
|
||||
const { ok } = patchIndexFeaturedContent('nope', [], mockSitePath)
|
||||
assert.strictEqual(ok, false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Invariant du dépôt : chaque slides/<slug>/meta.json a une entrée et des fichiers générés.
|
||||
* Exécuter après `npm run sync` (la CI lance sync avant les tests).
|
||||
*/
|
||||
import { test, describe, before } from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const repoRoot = path.resolve(__dirname, '..')
|
||||
|
||||
before(() => {
|
||||
const r = spawnSync(process.execPath, [path.join(repoRoot, 'tools', 'sync-portal.mjs')], {
|
||||
cwd: repoRoot,
|
||||
encoding: 'utf8',
|
||||
})
|
||||
assert.strictEqual(r.status, 0, r.stderr || r.stdout)
|
||||
})
|
||||
|
||||
describe('Cohérence slides ↔ portail (après sync)', () => {
|
||||
test('presentations.json existe et liste les dossiers slides', () => {
|
||||
const jsonPath = path.join(repoRoot, 'portal', '.vitepress', 'data', 'presentations.json')
|
||||
assert.ok(fs.existsSync(jsonPath), 'presentations.json manquant')
|
||||
const data = JSON.parse(fs.readFileSync(jsonPath, 'utf8'))
|
||||
assert.ok(Array.isArray(data))
|
||||
assert.ok(data.length >= 1, 'au moins une présentation attendue dans ce dépôt')
|
||||
|
||||
const slideDirs = fs
|
||||
.readdirSync(path.join(repoRoot, 'slides'), { withFileTypes: true })
|
||||
.filter((d) => d.isDirectory() && !d.name.startsWith('_'))
|
||||
.map((d) => d.name)
|
||||
.filter((name) => fs.existsSync(path.join(repoRoot, 'slides', name, 'meta.json')))
|
||||
|
||||
assert.strictEqual(
|
||||
data.length,
|
||||
slideDirs.length,
|
||||
'nombre d’entrées JSON = nombre de dossiers slides avec meta.json',
|
||||
)
|
||||
|
||||
const slugs = new Set(data.map((p) => p.slug))
|
||||
for (const dir of slideDirs) {
|
||||
assert.ok(slugs.has(dir), `slug manquant pour le dossier slides/${dir}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('chaque entrée a title, description, slug', () => {
|
||||
const jsonPath = path.join(repoRoot, 'portal', '.vitepress', 'data', 'presentations.json')
|
||||
const data = JSON.parse(fs.readFileSync(jsonPath, 'utf8'))
|
||||
for (const p of data) {
|
||||
assert.ok(typeof p.title === 'string' && p.title.length > 0, `title requis: ${p.slug}`)
|
||||
assert.ok(typeof p.description === 'string', `description requise: ${p.slug}`)
|
||||
assert.ok(typeof p.slug === 'string' && p.slug.length > 0)
|
||||
}
|
||||
})
|
||||
|
||||
test('chaque deck a un global-bottom.vue (pied de page portail)', () => {
|
||||
const data = JSON.parse(
|
||||
fs.readFileSync(path.join(repoRoot, 'portal', '.vitepress', 'data', 'presentations.json'), 'utf8'),
|
||||
)
|
||||
for (const p of data) {
|
||||
const gb = path.join(repoRoot, 'slides', p.slug, 'global-bottom.vue')
|
||||
assert.ok(fs.existsSync(gb), `slides/${p.slug}/global-bottom.vue manquant (pied de page)`)
|
||||
const raw = fs.readFileSync(gb, 'utf8')
|
||||
assert.ok(
|
||||
raw.includes('_shared/DeckFooter'),
|
||||
`global-bottom doit importer le pied de page partagé: ${p.slug}`,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('fiche et liste générées pour chaque slug', () => {
|
||||
const data = JSON.parse(
|
||||
fs.readFileSync(path.join(repoRoot, 'portal', '.vitepress', 'data', 'presentations.json'), 'utf8'),
|
||||
)
|
||||
const listMd = path.join(repoRoot, 'portal', 'presentations', 'index.md')
|
||||
const listContent = fs.readFileSync(listMd, 'utf8')
|
||||
|
||||
for (const p of data) {
|
||||
const detail = path.join(repoRoot, 'portal', 'presentations', p.slug, 'index.md')
|
||||
assert.ok(fs.existsSync(detail), `fiche manquante: presentations/${p.slug}/index.md`)
|
||||
const body = fs.readFileSync(detail, 'utf8')
|
||||
assert.match(body, new RegExp(`slides/${p.slug}/`, ''), 'lien deck dans la fiche')
|
||||
assert.match(body, /<QrEncode path="/, 'composant QR présent')
|
||||
assert.ok(listContent.includes(p.title), 'titre présent dans la liste')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import { test, describe, beforeEach } from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
import { getSiteBase, sitePath, vitepressAppBase } from '../tools/site-base.mjs'
|
||||
|
||||
describe('site-base (SITE_BASE / chemins statiques)', () => {
|
||||
beforeEach(() => {
|
||||
delete process.env.SITE_BASE
|
||||
delete process.env.TALKS_PORTAL_DEV
|
||||
})
|
||||
|
||||
test('défaut : même comportement que la racine du site', () => {
|
||||
assert.strictEqual(getSiteBase(), '')
|
||||
assert.strictEqual(sitePath('/slides/foo/'), '/slides/foo/')
|
||||
assert.strictEqual(sitePath('/presentations/bar/'), '/presentations/bar/')
|
||||
})
|
||||
|
||||
test('sous-chemin sans slash final', () => {
|
||||
process.env.SITE_BASE = '/mon-depot'
|
||||
assert.strictEqual(getSiteBase(), '/mon-depot')
|
||||
assert.strictEqual(sitePath('/slides/foo/'), '/mon-depot/slides/foo/')
|
||||
})
|
||||
|
||||
test('sous-chemin avec slash final', () => {
|
||||
process.env.SITE_BASE = '/repo/'
|
||||
assert.strictEqual(getSiteBase(), '/repo')
|
||||
assert.strictEqual(sitePath('/slides/x/'), '/repo/slides/x/')
|
||||
})
|
||||
|
||||
test('chemins relatifs normalisés', () => {
|
||||
delete process.env.SITE_BASE
|
||||
assert.strictEqual(sitePath('slides/x/'), '/slides/x/')
|
||||
})
|
||||
|
||||
test('SITE_BASE vide explicite : traité comme /', () => {
|
||||
process.env.SITE_BASE = ''
|
||||
assert.strictEqual(getSiteBase(), '')
|
||||
})
|
||||
|
||||
test('TALKS_PORTAL_DEV=1 : ignore SITE_BASE (dev local)', () => {
|
||||
process.env.TALKS_PORTAL_DEV = '1'
|
||||
process.env.SITE_BASE = '/pages-projet/'
|
||||
assert.strictEqual(getSiteBase(), '')
|
||||
assert.strictEqual(sitePath('/slides/a/'), '/slides/a/')
|
||||
assert.strictEqual(vitepressAppBase(), '/')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Intégration : sync-portal sur un dépôt minimal (TALKS_ROOT) sans toucher au vrai portail.
|
||||
*/
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const repoRoot = path.resolve(__dirname, '..')
|
||||
|
||||
test('sync sur dépôt temporaire (TALKS_ROOT)', () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'talks-tmp-'))
|
||||
|
||||
fs.mkdirSync(path.join(tmp, 'portal'), { recursive: true })
|
||||
fs.writeFileSync(
|
||||
path.join(tmp, 'portal', 'index.md'),
|
||||
['---', 'layout: doc', '---', '', '<!-- FEATURED_START -->', '<!-- FEATURED_END -->', ''].join('\n'),
|
||||
)
|
||||
|
||||
const hello = path.join(tmp, 'slides', 'hello-world')
|
||||
fs.mkdirSync(hello, { recursive: true })
|
||||
fs.writeFileSync(
|
||||
path.join(hello, 'meta.json'),
|
||||
JSON.stringify({
|
||||
title: 'Hello World',
|
||||
description: 'Test tmp',
|
||||
order: 1,
|
||||
featured: true,
|
||||
}),
|
||||
)
|
||||
fs.writeFileSync(path.join(hello, 'slides.md'), '---\n---\n\n# Slide\n')
|
||||
|
||||
const r = spawnSync(process.execPath, [path.join(repoRoot, 'tools', 'sync-portal.mjs')], {
|
||||
cwd: repoRoot,
|
||||
env: { ...process.env, TALKS_ROOT: tmp },
|
||||
encoding: 'utf8',
|
||||
})
|
||||
assert.strictEqual(r.status, 0, r.stderr || r.stdout)
|
||||
|
||||
const detail = path.join(tmp, 'portal', 'presentations', 'hello-world', 'index.md')
|
||||
assert.ok(fs.existsSync(detail), 'fiche générée')
|
||||
const body = fs.readFileSync(detail, 'utf8')
|
||||
assert.ok(body.includes('Hello World'))
|
||||
assert.ok(body.includes('<QrEncode path="/slides/hello-world/1"'))
|
||||
|
||||
const list = path.join(tmp, 'portal', 'presentations', 'index.md')
|
||||
assert.ok(fs.readFileSync(list, 'utf8').includes('Hello World'))
|
||||
|
||||
const featured = fs.readFileSync(path.join(tmp, 'portal', 'index.md'), 'utf8')
|
||||
assert.ok(featured.includes('talk-card'))
|
||||
})
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Tests du plugin slide-dists en dev VitePress (`/slides/<slug>/` → dist-<slug>/).
|
||||
*
|
||||
* Régression couverte (soumission initiale) :
|
||||
* En dev, des requêtes du type `GET /slides/<slug>/1.md?import&…` recevaient la page HTML
|
||||
* du portail VitePress (Content-Type: text/html) au lieu d’être ignorées ou traitées
|
||||
* comme des modules JS. Le navigateur affichait alors :
|
||||
* « Expected a JavaScript-or-Wasm module script but the server responded with a MIME type of text/html »
|
||||
* (middleware sirv trop large + repli SPA VitePress sur du HTML pour chemins inconnus.)
|
||||
*
|
||||
* Contrat : pour ces URLs « module Vite » sous /slides/, la réponse ne doit **jamais**
|
||||
* être du `text/html` (404 texte ou autre non-HTML est acceptable).
|
||||
*/
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import http from 'node:http'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { createServer } from 'vitepress'
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const deckIndex = path.join(root, 'dist-kubernetes-operators', 'index.html')
|
||||
if (!fs.existsSync(deckIndex)) {
|
||||
const e = { ...process.env, TALKS_PORTAL_DEV: '1' }
|
||||
delete e.SITE_BASE
|
||||
execFileSync(process.execPath, [path.join(root, 'tools', 'build-deck.mjs'), 'kubernetes-operators'], {
|
||||
cwd: root,
|
||||
stdio: 'inherit',
|
||||
env: e,
|
||||
})
|
||||
}
|
||||
|
||||
function get(url, headers = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const u = new URL(url)
|
||||
const opts = { hostname: u.hostname, port: u.port, path: u.pathname + u.search, headers }
|
||||
http
|
||||
.get(opts, (res) => {
|
||||
let body = ''
|
||||
res.on('data', (c) => {
|
||||
body += c
|
||||
})
|
||||
res.on('end', () => resolve({ status: res.statusCode, body, headers: res.headers }))
|
||||
})
|
||||
.on('error', reject)
|
||||
})
|
||||
}
|
||||
|
||||
/** Lance VitePress en mode dev local (TALKS_PORTAL_DEV), exécute le callback, puis ferme. */
|
||||
async function withDevPortal(callback) {
|
||||
const prevDev = process.env.TALKS_PORTAL_DEV
|
||||
const prevSite = process.env.SITE_BASE
|
||||
process.env.TALKS_PORTAL_DEV = '1'
|
||||
delete process.env.SITE_BASE
|
||||
const server = await createServer('portal', {
|
||||
server: { strictPort: true, host: true },
|
||||
})
|
||||
const port = 28740 + Math.floor(Math.random() * 200)
|
||||
await server.listen(port)
|
||||
try {
|
||||
await callback(port)
|
||||
} finally {
|
||||
await server.close()
|
||||
if (prevDev !== undefined) process.env.TALKS_PORTAL_DEV = prevDev
|
||||
else delete process.env.TALKS_PORTAL_DEV
|
||||
if (prevSite !== undefined) process.env.SITE_BASE = prevSite
|
||||
else delete process.env.SITE_BASE
|
||||
}
|
||||
}
|
||||
|
||||
test('VitePress dev : racine deck → 302 vers …/1 (première planche Slidev)', async () => {
|
||||
await withDevPortal(async (port) => {
|
||||
const base = `http://127.0.0.1:${port}`
|
||||
for (const path of ['/slides/kubernetes-operators/', '/slides/kubernetes-operators']) {
|
||||
const { status, headers } = await get(`${base}${path}`)
|
||||
assert.equal(status, 302, `${path} → attendu 302`)
|
||||
assert.equal(headers.location, '/slides/kubernetes-operators/1', `Location pour ${path}`)
|
||||
}
|
||||
const q = await get(`${base}/slides/kubernetes-operators/?embedded=1`)
|
||||
assert.equal(q.status, 302)
|
||||
assert.equal(q.headers.location, '/slides/kubernetes-operators/1?embedded=1')
|
||||
})
|
||||
})
|
||||
|
||||
test('VitePress dev : GET /slides/kubernetes-operators/1 → 200 (HTML Slidev)', async () => {
|
||||
await withDevPortal(async (port) => {
|
||||
const { status, body } = await get(`http://127.0.0.1:${port}/slides/kubernetes-operators/1`)
|
||||
assert.equal(status, 200, `attendu 200, reçu ${status}`)
|
||||
assert.match(body, /slidev|Slidev|#app/i, 'corps doit ressembler à une page Slidev')
|
||||
})
|
||||
})
|
||||
|
||||
test('Régression MIME module : *.md?import sous /slides/<slug>/ ne renvoie pas text/html (HAR)', async () => {
|
||||
const paths = [
|
||||
'/slides/kubernetes-operators/1.md?import&t=1775912428995',
|
||||
'/slides/kubernetes-operators/index.md?import',
|
||||
'/slides/kubernetes-operators/slides.md?import',
|
||||
]
|
||||
await withDevPortal(async (port) => {
|
||||
for (const p of paths) {
|
||||
const { status, headers, body } = await get(`http://127.0.0.1:${port}${p}`, {
|
||||
Accept: '*/*',
|
||||
'Sec-Fetch-Dest': 'script',
|
||||
'Sec-Fetch-Mode': 'cors',
|
||||
})
|
||||
const ct = String(headers['content-type'] || '')
|
||||
assert.ok(
|
||||
!ct.toLowerCase().includes('text/html'),
|
||||
`${p} : interdit text/html (reçu ${status}, Content-Type: ${ct || '(vide)'}) — régression MIME module`,
|
||||
)
|
||||
assert.ok(
|
||||
!/^<\s*!DOCTYPE\s+html/i.test(body.trim()),
|
||||
`${p} : le corps ne doit pas être un document HTML (ex. shell VitePress)`,
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test('VitePress dev : GET …/2 (route client Slidev) → 200 HTML deck', async () => {
|
||||
await withDevPortal(async (port) => {
|
||||
const { status, body } = await get(`http://127.0.0.1:${port}/slides/kubernetes-operators/2`, {
|
||||
Accept: 'text/html',
|
||||
})
|
||||
assert.equal(status, 200)
|
||||
assert.match(body, /slidev|Slidev|#app/i)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env node
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { listPresentationSlugs } from './list-slugs.mjs'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const root = path.resolve(__dirname, '..')
|
||||
|
||||
const slugs = listPresentationSlugs(root)
|
||||
if (!slugs.length) {
|
||||
console.log('[build-all-decks] aucune présentation')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
for (const slug of slugs) {
|
||||
execFileSync(process.execPath, [path.join(root, 'tools', 'build-deck.mjs'), slug], {
|
||||
stdio: 'inherit',
|
||||
cwd: root,
|
||||
env: process.env,
|
||||
})
|
||||
}
|
||||
console.log('[build-all-decks] terminé')
|
||||
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Build Slidev pour un slug → dossier dist-<slug>/ à la racine du dépôt.
|
||||
* Usage : node tools/build-deck.mjs <slug>
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { sitePath } from './site-base.mjs'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const root = path.resolve(__dirname, '..')
|
||||
|
||||
function publishBuild(wipDir, finalDir) {
|
||||
fs.mkdirSync(finalDir, { recursive: true })
|
||||
fs.cpSync(wipDir, finalDir, { recursive: true, force: true })
|
||||
fs.rmSync(wipDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
function slidevBin() {
|
||||
const p = path.join(root, 'node_modules', '.bin', 'slidev')
|
||||
if (fs.existsSync(p)) return p
|
||||
return 'slidev'
|
||||
}
|
||||
|
||||
const slug = process.argv[2]
|
||||
if (!slug || slug.startsWith('wip_')) {
|
||||
console.error('usage: node tools/build-deck.mjs <slug>')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const entry = path.join(root, 'slides', slug, 'slides.md')
|
||||
const metaPath = path.join(root, 'slides', slug, 'meta.json')
|
||||
if (!fs.existsSync(entry) || !fs.existsSync(metaPath)) {
|
||||
console.error(`[build-deck] slides/${slug}/ incomplet (slides.md + meta.json requis)`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const finalDir = path.join(root, `dist-${slug}`)
|
||||
const wipDir = path.join(root, '.slide-wip', slug)
|
||||
const base = sitePath(`/slides/${slug}/`)
|
||||
if (!base.endsWith('/')) throw new Error(`base invalide: ${base}`)
|
||||
|
||||
fs.rmSync(wipDir, { recursive: true, force: true })
|
||||
fs.mkdirSync(wipDir, { recursive: true })
|
||||
/* Ne pas supprimer slides/<slug>/index.html avant le build : Slidev s’attend à pouvoir unlink ce fichier en fin de build. */
|
||||
console.log(`[build-deck] ${slug} → dist-${slug}/ (base=${base})`)
|
||||
try {
|
||||
execFileSync(
|
||||
slidevBin(),
|
||||
['build', entry, '--out', wipDir, '--base', base],
|
||||
{ stdio: 'inherit', cwd: root, env: process.env },
|
||||
)
|
||||
publishBuild(wipDir, finalDir)
|
||||
/* Slidev peut écrire un index.html à côté de slides.md — inutile et bruyant au build suivant */
|
||||
try {
|
||||
fs.rmSync(path.join(root, 'slides', slug, 'index.html'), { force: true })
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
} catch (e) {
|
||||
try {
|
||||
fs.rmSync(wipDir, { recursive: true, force: true })
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* npm run dev:portal — sync + VitePress dev avec base=/ (ignore SITE_BASE sauf SKIP_PORTAL_DEV=1).
|
||||
*/
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { spawn, execFileSync } from 'node:child_process'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const root = path.resolve(__dirname, '..')
|
||||
const runEnv =
|
||||
process.env.SKIP_PORTAL_DEV === '1'
|
||||
? process.env
|
||||
: { ...process.env, TALKS_PORTAL_DEV: '1' }
|
||||
|
||||
execFileSync(process.execPath, [path.join(root, 'tools', 'sync-portal.mjs')], {
|
||||
stdio: 'inherit',
|
||||
cwd: root,
|
||||
env: runEnv,
|
||||
})
|
||||
|
||||
const vitepressBin = path.join(root, 'node_modules', '.bin', 'vitepress')
|
||||
const child = spawn(vitepressBin, ['dev', 'portal'], {
|
||||
cwd: root,
|
||||
stdio: 'inherit',
|
||||
env: runEnv,
|
||||
shell: process.platform === 'win32',
|
||||
})
|
||||
|
||||
function die() {
|
||||
try {
|
||||
child.kill('SIGTERM')
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
process.exit(0)
|
||||
}
|
||||
process.on('SIGINT', die)
|
||||
process.on('SIGTERM', die)
|
||||
child.on('exit', (code) => process.exit(code ?? 0))
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Dossiers de présentation sous slides/ (hors wip_*), avec meta.json + slides.md.
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
export function listPresentationSlugs(root) {
|
||||
const slides = path.join(root, 'slides')
|
||||
if (!fs.existsSync(slides)) return []
|
||||
return fs
|
||||
.readdirSync(slides, { withFileTypes: true })
|
||||
.filter((d) => d.isDirectory() && !d.name.startsWith('wip_') && !d.name.startsWith('_'))
|
||||
.map((d) => d.name)
|
||||
.filter((slug) => {
|
||||
return (
|
||||
fs.existsSync(path.join(slides, slug, 'meta.json')) &&
|
||||
fs.existsSync(path.join(slides, slug, 'slides.md'))
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* create | delete | clone | rename — cohérence slides + portail (sync).
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const root = path.resolve(__dirname, '..')
|
||||
|
||||
function sync() {
|
||||
execFileSync(process.execPath, [path.join(root, 'tools', 'sync-portal.mjs')], {
|
||||
stdio: 'inherit',
|
||||
cwd: root,
|
||||
env: process.env,
|
||||
})
|
||||
}
|
||||
|
||||
function assertSlug(s, label = 'slug') {
|
||||
if (!s || typeof s !== 'string') throw new Error(`${label} manquant`)
|
||||
if (!/^[a-z0-9-]+$/.test(s)) throw new Error(`${label} invalide (a-z 0-9 -): ${s}`)
|
||||
if (s.length < 2) throw new Error(`${label} trop court`)
|
||||
if (s === 'portal') throw new Error('slug réservé')
|
||||
}
|
||||
|
||||
const cmd = process.argv[2]
|
||||
const a = process.argv[3]
|
||||
const b = process.argv[4]
|
||||
|
||||
try {
|
||||
if (cmd === 'create') {
|
||||
assertSlug(a)
|
||||
const dir = path.join(root, 'slides', a)
|
||||
if (fs.existsSync(dir)) throw new Error(`existe déjà: slides/${a}/`)
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'meta.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
title: 'Nouvelle présentation',
|
||||
description: '',
|
||||
order: 99,
|
||||
tags: [],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
'utf8',
|
||||
)
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'slides.md'),
|
||||
['---', 'title: Nouvelle présentation', '---', '', '# Bienvenue', ''].join('\n'),
|
||||
'utf8',
|
||||
)
|
||||
sync()
|
||||
console.log(`[create] slides/${a}/ — lancez make run ou make build`)
|
||||
} else if (cmd === 'delete') {
|
||||
assertSlug(a)
|
||||
fs.rmSync(path.join(root, 'slides', a), { recursive: true, force: true })
|
||||
fs.rmSync(path.join(root, `dist-${a}`), { recursive: true, force: true })
|
||||
sync()
|
||||
console.log(`[delete] ${a} retiré`)
|
||||
} else if (cmd === 'clone') {
|
||||
assertSlug(a, 'source')
|
||||
assertSlug(b, 'dest')
|
||||
const src = path.join(root, 'slides', a)
|
||||
const dst = path.join(root, 'slides', b)
|
||||
if (!fs.existsSync(src)) throw new Error(`source absente: slides/${a}/`)
|
||||
if (fs.existsSync(dst)) throw new Error(`dest existe: slides/${b}/`)
|
||||
fs.cpSync(src, dst, { recursive: true })
|
||||
const metaPath = path.join(dst, 'meta.json')
|
||||
const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8'))
|
||||
meta.title = `${meta.title || a} (copie)`
|
||||
fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2), 'utf8')
|
||||
sync()
|
||||
console.log(`[clone] ${a} → ${b} — build: node tools/build-deck.mjs ${b}`)
|
||||
} else if (cmd === 'rename') {
|
||||
assertSlug(a, 'ancien')
|
||||
assertSlug(b, 'nouveau')
|
||||
const src = path.join(root, 'slides', a)
|
||||
const dst = path.join(root, 'slides', b)
|
||||
if (!fs.existsSync(src)) throw new Error(`slides/${a}/ absent`)
|
||||
if (fs.existsSync(dst)) throw new Error(`slides/${b}/ existe déjà`)
|
||||
fs.renameSync(src, dst)
|
||||
const metaPath = path.join(dst, 'meta.json')
|
||||
const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8'))
|
||||
delete meta.slug
|
||||
fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2), 'utf8')
|
||||
const oldDist = path.join(root, `dist-${a}`)
|
||||
const newDist = path.join(root, `dist-${b}`)
|
||||
if (fs.existsSync(oldDist)) {
|
||||
fs.renameSync(oldDist, newDist)
|
||||
}
|
||||
sync()
|
||||
console.log(`[rename] ${a} → ${b}`)
|
||||
} else {
|
||||
console.error('usage: node tools/presentation-cli.mjs <create|delete|clone|rename> [args]')
|
||||
process.exit(1)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(String(e.message || e))
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Logique pure partagée : génération markdown, lecture meta, échappements.
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
export const FEATURED_START = '<!-- FEATURED_START -->'
|
||||
export const FEATURED_END = '<!-- FEATURED_END -->'
|
||||
|
||||
export function escMd(s) {
|
||||
if (typeof s !== 'string') return ''
|
||||
return s.replace(/\|/g, '\\|')
|
||||
}
|
||||
|
||||
export function escHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
export function pickFeatured(items) {
|
||||
const marked = items.filter((p) => p.featured).sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
||||
if (marked.length) return marked.slice(0, 6)
|
||||
return [...items].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)).slice(0, 3)
|
||||
}
|
||||
|
||||
/** @param {Function} sitePath - ex. import { sitePath } from './site-base.mjs' */
|
||||
export function buildFeaturedHtml(items, sitePath) {
|
||||
const featured = pickFeatured(items)
|
||||
if (!featured.length) return '<!-- (aucune présentation) -->'
|
||||
const lines = [
|
||||
'<section class="talks-section talks-section--home" aria-labelledby="featured-heading">',
|
||||
' <h2 id="featured-heading" class="section-title">Présentations</h2>',
|
||||
' <div class="talks-grid">',
|
||||
]
|
||||
for (const p of featured) {
|
||||
const href = sitePath(`/presentations/${p.slug}/`)
|
||||
const meta = `Présentation · ${p.slug}`
|
||||
lines.push(` <a class="talk-card" href="${escHtml(href)}">`)
|
||||
lines.push(` <h2>${escHtml(p.title)}</h2>`)
|
||||
lines.push(` <p>${escHtml(p.description || '')}</p>`)
|
||||
lines.push(` <span class="meta">${escHtml(meta)}</span>`)
|
||||
lines.push(' </a>')
|
||||
}
|
||||
lines.push(' </div>', '</section>')
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
export function readAllMeta(slidesRoot) {
|
||||
if (!fs.existsSync(slidesRoot)) return []
|
||||
const dirs = fs
|
||||
.readdirSync(slidesRoot, { withFileTypes: true })
|
||||
.filter((d) => d.isDirectory() && !d.name.startsWith('wip_') && !d.name.startsWith('_'))
|
||||
.map((d) => d.name)
|
||||
|
||||
const list = []
|
||||
for (const dir of dirs) {
|
||||
const metaPath = path.join(slidesRoot, dir, 'meta.json')
|
||||
if (!fs.existsSync(metaPath)) continue
|
||||
const raw = fs.readFileSync(metaPath, 'utf8')
|
||||
const meta = JSON.parse(raw)
|
||||
if (meta.slug && meta.slug !== dir) {
|
||||
console.warn(`[sync] slug "${meta.slug}" ≠ dossier "${dir}", le dossier fait foi pour les URLs Slidev`)
|
||||
}
|
||||
list.push({ ...meta, slug: dir, folder: dir })
|
||||
}
|
||||
list.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
||||
return list
|
||||
}
|
||||
|
||||
/** @param {Function} sitePath */
|
||||
export function generateListPageMarkdown(items, sitePath) {
|
||||
const lines = [
|
||||
'---',
|
||||
'title: Présentations',
|
||||
'outline: deep',
|
||||
'---',
|
||||
'',
|
||||
'# Présentations',
|
||||
'',
|
||||
'Decks disponibles — fiche portail et build Slidev statique.',
|
||||
'',
|
||||
'<div class="talks-grid">',
|
||||
]
|
||||
for (const p of items) {
|
||||
const detail = sitePath(`/presentations/${p.slug}/`)
|
||||
/** Première planche (`/1`) + classe `vp-raw` : le routeur VitePress ignore ces liens (navigation réelle vers Slidev). */
|
||||
const deck = sitePath(`/slides/${p.slug}/1`)
|
||||
const tags = (p.tags || []).map((t) => `\`${escMd(t)}\``).join(' · ')
|
||||
lines.push(' <article class="talk-card talk-card--list">')
|
||||
lines.push(` <h2><a href="${detail}">${escMd(p.title)}</a></h2>`)
|
||||
lines.push(` <p>${escMd(p.description || '')}</p>`)
|
||||
if (tags) lines.push(` <p class="meta">${tags}</p>`)
|
||||
lines.push(' <div class="talk-card__actions">')
|
||||
lines.push(` <a class="vp-button" href="${detail}">Fiche</a>`)
|
||||
lines.push(
|
||||
` <a class="vp-button vp-button--alt vp-raw" href="${deck}" rel="noopener">Slides</a>`,
|
||||
)
|
||||
lines.push(' </div>')
|
||||
lines.push(' </article>')
|
||||
}
|
||||
lines.push('</div>', '')
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
/** @param {Function} sitePath */
|
||||
export function generateDetailPageMarkdown(p, sitePath) {
|
||||
/** `vp-raw` : VitePress ne intercepte pas le clic (sinon 404 SPA sur /slides/…). */
|
||||
const deck = sitePath(`/slides/${p.slug}/1`)
|
||||
const tags = (p.tags || []).map((t) => `\`${t}\``).join(' · ')
|
||||
const lines = [
|
||||
'---',
|
||||
`title: ${p.title.replace(/"/g, '\\"')}`,
|
||||
'outline: deep',
|
||||
'---',
|
||||
'',
|
||||
`# ${p.title}`,
|
||||
'',
|
||||
...(tags ? [`**Tags :** ${tags}`, ''] : []),
|
||||
'## Résumé',
|
||||
'',
|
||||
p.description || '_Pas de description._',
|
||||
'',
|
||||
'## Accès',
|
||||
'',
|
||||
`<p class="pres-actions">`,
|
||||
` <a class="vp-button vp-raw" href="${deck}">Voir les slides</a>`,
|
||||
` <a class="vp-button vp-button--alt vp-raw" href="${deck}" target="_blank" rel="noopener noreferrer">Ouvrir dans un nouvel onglet</a>`,
|
||||
`</p>`,
|
||||
'',
|
||||
'## Partager les slides',
|
||||
'',
|
||||
'Depuis la salle, scannez le code pour ouvrir les slides sur mobile.',
|
||||
'',
|
||||
`<QrEncode path="${deck}" />`,
|
||||
'',
|
||||
'::: info À propos',
|
||||
'Les slides sont servies en SPA statique (Slidev). Les chemins d’assets suivent le `base` défini au build.',
|
||||
':::',
|
||||
'',
|
||||
]
|
||||
return lines.filter((l) => l !== undefined).join('\n')
|
||||
}
|
||||
|
||||
export function patchIndexFeaturedContent(indexContent, items, sitePath) {
|
||||
const i0 = indexContent.indexOf(FEATURED_START)
|
||||
const i1 = indexContent.indexOf(FEATURED_END)
|
||||
if (i0 === -1 || i1 === -1 || i1 <= i0) {
|
||||
return { ok: false, content: indexContent }
|
||||
}
|
||||
const before = indexContent.slice(0, i0 + FEATURED_START.length)
|
||||
const after = indexContent.slice(i1)
|
||||
const block = buildFeaturedHtml(items, sitePath)
|
||||
const content = `${before}\n\n${block}\n\n${after}`
|
||||
return { ok: true, content }
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* make run : sync + build tous les decks, puis VitePress dev + watch slides/
|
||||
* → rebuild ciblé + sync pour refléter les sources.
|
||||
*/
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { spawn, execFileSync } from 'node:child_process'
|
||||
import chokidar from 'chokidar'
|
||||
import { listPresentationSlugs } from './list-slugs.mjs'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const root = path.resolve(__dirname, '..')
|
||||
const slidesDir = path.join(root, 'slides')
|
||||
|
||||
/** Aligne sync, decks et VitePress sur base=/ (ignore SITE_BASE du .env). Voir tools/site-base.mjs */
|
||||
const runEnv =
|
||||
process.env.SKIP_PORTAL_DEV === '1'
|
||||
? process.env
|
||||
: { ...process.env, TALKS_PORTAL_DEV: '1' }
|
||||
|
||||
function sync() {
|
||||
execFileSync(process.execPath, [path.join(root, 'tools', 'sync-portal.mjs')], {
|
||||
stdio: 'inherit',
|
||||
cwd: root,
|
||||
env: runEnv,
|
||||
})
|
||||
}
|
||||
|
||||
function buildDeck(slug) {
|
||||
execFileSync(process.execPath, [path.join(root, 'tools', 'build-deck.mjs'), slug], {
|
||||
stdio: 'inherit',
|
||||
cwd: root,
|
||||
env: runEnv,
|
||||
})
|
||||
}
|
||||
|
||||
function buildAllDecks() {
|
||||
const slugs = listPresentationSlugs(root)
|
||||
for (const slug of slugs) buildDeck(slug)
|
||||
}
|
||||
|
||||
sync()
|
||||
buildAllDecks()
|
||||
|
||||
const pending = new Set()
|
||||
let flushTimer = null
|
||||
function flush() {
|
||||
flushTimer = null
|
||||
sync()
|
||||
for (const slug of pending) {
|
||||
try {
|
||||
buildDeck(slug)
|
||||
} catch {
|
||||
/* build-deck a déjà loggé */
|
||||
}
|
||||
}
|
||||
pending.clear()
|
||||
}
|
||||
|
||||
chokidar
|
||||
.watch(slidesDir, {
|
||||
ignoreInitial: true,
|
||||
ignored: (p) => path.basename(p).startsWith('.') || p.includes(`${path.sep}wip_`),
|
||||
})
|
||||
.on('all', (_evt, fp) => {
|
||||
const rel = path.relative(slidesDir, fp)
|
||||
const slug = rel.split(path.sep)[0]
|
||||
if (!slug || slug.startsWith('wip_')) return
|
||||
pending.add(slug)
|
||||
clearTimeout(flushTimer)
|
||||
flushTimer = setTimeout(flush, 450)
|
||||
})
|
||||
|
||||
const vitepressBin = path.join(root, 'node_modules', '.bin', 'vitepress')
|
||||
const child = spawn(vitepressBin, ['dev', 'portal'], {
|
||||
cwd: root,
|
||||
stdio: 'inherit',
|
||||
env: runEnv,
|
||||
shell: process.platform === 'win32',
|
||||
})
|
||||
|
||||
function die() {
|
||||
try {
|
||||
child.kill('SIGTERM')
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
process.exit(0)
|
||||
}
|
||||
process.on('SIGINT', die)
|
||||
process.on('SIGTERM', die)
|
||||
child.on('exit', (code) => process.exit(code ?? 0))
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Préfixe URL du site, aligné sur VitePress `base`.
|
||||
* Racine : SITE_BASE=/ ou absent
|
||||
* Sous-chemin (ex. GitHub Pages) : SITE_BASE=/mon-repo/
|
||||
*
|
||||
* En dev local (`TALKS_PORTAL_DEV=1`, ex. make run), SITE_BASE est ignoré pour
|
||||
* éviter les modules .md servis en text/html (mismatch base / URLs).
|
||||
* Pour tester un sous-chemin en dev : SKIP_PORTAL_DEV=1 SITE_BASE=/repo/ npm run dev:portal
|
||||
*/
|
||||
export function getSiteBase() {
|
||||
if (process.env.TALKS_PORTAL_DEV === '1') return ''
|
||||
const raw = process.env.SITE_BASE?.trim() || '/'
|
||||
if (raw === '/') return ''
|
||||
return raw.replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
/** Valeur `base` VitePress : `/` ou `/mon-projet/` */
|
||||
export function vitepressAppBase() {
|
||||
if (process.env.TALKS_PORTAL_DEV === '1') return '/'
|
||||
const raw = process.env.SITE_BASE?.trim()
|
||||
if (!raw || raw === '/') return '/'
|
||||
return raw.endsWith('/') ? raw : `${raw}/`
|
||||
}
|
||||
|
||||
/** Chemin absolu du site : /slides/foo/ */
|
||||
export function sitePath(absolutePath) {
|
||||
const p = absolutePath.startsWith('/') ? absolutePath : `/${absolutePath}`
|
||||
const b = getSiteBase()
|
||||
return b ? `${b}${p}` : p
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Sert dist-portal + chaque dist-<slug> sous /slides/<slug>/ (tests E2E, prod locale).
|
||||
* Comme le plugin Vite en dev : racine du deck → 302 vers /slides/<slug>/1 ; routes Slidev (/1, /2…) via SPA (sirv single).
|
||||
*/
|
||||
import http from 'node:http'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import sirv from 'sirv'
|
||||
import { listPresentationSlugs } from './list-slugs.mjs'
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const port = Number(process.env.PREVIEW_PORT || 4174)
|
||||
|
||||
const portalStatic = sirv(path.join(root, 'dist-portal'), { etag: true })
|
||||
const slideSirv = new Map()
|
||||
|
||||
function getSirv(slug) {
|
||||
if (!slideSirv.has(slug)) {
|
||||
slideSirv.set(
|
||||
slug,
|
||||
sirv(path.join(root, `dist-${slug}`), {
|
||||
etag: true,
|
||||
single: 'index.html',
|
||||
}),
|
||||
)
|
||||
}
|
||||
return slideSirv.get(slug)
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const u = new URL(req.url || '/', 'http://127.0.0.1')
|
||||
const pathname = u.pathname
|
||||
const m = pathname.match(/^\/slides\/([^/]+)(.*)$/)
|
||||
if (m) {
|
||||
const slug = m[1]
|
||||
const rest = m[2] || '/'
|
||||
const slugs = new Set(listPresentationSlugs(root))
|
||||
if (!slugs.has(slug)) {
|
||||
res.statusCode = 404
|
||||
res.end('not found')
|
||||
return
|
||||
}
|
||||
const atDeckRoot = rest === '/' || rest === ''
|
||||
if (atDeckRoot && (req.method === 'GET' || req.method === 'HEAD')) {
|
||||
res.statusCode = 302
|
||||
res.setHeader('Location', `/slides/${slug}/1${u.search || ''}`)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
const orig = req.url
|
||||
req.url = rest + (u.search || '')
|
||||
getSirv(slug)(req, res, () => {
|
||||
req.url = orig
|
||||
res.statusCode = 404
|
||||
res.end()
|
||||
})
|
||||
return
|
||||
}
|
||||
portalStatic(req, res, () => {
|
||||
res.statusCode = 404
|
||||
res.end('not found')
|
||||
})
|
||||
})
|
||||
|
||||
server.listen(port, '127.0.0.1', () => {
|
||||
console.log(`[static-preview] http://127.0.0.1:${port}/`)
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Lit chaque slides/<slug>/meta.json, écrit presentations.json, index featured, liste + fiches.
|
||||
* Retire les fiches portal obsolètes.
|
||||
*
|
||||
* TALKS_ROOT (optionnel) : racine du dépôt — tests.
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { sitePath } from './site-base.mjs'
|
||||
import {
|
||||
readAllMeta,
|
||||
generateListPageMarkdown,
|
||||
generateDetailPageMarkdown,
|
||||
patchIndexFeaturedContent,
|
||||
} from './presentation-contracts.mjs'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const root = process.env.TALKS_ROOT ? path.resolve(process.env.TALKS_ROOT) : path.resolve(__dirname, '..')
|
||||
const slidesRoot = path.join(root, 'slides')
|
||||
const portalData = path.join(root, 'portal', '.vitepress', 'data')
|
||||
const presentationsDir = path.join(root, 'portal', 'presentations')
|
||||
|
||||
function pruneStalePresentationDirs(items) {
|
||||
const keep = new Set(items.map((p) => p.slug))
|
||||
if (!fs.existsSync(presentationsDir)) return
|
||||
for (const d of fs.readdirSync(presentationsDir, { withFileTypes: true })) {
|
||||
if (!d.isDirectory()) continue
|
||||
if (!keep.has(d.name)) {
|
||||
fs.rmSync(path.join(presentationsDir, d.name), { recursive: true, force: true })
|
||||
console.warn(`[sync] fiche retirée (plus de slides/${d.name}/): presentations/${d.name}/`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const items = readAllMeta(slidesRoot)
|
||||
fs.mkdirSync(portalData, { recursive: true })
|
||||
fs.writeFileSync(path.join(portalData, 'presentations.json'), JSON.stringify(items, null, 2), 'utf8')
|
||||
|
||||
const indexPath = path.join(root, 'portal', 'index.md')
|
||||
if (fs.existsSync(indexPath)) {
|
||||
const raw = fs.readFileSync(indexPath, 'utf8')
|
||||
const { ok, content } = patchIndexFeaturedContent(raw, items, sitePath)
|
||||
if (!ok) {
|
||||
console.warn('[sync] marqueurs FEATURED_START / FEATURED_END absents ou invalides dans portal/index.md')
|
||||
} else {
|
||||
fs.writeFileSync(indexPath, content, 'utf8')
|
||||
}
|
||||
}
|
||||
|
||||
fs.mkdirSync(presentationsDir, { recursive: true })
|
||||
fs.writeFileSync(path.join(presentationsDir, 'index.md'), generateListPageMarkdown(items, sitePath), 'utf8')
|
||||
|
||||
for (const p of items) {
|
||||
const dir = path.join(presentationsDir, p.slug)
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
fs.writeFileSync(path.join(dir, 'index.md'), generateDetailPageMarkdown(p, sitePath), 'utf8')
|
||||
}
|
||||
|
||||
pruneStalePresentationDirs(items)
|
||||
|
||||
console.log(`[sync] ${items.length} présentation(s) — portal mis à jour`)
|
||||
}
|
||||
|
||||
main()
|
||||
Reference in New Issue
Block a user