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
+31
View File
@@ -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`)
}
})
})
+146
View File
@@ -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">'), '&lt;a href=&quot;x&quot;&gt;')
})
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, /&lt;evil&gt;/)
assert.match(html, /d &amp; 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)
})
})
+90
View File
@@ -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')
}
})
})
+46
View File
@@ -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(), '/')
})
})
+55
View File
@@ -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'))
})
+129
View File
@@ -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)
})
})