Add Makefile, package.json, and initial configuration for VitePress and Slidev integration
- Introduced a Makefile for streamlined commands related to building, running, and managing presentations. - Created package.json to define project metadata, dependencies, and scripts for development and testing. - Added .gitignore entries to exclude build artifacts and temporary files from version control. - Included playwright.config.mjs for end-to-end testing setup with Playwright. - Added README.md for project documentation, outlining setup instructions and development guidelines.
This commit is contained in:
@@ -1,116 +0,0 @@
|
|||||||
name: Talks slides — image & chart
|
|
||||||
|
|
||||||
# Déclenché uniquement si l’image Docker ou le chart Helm change (évite les builds sur contenu slides seul).
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main, develop, cicd]
|
|
||||||
tags: ['v*']
|
|
||||||
paths:
|
|
||||||
- 'server/**'
|
|
||||||
- 'talks-slides-dist/**'
|
|
||||||
- '.gitea/workflows/**'
|
|
||||||
pull_request:
|
|
||||||
branches: [main, develop]
|
|
||||||
paths:
|
|
||||||
- 'server/**'
|
|
||||||
- 'talks-slides-dist/**'
|
|
||||||
- '.gitea/workflows/**'
|
|
||||||
|
|
||||||
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
|
|
||||||
# Même registre OCI Helm que les autres charts (ex. knowledge-mcp).
|
|
||||||
HELM_OCI_REPOSITORY: oci://git.specificat.io/specificat.io
|
|
||||||
HELM_DIR: ./talks-slides-dist
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
vars:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
outputs:
|
|
||||||
version: ${{ steps.out.outputs.version }}
|
|
||||||
steps:
|
|
||||||
- id: out
|
|
||||||
env:
|
|
||||||
RUN_NUMBER: ${{ github.run_number }}
|
|
||||||
REF_NAME: ${{ github.ref_name }}
|
|
||||||
run: |
|
|
||||||
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
|
|
||||||
echo "version=${REF_NAME#v}" >> $GITHUB_OUTPUT
|
|
||||||
else
|
|
||||||
echo "version=0.0.${RUN_NUMBER}" >> $GITHUB_OUTPUT
|
|
||||||
fi
|
|
||||||
|
|
||||||
build-image:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
name: Build container image
|
|
||||||
needs: vars
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
- uses: docker/setup-buildx-action@v3
|
|
||||||
- uses: docker/login-action@v3
|
|
||||||
if: github.event_name == 'push'
|
|
||||||
with:
|
|
||||||
registry: ${{ env.IMAGE_REGISTRY }}
|
|
||||||
username: ${{ secrets.CI_USER }}
|
|
||||||
password: ${{ secrets.CI_TOKEN }}
|
|
||||||
- name: Build and push image
|
|
||||||
if: github.event_name == 'push'
|
|
||||||
env:
|
|
||||||
VERSION: ${{ needs.vars.outputs.version }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
IMAGE="${IMAGE_REGISTRY}/${IMAGE_NAME}:${VERSION}"
|
|
||||||
docker build -f server/Dockerfile -t "${IMAGE}" server/
|
|
||||||
docker push "${IMAGE}"
|
|
||||||
if [[ "${{ github.ref }}" == "refs/heads/main" ]]; then
|
|
||||||
docker tag "${IMAGE}" "${IMAGE_REGISTRY}/${IMAGE_NAME}:latest"
|
|
||||||
docker push "${IMAGE_REGISTRY}/${IMAGE_NAME}:latest"
|
|
||||||
fi
|
|
||||||
- name: Build image (PR, no push)
|
|
||||||
if: github.event_name == 'pull_request'
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
docker build -f server/Dockerfile -t talks-slides:ci server/
|
|
||||||
|
|
||||||
helm:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
name: Helm chart
|
|
||||||
needs: vars
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Install Helm
|
|
||||||
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"
|
|
||||||
tar -xzf helm.tgz
|
|
||||||
sudo mv linux-amd64/helm /usr/local/bin/helm
|
|
||||||
helm version
|
|
||||||
# Chart publié avec le même tag que l’image (évite latest / oubli de --set au déploiement).
|
|
||||||
- name: Aligner slides.image.tag sur la version build
|
|
||||||
env:
|
|
||||||
VERSION: ${{ needs.vars.outputs.version }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
f="${HELM_DIR}/values.yaml"
|
|
||||||
sed -i 's|^ tag: ".*"| tag: "'"${VERSION}"'"|' "$f"
|
|
||||||
grep -E '^ tag:' "$f" || true
|
|
||||||
- name: Helm lint & template
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
helm lint "${HELM_DIR}"
|
|
||||||
helm template talks-slides "${HELM_DIR}"
|
|
||||||
- name: Package and push chart (OCI)
|
|
||||||
if: github.event_name == 'push'
|
|
||||||
env:
|
|
||||||
VERSION: ${{ needs.vars.outputs.version }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
helm package "${HELM_DIR}" --version "${VERSION}" --app-version "${VERSION}"
|
|
||||||
helm registry login "${IMAGE_REGISTRY}" -u "${{ secrets.CI_USER }}" -p "${{ secrets.CI_TOKEN }}"
|
|
||||||
helm push "talks-slides-chart-${VERSION}.tgz" "${HELM_OCI_REPOSITORY}"
|
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Vérifie que le build production (VitePress + Slidev) réussit sans lancer les E2E
|
||||||
|
name: Build
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, master, develop]
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: build-${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
production-build:
|
||||||
|
name: Build statique
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- run: npm ci
|
||||||
|
|
||||||
|
- name: Build (sync + VitePress + decks Slidev)
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Tests layout dist/
|
||||||
|
run: node --test tests/dist-layout.test.mjs
|
||||||
|
|
||||||
|
- name: Archive dist/ (aperçu)
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: dist
|
||||||
|
path: dist/
|
||||||
|
retention-days: 3
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Tests unitaires + E2E Playwright (build statique complet)
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, master, develop]
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
node:
|
||||||
|
name: Tests Node (contrats + cohérence + sync tmp)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Installer les dépendances
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Tests Node (voir scripts/presentation-contracts.mjs)
|
||||||
|
run: npm run test:node
|
||||||
|
|
||||||
|
e2e:
|
||||||
|
name: E2E Playwright
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 25
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Installer les dépendances
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Navigateur Chromium + dépendances système
|
||||||
|
run: npx playwright install chromium --with-deps
|
||||||
|
|
||||||
|
- name: Parcours E2E (build + serve dist)
|
||||||
|
run: npm run test:e2e
|
||||||
|
env:
|
||||||
|
CI: true
|
||||||
|
|
||||||
|
- name: Rapport Playwright (échec)
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: playwright-report
|
||||||
|
path: playwright-report/
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
- name: Traces Playwright (échec)
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: test-results
|
||||||
|
path: test-results/
|
||||||
|
retention-days: 7
|
||||||
+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
|
.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,290 +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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.masthead-lead {
|
|
||||||
max-width: 42em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.masthead-lead strong {
|
|
||||||
color: var(--accent-cyan);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-title {
|
|
||||||
margin: 0 0 1rem;
|
|
||||||
font-size: 1.15rem;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: -0.01em;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.intro {
|
|
||||||
margin-bottom: 2.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.intro p {
|
|
||||||
margin: 0 0 1rem;
|
|
||||||
font-size: 0.98rem;
|
|
||||||
line-height: 1.6;
|
|
||||||
color: var(--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(--accent-cyan-bg);
|
|
||||||
border: 1px solid var(--border-very-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.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(--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);
|
|
||||||
}
|
|
||||||
|
|
||||||
.qr-card h3 {
|
|
||||||
margin: 0 0 0.35rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--accent-cyan);
|
|
||||||
}
|
|
||||||
|
|
||||||
.qr-card-desc {
|
|
||||||
margin: 0 0 0.75rem;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
line-height: 1.45;
|
|
||||||
color: var(--text-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.qr-link {
|
|
||||||
display: inline-block;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
font-size: 0.92rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--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(--accent-cyan);
|
|
||||||
outline-offset: 2px;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.qrcode-wrap {
|
|
||||||
align-self: center;
|
|
||||||
margin-top: auto;
|
|
||||||
padding: 0.5rem;
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 8px;
|
|
||||||
line-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.qrcode-wrap canvas,
|
|
||||||
.qrcode-wrap img {
|
|
||||||
display: block;
|
|
||||||
max-width: 100%;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.talks-section .section-title {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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,95 +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 class="masthead-lead">
|
|
||||||
Présentations techniques au format <strong><a href="https://revealjs.com/" target="_blank" rel="noopener noreferrer">Reveal.js</a></strong>
|
|
||||||
|
|
||||||
</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 possible selon les thèmes. Le contenu vit dans le dépôt Git <em>Talks</em> ; les mises à jour du
|
|
||||||
dossier <code>content/</code> sont déployées automatiquement.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
En salle ou en visio, vous pouvez partager l’URL des slides ou le dépôt source pour que le public récupère les
|
|
||||||
liens et le code — les QR codes ci-dessous facilitent l’accès depuis un téléphone.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="qr-section" aria-labelledby="qr-heading">
|
|
||||||
<h2 id="qr-heading" class="section-title">Liens rapides</h2>
|
|
||||||
<div class="qr-grid">
|
|
||||||
<article class="qr-card">
|
|
||||||
<h3>Dépôt de code</h3>
|
|
||||||
<p class="qr-card-desc">Sources, CI et configuration du site.</p>
|
|
||||||
<a class="qr-link" href="https://git.specificat.io/arnault/Talks">git.specificat.io/arnault/Talks</a>
|
|
||||||
<div id="qrcode-repo" class="qrcode-wrap" aria-hidden="true"></div>
|
|
||||||
</article>
|
|
||||||
<article class="qr-card">
|
|
||||||
<h3>Ce site (slides)</h3>
|
|
||||||
<p class="qr-card-desc">URL à projeter ou à partager pendant une présentation.</p>
|
|
||||||
<a class="qr-link" id="qr-site-link" href="./">…</a>
|
|
||||||
<div id="qrcode-site" class="qrcode-wrap" aria-hidden="true"></div>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="talks-section" aria-labelledby="talks-heading">
|
|
||||||
<h2 id="talks-heading" class="section-title">Présentations</h2>
|
|
||||||
<div class="talks-grid">
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<footer class="site-footer">
|
|
||||||
Contenu servi depuis le dépôt <em>Talks</em> (dossier <code>content/</code>).
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/qrcodejs/qrcode.min.js"></script>
|
|
||||||
<script>
|
|
||||||
(function () {
|
|
||||||
var u = new URL(window.location.href);
|
|
||||||
u.hash = "";
|
|
||||||
var siteUrl = u.href;
|
|
||||||
|
|
||||||
var siteLink = document.getElementById("qr-site-link");
|
|
||||||
if (siteLink) {
|
|
||||||
siteLink.href = siteUrl;
|
|
||||||
siteLink.textContent = siteUrl.replace(/^https?:\/\//, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
new QRCode(document.getElementById("qrcode-repo"), {
|
|
||||||
text: "https://git.specificat.io/arnault/Talks",
|
|
||||||
width: 200,
|
|
||||||
height: 200,
|
|
||||||
});
|
|
||||||
new QRCode(document.getElementById("qrcode-site"), {
|
|
||||||
text: siteUrl,
|
|
||||||
width: 200,
|
|
||||||
height: 200,
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
@@ -1,369 +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 blockquote .blockquote-sub {
|
|
||||||
display: block;
|
|
||||||
margin-top: 0.0em;
|
|
||||||
font-size: 0.88em;
|
|
||||||
color: var(--text-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.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 » (API / événements / controllers / réconciliation) : graphe Mermaid assez large — hauteur plafonnée.
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reveal section.slide-boucle .slide-boucle-lead {
|
|
||||||
margin: 0 0 0.45em;
|
|
||||||
padding: 0;
|
|
||||||
list-style: none;
|
|
||||||
font-size: clamp(0.82rem, 2.6vw, 0.95rem);
|
|
||||||
color: var(--text-muted);
|
|
||||||
line-height: 1.35;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mermaid sur la slide « boucle » : graphe fusionné (outils + boucle) — un peu plus de hauteur qu’avant */
|
|
||||||
.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(42vh, 300px) !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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Slide de fin : remerciement, Q/R, QR codes */
|
|
||||||
.reveal section.slide-fin h1 {
|
|
||||||
margin: 0 0 0.2em;
|
|
||||||
font-size: clamp(1.15em, 5vw, 1.45em);
|
|
||||||
}
|
|
||||||
|
|
||||||
.reveal section.slide-fin .slide-fin-thanks {
|
|
||||||
margin: 0 0 0.15em;
|
|
||||||
font-size: 0.52em;
|
|
||||||
line-height: 1.35;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.reveal section.slide-fin .slide-fin-cta {
|
|
||||||
margin: 0 0 0.35em;
|
|
||||||
font-size: 0.72em;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--accent-cyan);
|
|
||||||
}
|
|
||||||
|
|
||||||
.reveal section.slide-fin .slide-fin-hint {
|
|
||||||
margin: 0 auto 0.45em;
|
|
||||||
max-width: 28em;
|
|
||||||
font-size: 0.38em;
|
|
||||||
line-height: 1.4;
|
|
||||||
color: var(--text-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.reveal section.slide-fin .slide-fin-qr-grid {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: flex-end;
|
|
||||||
gap: 0.85em 1.75em;
|
|
||||||
margin: 0.15em 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reveal section.slide-fin .slide-fin-qr-block {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
border: none;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reveal section.slide-fin .slide-fin-qr-block figcaption {
|
|
||||||
margin: 0 0 0.35em;
|
|
||||||
font-size: 0.36em;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.reveal section.slide-fin .qrcode-fin-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);
|
|
||||||
}
|
|
||||||
|
|
||||||
.reveal section.slide-fin .qrcode-fin-wrap canvas,
|
|
||||||
.reveal section.slide-fin .qrcode-fin-wrap img {
|
|
||||||
display: block;
|
|
||||||
width: 150px;
|
|
||||||
height: 150px;
|
|
||||||
}
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
|
||||||
<svg
|
|
||||||
height="64"
|
|
||||||
viewBox="0 0 64 64"
|
|
||||||
width="64"
|
|
||||||
version="1.1"
|
|
||||||
id="svg12"
|
|
||||||
sodipodi:docname="helmsh-icon.svg"
|
|
||||||
inkscape:version="1.3.2 (091e20e, 2023-11-25)"
|
|
||||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
|
||||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
xmlns:svg="http://www.w3.org/2000/svg">
|
|
||||||
<defs
|
|
||||||
id="defs12" />
|
|
||||||
<sodipodi:namedview
|
|
||||||
id="namedview12"
|
|
||||||
pagecolor="#ffffff"
|
|
||||||
bordercolor="#000000"
|
|
||||||
borderopacity="0.25"
|
|
||||||
inkscape:showpageshadow="2"
|
|
||||||
inkscape:pageopacity="0.0"
|
|
||||||
inkscape:pagecheckerboard="0"
|
|
||||||
inkscape:deskcolor="#d1d1d1"
|
|
||||||
inkscape:zoom="2.2302632"
|
|
||||||
inkscape:cx="152"
|
|
||||||
inkscape:cy="175.31563"
|
|
||||||
inkscape:window-width="1392"
|
|
||||||
inkscape:window-height="997"
|
|
||||||
inkscape:window-x="1802"
|
|
||||||
inkscape:window-y="25"
|
|
||||||
inkscape:window-maximized="0"
|
|
||||||
inkscape:current-layer="svg12" />
|
|
||||||
<mask
|
|
||||||
id="a"
|
|
||||||
fill="#ffffff">
|
|
||||||
<path
|
|
||||||
d="M 0,0 H 313.30315 V 159.86486 H 0 Z"
|
|
||||||
fill="#ffffff"
|
|
||||||
fill-rule="evenodd"
|
|
||||||
id="path1" />
|
|
||||||
</mask>
|
|
||||||
<mask
|
|
||||||
id="b"
|
|
||||||
fill="#ffffff">
|
|
||||||
<path
|
|
||||||
d="M 0,0 H 313.30315 V 159.86486 H 0 Z"
|
|
||||||
fill="#ffffff"
|
|
||||||
fill-rule="evenodd"
|
|
||||||
id="path2" />
|
|
||||||
</mask>
|
|
||||||
<g
|
|
||||||
fill="none"
|
|
||||||
fill-rule="evenodd"
|
|
||||||
transform="matrix(0.1829216,0,0,0.1829216,1.8637371,-9.3897197)"
|
|
||||||
id="g12">
|
|
||||||
<path
|
|
||||||
d="m 11.678571,189 h 19.785836 v 26.789 h 23.9037 V 189 h 19.785836 v 75.25 H 55.368107 v -28.69533 h -23.9037 V 264.25 H 11.678571 Z m 86.173843,75.25 V 189 h 46.803046 v 16.35433 H 117.63825 V 217.595 h 23.9037 v 16.65533 h -23.9037 v 13.846 h 27.01721 V 264.25 Z m 68.497156,0 V 189 h 19.78584 v 55.384 h 27.11764 V 264.25 Z M 243.88594,189 274.61927,216.89267 305.25216,189 h 8.93878 v 75.25 H 294.30467 V 225.62167 L 274.61927,243.58133 254.83344,225.722 V 264.25 H 234.94716 V 189 Z"
|
|
||||||
fill="#0f1689"
|
|
||||||
id="path3" />
|
|
||||||
<g
|
|
||||||
transform="matrix(1,0,0,-1,11.958136,455)"
|
|
||||||
id="g7">
|
|
||||||
<g
|
|
||||||
fill="#0f1689"
|
|
||||||
mask="url(#a)"
|
|
||||||
id="g6">
|
|
||||||
<path
|
|
||||||
d="m 203.46068,95.687543 c 6.93631,0 12.5593,-14.80922 12.5593,-33.077318 0,-18.268097 -5.62299,-33.077317 -12.5593,-33.077317 -6.93631,0 -12.55931,14.80922 -12.55931,33.077317 0,18.268098 5.62299,33.077318 12.55931,33.077318 z"
|
|
||||||
transform="rotate(35,137.93117,151.55002)"
|
|
||||||
id="path4" />
|
|
||||||
<path
|
|
||||||
d="m 30.142322,95.687543 c 6.936311,0 12.559301,-14.80922 12.559301,-33.077318 0,-18.268097 -5.62299,-33.077317 -12.559301,-33.077317 -6.93631,0 -12.559301,14.80922 -12.559301,33.077317 0,18.268098 5.622991,33.077318 12.559301,33.077318 z"
|
|
||||||
transform="matrix(-0.81915204,0.57357644,0.57357644,0.81915204,58.084611,47.704768)"
|
|
||||||
id="path5" />
|
|
||||||
<path
|
|
||||||
d="m 116.73282,66.275268 c 6.93631,0 12.5593,-14.80922 12.5593,-33.077318 0,-18.268097 -5.62299,-33.07731673 -12.55931,-33.07731673 -6.93631,0 -12.5593,14.80921973 -12.5593,33.07731673 0,18.268098 5.62299,33.077318 12.55931,33.077318 z"
|
|
||||||
transform="matrix(-1,0,0,1,272.62852,53.670762)"
|
|
||||||
id="path6" />
|
|
||||||
</g>
|
|
||||||
<path
|
|
||||||
d="m 251.46701,173.09985 c -20.23008,-33.60997 -56.88957,-56.06791 -98.75578,-56.06791 -40.7208,0 -76.515877,21.2459 -97.058696,53.33459 m 2.198111,129.16953 c 20.840303,30.2327 55.555905,50.02659 94.860585,50.02659 39.3761,0 74.14642,-19.86588 94.97405,-50.19149"
|
|
||||||
mask="url(#a)"
|
|
||||||
stroke="#0f1689"
|
|
||||||
stroke-width="20"
|
|
||||||
id="path7" />
|
|
||||||
</g>
|
|
||||||
<g
|
|
||||||
transform="translate(11.958136)"
|
|
||||||
id="g11">
|
|
||||||
<g
|
|
||||||
fill="#0f1689"
|
|
||||||
mask="url(#b)"
|
|
||||||
id="g10">
|
|
||||||
<path
|
|
||||||
d="m 203.46068,95.687543 c 6.93631,0 12.5593,-14.80922 12.5593,-33.077318 0,-18.268097 -5.62299,-33.077317 -12.5593,-33.077317 -6.93631,0 -12.55931,14.80922 -12.55931,33.077317 0,18.268098 5.62299,33.077318 12.55931,33.077318 z"
|
|
||||||
transform="rotate(35,141.83137,150.32029)"
|
|
||||||
id="path8" />
|
|
||||||
<path
|
|
||||||
d="m 30.142322,95.687543 c 6.936311,0 12.559301,-14.80922 12.559301,-33.077318 0,-18.268097 -5.62299,-33.077317 -12.559301,-33.077317 -6.93631,0 -12.559301,14.80922 -12.559301,33.077317 0,18.268098 5.622991,33.077318 12.559301,33.077318 z"
|
|
||||||
transform="matrix(-0.81915204,0.57357644,0.57357644,0.81915204,58.084611,45.245308)"
|
|
||||||
id="path9" />
|
|
||||||
<path
|
|
||||||
d="m 116.73282,66.275268 c 6.93631,0 12.5593,-14.80922 12.5593,-33.077318 0,-18.268097 -5.62299,-33.07731673 -12.55931,-33.07731673 -6.93631,0 -12.5593,14.80921973 -12.5593,33.07731673 0,18.268098 5.62299,33.077318 12.55931,33.077318 z"
|
|
||||||
transform="matrix(-1,0,0,1,272.62852,51.211302)"
|
|
||||||
id="path10" />
|
|
||||||
</g>
|
|
||||||
<path
|
|
||||||
d="m 251.46701,170.64039 c -20.23008,-33.60997 -56.88957,-56.06791 -98.75578,-56.06791 -40.7208,0 -76.515877,21.2459 -97.058696,53.33459 m 2.198111,129.16953 c 20.840303,30.2327 55.555905,50.02659 94.860585,50.02659 39.3761,0 74.14642,-19.86588 94.97405,-50.19149"
|
|
||||||
mask="url(#b)"
|
|
||||||
stroke="#0f1689"
|
|
||||||
stroke-width="20"
|
|
||||||
id="path11" />
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 5.2 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
File diff suppressed because it is too large
Load Diff
@@ -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).
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
# syntax=docker/dockerfile:1
|
|
||||||
# Même logique que l’image upstream : UID/GID numériques, pas le nom « nginx ».
|
|
||||||
# https://github.com/nginxinc/docker-nginx-unprivileged/blob/main/stable/alpine/Dockerfile
|
|
||||||
FROM nginxinc/nginx-unprivileged:stable-alpine
|
|
||||||
|
|
||||||
ARG UID=101
|
|
||||||
ARG GID=101
|
|
||||||
|
|
||||||
USER root
|
|
||||||
RUN --mount=type=cache,target=/var/cache/apk \
|
|
||||||
apk add --no-cache git
|
|
||||||
|
|
||||||
WORKDIR /usr/share/nginx/html
|
|
||||||
|
|
||||||
ARG TALKS_REPO_URL=https://git.specificat.io/arnault/Talks.git
|
|
||||||
ARG TALKS_BRANCH=main
|
|
||||||
ARG TALKS_SPARSE_DIR=content
|
|
||||||
|
|
||||||
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}"
|
|
||||||
|
|
||||||
COPY nginx/default.conf /etc/nginx/conf.d/default.conf
|
|
||||||
COPY refresh.sh /refresh.sh
|
|
||||||
|
|
||||||
# Pas de « su 101 » : git config en root puis chown. HOME=/tmp : pas de /home artificiel dans une image stateless.
|
|
||||||
RUN chmod +x /refresh.sh \
|
|
||||||
&& chown -R "${UID}:${GID}" /usr/share/nginx/html /refresh.sh \
|
|
||||||
&& HOME=/tmp git config --global --add safe.directory /usr/share/nginx/html \
|
|
||||||
&& chown "${UID}:${GID}" /tmp/.gitconfig
|
|
||||||
|
|
||||||
ENV HOME=/tmp
|
|
||||||
|
|
||||||
USER ${UID}
|
|
||||||
|
|
||||||
EXPOSE 8080
|
|
||||||
|
|
||||||
CMD sh -c "/refresh.sh & exec nginx -g 'daemon off;'"
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
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;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
add_header Cache-Control "no-cache";
|
|
||||||
try_files $uri $uri/ =404;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,577 @@
|
|||||||
|
---
|
||||||
|
theme: default
|
||||||
|
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,43 @@
|
|||||||
|
---
|
||||||
|
theme: default
|
||||||
|
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";
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# Patterns to ignore when building packages.
|
|
||||||
.DS_Store
|
|
||||||
.git/
|
|
||||||
*.tgz
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
apiVersion: v2
|
|
||||||
name: talks-slides-chart
|
|
||||||
description: Helm chart for Talks slides (Nginx + contenu git)
|
|
||||||
type: application
|
|
||||||
version: 0.1.0
|
|
||||||
appVersion: "0.1.0"
|
|
||||||
keywords:
|
|
||||||
- slides
|
|
||||||
- nginx
|
|
||||||
- reveal
|
|
||||||
maintainers: []
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
Déploiement Talks slides (Nginx).
|
|
||||||
Vérifiez l’Ingress et le DNS pour l’URL publique.
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
{{/*
|
|
||||||
Expand the name of the chart.
|
|
||||||
*/}}
|
|
||||||
{{- define "talks-slides.name" -}}
|
|
||||||
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
|
|
||||||
{{- end }}
|
|
||||||
|
|
||||||
{{/*
|
|
||||||
Create a default fully qualified app name.
|
|
||||||
*/}}
|
|
||||||
{{- define "talks-slides.fullname" -}}
|
|
||||||
{{- if .Values.fullnameOverride }}
|
|
||||||
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
|
|
||||||
{{- else }}
|
|
||||||
{{- $name := default .Chart.Name .Values.nameOverride }}
|
|
||||||
{{- if contains $name .Release.Name }}
|
|
||||||
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
|
|
||||||
{{- else }}
|
|
||||||
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
|
|
||||||
{{- end }}
|
|
||||||
{{- end }}
|
|
||||||
{{- end }}
|
|
||||||
|
|
||||||
{{/*
|
|
||||||
Common labels
|
|
||||||
*/}}
|
|
||||||
{{- define "talks-slides.labels" -}}
|
|
||||||
helm.sh/chart: {{ include "talks-slides.name" . }}-{{ .Chart.Version | replace "+" "_" }}
|
|
||||||
app.kubernetes.io/name: {{ include "talks-slides.name" . }}
|
|
||||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
|
||||||
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
|
||||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
|
||||||
{{- end }}
|
|
||||||
|
|
||||||
{{/*
|
|
||||||
Selector labels
|
|
||||||
*/}}
|
|
||||||
{{- define "talks-slides.selectorLabels" -}}
|
|
||||||
app.kubernetes.io/name: {{ include "talks-slides.name" . }}
|
|
||||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
|
||||||
{{- end }}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
name: {{ include "talks-slides.fullname" . }}
|
|
||||||
labels:
|
|
||||||
{{- include "talks-slides.labels" . | nindent 4 }}
|
|
||||||
spec:
|
|
||||||
{{- if not .Values.slides.autoscaling.enabled }}
|
|
||||||
replicas: {{ .Values.slides.replicaCount }}
|
|
||||||
{{- end }}
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
{{- include "talks-slides.selectorLabels" . | nindent 6 }}
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
{{- include "talks-slides.selectorLabels" . | nindent 8 }}
|
|
||||||
{{- with .Values.slides.podLabels }}
|
|
||||||
{{- toYaml . | nindent 8 }}
|
|
||||||
{{- end }}
|
|
||||||
spec:
|
|
||||||
{{- with .Values.slides.imagePullSecrets }}
|
|
||||||
imagePullSecrets:
|
|
||||||
{{- toYaml . | nindent 8 }}
|
|
||||||
{{- end }}
|
|
||||||
securityContext:
|
|
||||||
{{- 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
|
|
||||||
# Image : USER nginx + master non-root → pas de SETUID/SETGID ; port 8080 → pas de NET_BIND_SERVICE.
|
|
||||||
capabilities:
|
|
||||||
drop: ["ALL"]
|
|
||||||
readOnlyRootFilesystem: false
|
|
||||||
ports:
|
|
||||||
- name: http
|
|
||||||
containerPort: {{ .Values.slides.containerPort }}
|
|
||||||
protocol: TCP
|
|
||||||
livenessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /
|
|
||||||
port: http
|
|
||||||
initialDelaySeconds: 10
|
|
||||||
periodSeconds: 30
|
|
||||||
readinessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /
|
|
||||||
port: http
|
|
||||||
initialDelaySeconds: 5
|
|
||||||
periodSeconds: 10
|
|
||||||
resources:
|
|
||||||
{{- toYaml .Values.slides.resources | nindent 12 }}
|
|
||||||
{{- with .Values.slides.nodeSelector }}
|
|
||||||
nodeSelector:
|
|
||||||
{{- toYaml . | nindent 8 }}
|
|
||||||
{{- end }}
|
|
||||||
{{- with .Values.slides.affinity }}
|
|
||||||
affinity:
|
|
||||||
{{- toYaml . | nindent 8 }}
|
|
||||||
{{- end }}
|
|
||||||
{{- with .Values.slides.tolerations }}
|
|
||||||
tolerations:
|
|
||||||
{{- toYaml . | nindent 8 }}
|
|
||||||
{{- end }}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
{{- if .Values.slides.ingress.enabled -}}
|
|
||||||
{{- $tlsOn := or .Values.slides.ingress.tls.enabled .Values.slides.ingress.certManager.enabled -}}
|
|
||||||
apiVersion: networking.k8s.io/v1
|
|
||||||
kind: Ingress
|
|
||||||
metadata:
|
|
||||||
name: {{ include "talks-slides.fullname" . }}
|
|
||||||
labels:
|
|
||||||
{{- include "talks-slides.labels" . | nindent 4 }}
|
|
||||||
{{- if or .Values.slides.ingress.certManager.enabled .Values.slides.ingress.annotations }}
|
|
||||||
annotations:
|
|
||||||
{{- if .Values.slides.ingress.certManager.enabled }}
|
|
||||||
cert-manager.io/cluster-issuer: {{ .Values.slides.ingress.certManager.clusterIssuer | quote }}
|
|
||||||
{{- end }}
|
|
||||||
{{- with .Values.slides.ingress.annotations }}
|
|
||||||
{{- toYaml . | nindent 4 }}
|
|
||||||
{{- end }}
|
|
||||||
{{- end }}
|
|
||||||
spec:
|
|
||||||
{{- if .Values.slides.ingress.className }}
|
|
||||||
ingressClassName: {{ .Values.slides.ingress.className }}
|
|
||||||
{{- end }}
|
|
||||||
{{- if $tlsOn }}
|
|
||||||
tls:
|
|
||||||
- hosts:
|
|
||||||
{{- range .Values.slides.ingress.hosts }}
|
|
||||||
- {{ .host | quote }}
|
|
||||||
{{- end }}
|
|
||||||
secretName: {{ .Values.slides.ingress.tls.secretName | default (printf "%s-tls" (include "talks-slides.fullname" .)) }}
|
|
||||||
{{- end }}
|
|
||||||
rules:
|
|
||||||
{{- range .Values.slides.ingress.hosts }}
|
|
||||||
- host: {{ .host | quote }}
|
|
||||||
http:
|
|
||||||
paths:
|
|
||||||
{{- range .paths }}
|
|
||||||
- path: {{ .path }}
|
|
||||||
pathType: {{ .pathType }}
|
|
||||||
backend:
|
|
||||||
service:
|
|
||||||
name: {{ include "talks-slides.fullname" $ }}
|
|
||||||
port:
|
|
||||||
number: {{ $.Values.slides.service.port }}
|
|
||||||
{{- end }}
|
|
||||||
{{- end }}
|
|
||||||
{{- end }}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
apiVersion: v1
|
|
||||||
kind: Service
|
|
||||||
metadata:
|
|
||||||
name: {{ include "talks-slides.fullname" . }}
|
|
||||||
labels:
|
|
||||||
{{- include "talks-slides.labels" . | nindent 4 }}
|
|
||||||
spec:
|
|
||||||
type: {{ .Values.slides.service.type }}
|
|
||||||
ports:
|
|
||||||
- port: {{ .Values.slides.service.port }}
|
|
||||||
targetPort: http
|
|
||||||
protocol: TCP
|
|
||||||
name: http
|
|
||||||
selector:
|
|
||||||
{{- include "talks-slides.selectorLabels" . | nindent 4 }}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
# Image : git.specificat.io/specificat.io/talks-slides
|
|
||||||
# Avec release "talks-slides" et nameOverride "talks-slides", le fullname reste cohérent.
|
|
||||||
nameOverride: "talks-slides"
|
|
||||||
fullnameOverride: ""
|
|
||||||
|
|
||||||
slides:
|
|
||||||
image:
|
|
||||||
repository: git.specificat.io/specificat.io/talks-slides
|
|
||||||
tag: "latest"
|
|
||||||
pullPolicy: IfNotPresent
|
|
||||||
|
|
||||||
imagePullSecrets:
|
|
||||||
- name: prd-gitea-registry-secret
|
|
||||||
|
|
||||||
replicaCount: 1
|
|
||||||
|
|
||||||
# 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.
|
|
||||||
port: 80
|
|
||||||
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
memory: 128Mi
|
|
||||||
requests:
|
|
||||||
cpu: 10m
|
|
||||||
memory: 32Mi
|
|
||||||
|
|
||||||
autoscaling:
|
|
||||||
enabled: false
|
|
||||||
|
|
||||||
nodeSelector: {}
|
|
||||||
affinity: {}
|
|
||||||
tolerations: []
|
|
||||||
podLabels: {}
|
|
||||||
|
|
||||||
ingress:
|
|
||||||
enabled: true
|
|
||||||
className: ""
|
|
||||||
annotations: {}
|
|
||||||
hosts:
|
|
||||||
- host: slides.specificat.io
|
|
||||||
paths:
|
|
||||||
- path: /
|
|
||||||
pathType: Prefix
|
|
||||||
# TLS : soit manuel (tls.enabled + secretName), soit via cert-manager (certManager.enabled).
|
|
||||||
# Le nom d’hôte vient de hosts[].host (réutilisé pour le bloc tls.hosts).
|
|
||||||
certManager:
|
|
||||||
enabled: false
|
|
||||||
# Ex. letsencrypt-prod — requis si certManager.enabled est true
|
|
||||||
clusterIssuer: ""
|
|
||||||
tls:
|
|
||||||
enabled: false
|
|
||||||
# Secret TLS dans le namespace (créé par cert-manager ou importé à la main).
|
|
||||||
# Vide : suffixe -tls sur le nom complet de la release (ex. talks-slides-tls).
|
|
||||||
secretName: ""
|
|
||||||
@@ -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