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:
Le Prévost-Corvellec Arnault
2026-04-11 15:59:20 +02:00
parent 10f7798afb
commit 354c8cd9ef
70 changed files with 16388 additions and 2469 deletions
+43
View File
@@ -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>
+46
View File
@@ -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.',
},
})
+30
View File
@@ -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"
}
]
+401
View File
@@ -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;
}
+13
View File
@@ -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()
})
})
},
}
}
+59
View File
@@ -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>
+29
View File
@@ -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.
:::
+29
View File
@@ -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.
:::