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
+23
View File
@@ -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é')
+69
View File
@@ -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)
}
+40
View File
@@ -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))
+20
View File
@@ -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'))
)
})
}
+105
View File
@@ -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)
}
+158
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
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 }
}
+93
View File
@@ -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))
+30
View File
@@ -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
}
+68
View File
@@ -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}/`)
})
+67
View File
@@ -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()