alpinux.presentations/scripts/build.mjs
Cédrix 9ecf1973b8 Suivre le nom réel du dépôt : alpinux.presentations
Le dépôt a été créé sur la forge sous le nom alpinux.presentations, et non
alpinux-presentations : adresses de clonage, lien de la page d'accueil et
consignes d'installation sont alignés.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M713yNBL8cssBA1Zri5QLT
2026-10-10 22:06:47 +02:00

599 lines
26 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
// Construit le site des présentations d'Alpinux.
//
// node scripts/build.mjs [sortie] build complet (sortie : public/ par défaut)
// node scripts/build.mjs --pdf <identifiant> [sortie]
// un PDF, sur un poste qui a un navigateur
//
// Le build est strict, comme « mkdocs build --strict » pour le wiki : un front matter
// incomplet, une image introuvable ou une ressource chargée depuis un autre site le
// font échouer avant d'avoir écrit quoi que ce soit dans le répertoire de sortie.
import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
import { dirname, join, relative, resolve, sep } from 'node:path'
import { fileURLToPath } from 'node:url'
import { marpCli } from '@marp-team/marp-cli'
import { CORE_SCHEMA, load } from 'js-yaml'
const RACINE = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const SOURCES = join(RACINE, 'slides')
const THEME = join(RACINE, 'theme', 'alpinux.css')
const NOM_DU_THEME = 'alpinux'
// Adresse publique du site : elle ne sert qu'aux URL absolues de presentations.json.
const URL_SITE = (process.env.URL_SITE ?? 'https://presentations.alpinux.org').replace(/\/+$/, '')
const URL_LOGO = 'https://static.alpinux.org/logo/alpinux-logo-512.png'
// Seules adresses qu'une page générée a le droit de charger depuis un autre site.
const RESSOURCES_EXTERNES_ADMISES = new Set([URL_LOGO])
const NIVEAUX = { debutant: 'Débutant', intermediaire: 'Intermédiaire', confirme: 'Confirmé' }
const FORMATS = { eclair: 'Éclair', court: 'Court', long: 'Long' }
const CHAMPS_OBLIGATOIRES = ['title', 'description', 'auteur', 'date', 'niveau', 'format', 'tags']
const CHAMPS_FACULTATIFS = ['wiki', 'brouillon']
// Directives de Marp admises dans le front matter. Toute autre clé est refusée : une
// faute de frappe sur « brouillon » publierait sinon une présentation inachevée.
const DIRECTIVES_MARP = [
'marp', 'theme', 'paginate', 'header', 'footer', 'class', 'size', 'style', 'lang', 'math',
'headingDivider', 'transition', 'color', 'backgroundColor', 'backgroundImage',
'backgroundPosition', 'backgroundRepeat', 'backgroundSize',
]
const CLES_CONNUES = new Set([...CHAMPS_OBLIGATOIRES, ...CHAMPS_FACULTATIFS, ...DIRECTIVES_MARP])
const FORME_IDENTIFIANT = /^[a-z0-9]+(-[a-z0-9]+)*$/
class ErreurDeBuild extends Error {}
// ── Lecture et validation des sources ──────────────────────────────────────
function lireFrontMatter(texte) {
const trouve = /^?---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(\r?\n|$)/.exec(texte)
if (!trouve) {
throw new ErreurDeBuild('pas de front matter : le fichier doit commencer par un bloc encadré de « --- »')
}
let donnees
try {
// CORE_SCHEMA : les dates restent des chaînes, telles qu'elles sont écrites.
donnees = load(trouve[1], { schema: CORE_SCHEMA })
} catch (erreur) {
throw new ErreurDeBuild(`front matter illisible (YAML) : ${erreur.reason ?? erreur.message}` +
(erreur.mark ? `, ligne ${erreur.mark.line + 2}` : ''))
}
if (donnees === null || typeof donnees !== 'object' || Array.isArray(donnees)) {
throw new ErreurDeBuild('front matter vide ou mal formé : une liste de « clé: valeur » est attendue')
}
return donnees
}
const estTexte = (valeur) => typeof valeur === 'string' && valeur.trim() !== ''
function estUneDate(valeur) {
if (typeof valeur !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(valeur)) return false
const date = new Date(`${valeur}T00:00:00Z`)
return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === valeur
}
function estUneAdresseWeb(valeur) {
if (typeof valeur !== 'string') return false
try {
return ['http:', 'https:'].includes(new URL(valeur).protocol)
} catch {
return false
}
}
/** Renvoie la liste des erreurs du front matter — vide s'il est bon. */
function validerFrontMatter(fm) {
const erreurs = []
const choix = (valeurs) => Object.keys(valeurs).join(' | ')
for (const champ of CHAMPS_OBLIGATOIRES) {
if (fm[champ] === undefined || fm[champ] === null || fm[champ] === '') {
erreurs.push(`champ obligatoire manquant : « ${champ} »`)
}
}
for (const cle of Object.keys(fm)) {
if (!CLES_CONNUES.has(cle)) {
erreurs.push(`champ inconnu : « ${cle} » (faute de frappe ? champs admis : ${[...CHAMPS_OBLIGATOIRES, ...CHAMPS_FACULTATIFS].join(', ')})`)
}
}
const present = (champ) => fm[champ] !== undefined && fm[champ] !== null && fm[champ] !== ''
for (const champ of ['title', 'description', 'auteur']) {
if (present(champ) && !estTexte(fm[champ])) erreurs.push(`« ${champ} » doit être un texte`)
}
if (present('date') && !estUneDate(fm.date)) {
erreurs.push(`« date » invalide : « ${fm.date} » — attendu AAAA-MM-JJ, la date de la séance (exemple : 2026-10-15)`)
}
if (present('niveau') && !Object.hasOwn(NIVEAUX, fm.niveau)) {
erreurs.push(`« niveau » invalide : « ${fm.niveau} » — attendu ${choix(NIVEAUX)}`)
}
if (present('format') && !Object.hasOwn(FORMATS, fm.format)) {
erreurs.push(`« format » invalide : « ${fm.format} » — attendu ${choix(FORMATS)}`)
}
if (present('tags')) {
if (!Array.isArray(fm.tags) || fm.tags.length === 0 || !fm.tags.every(estTexte)) {
erreurs.push('« tags » doit être une liste d\'au moins un mot-clé (exemple : tags: [pdf, bureautique])')
}
}
if (present('wiki') && !estUneAdresseWeb(fm.wiki)) {
erreurs.push(`« wiki » invalide : « ${fm.wiki} » — attendu une adresse complète, en https://`)
}
if (fm.theme !== undefined && fm.theme !== NOM_DU_THEME) {
erreurs.push(`« theme » invalide : « ${fm.theme} » — seul le thème commun « ${NOM_DU_THEME} » est publié`)
}
return erreurs
}
/**
* Parcourt slides/ et renvoie les présentations à publier, de la plus récente à la
* plus ancienne. Lève une ErreurDeBuild qui détaille tous les défauts trouvés.
*/
function collecterLesPresentations() {
if (!existsSync(SOURCES)) throw new ErreurDeBuild(`répertoire introuvable : ${SOURCES}`)
const presentations = []
const brouillons = []
const defauts = []
const dossiers = readdirSync(SOURCES, { withFileTypes: true })
.filter((entree) => entree.isDirectory() && !entree.name.startsWith('_') && !entree.name.startsWith('.'))
.map((entree) => entree.name)
.sort()
for (const id of dossiers) {
const source = join(SOURCES, id, 'slides.md')
const chemin = relative(RACINE, source)
const signaler = (...messages) => defauts.push(...messages.map((message) => `${chemin} : ${message}`))
if (!FORME_IDENTIFIANT.test(id)) {
signaler(`nom de dossier « ${id} » invalide — minuscules sans accent, chiffres et tirets seulement (exemple : pdf-signer)`)
continue
}
if (!existsSync(source)) {
signaler('fichier absent — chaque dossier de slides/ doit contenir un slides.md')
continue
}
let fm
try {
fm = lireFrontMatter(readFileSync(source, 'utf8'))
} catch (erreur) {
if (!(erreur instanceof ErreurDeBuild)) throw erreur
signaler(erreur.message)
continue
}
if (fm.brouillon !== undefined && typeof fm.brouillon !== 'boolean') {
signaler(`« brouillon » invalide : « ${fm.brouillon} » — attendu true ou false`)
continue
}
if (fm.brouillon === true) {
brouillons.push(id)
continue
}
const erreurs = validerFrontMatter(fm)
if (erreurs.length > 0) {
signaler(...erreurs)
continue
}
presentations.push({
id,
source,
titre: fm.title.trim(),
description: fm.description.trim(),
auteur: fm.auteur.trim(),
date: fm.date,
niveau: fm.niveau,
format: fm.format,
tags: fm.tags.map((tag) => tag.trim()),
wiki: fm.wiki ?? null,
langue: typeof fm.lang === 'string' ? fm.lang : 'fr',
})
}
if (defauts.length > 0) {
throw new ErreurDeBuild(`${defauts.length} erreur(s) dans les sources :\n` + defauts.map((defaut) => ` - ${defaut}`).join('\n'))
}
presentations.sort((a, b) => b.date.localeCompare(a.date) || a.titre.localeCompare(b.titre, 'fr'))
return { presentations, brouillons }
}
// ── Diaporamas ─────────────────────────────────────────────────────────────
/** Lance Marp CLI en gardant sa sortie pour nous : elle n'est montrée qu'en cas d'échec. */
async function lancerMarp(arguments_) {
const journal = []
const originaux = { log: console.log, info: console.info, warn: console.warn, error: console.error }
for (const methode of Object.keys(originaux)) {
console[methode] = (...morceaux) => journal.push(morceaux.join(' '))
}
let code
try {
code = await marpCli(['--no-stdin', '--no-config-file', ...arguments_])
} catch (erreur) {
journal.push(String(erreur?.message ?? erreur))
code = 1
} finally {
Object.assign(console, originaux)
}
return { code, journal: journal.join('\n').trim() }
}
const adresses = (html, motif) => [...html.matchAll(motif)].map((trouve) => trouve[1].replaceAll('&amp;', '&'))
/**
* Vérifie ce que le diaporama généré charge : rien d'un autre site hormis le logo, et
* des images locales qui existent bien dans le dossier images/ de la présentation.
*/
function controlerLesRessources(html, presentation) {
const erreurs = []
const chargees = new Set([
...adresses(html, /<(?:img|script|source|video|audio|iframe|embed|track)\b[^>]*?\ssrc=["']([^"']+)["']/gi),
...adresses(html, /<link\b[^>]*?\shref=["']([^"']+)["']/gi),
...adresses(html, /url\(\s*(?:&quot;|["'])?([^"')&]+)/gi),
...adresses(html, /@import\s+(?:url\()?\s*(?:&quot;|["'])([^"')&]+)/gi),
])
for (const adresse of chargees) {
if (adresse.startsWith('data:') || adresse.startsWith('#')) continue
if (/^([a-z][a-z0-9+.-]*:)?\/\//i.test(adresse)) {
if (!RESSOURCES_EXTERNES_ADMISES.has(adresse)) {
erreurs.push(`ressource externe interdite : ${adresse} — copiez le fichier dans images/ et citez-le par son chemin relatif`)
}
continue
}
const chemin = decodeURI(adresse.split(/[?#]/)[0])
if (!chemin.startsWith('images/') || chemin.split('/').includes('..')) {
erreurs.push(`fichier hors du dossier images/ : ${adresse} — seul images/ est publié avec le diaporama`)
} else if (!existsSync(join(dirname(presentation.source), chemin))) {
erreurs.push(`image introuvable : ${adresse}`)
}
}
return erreurs
}
async function genererLeDiaporama(presentation, sortie) {
const chemin = relative(RACINE, presentation.source)
const dossier = join(sortie, presentation.id)
const page = join(dossier, 'index.html')
mkdirSync(dossier, { recursive: true })
const { code, journal } = await lancerMarp([
presentation.source, '-o', page, '--theme-set', THEME, '--theme', NOM_DU_THEME,
])
if (code !== 0 || !existsSync(page)) {
throw new ErreurDeBuild(`${chemin} : Marp n'a pas pu générer le diaporama.\n${journal}`)
}
let html = readFileSync(page, 'utf8')
const erreurs = controlerLesRessources(html, presentation)
if (erreurs.length > 0) {
throw new ErreurDeBuild(erreurs.map((erreur) => `${chemin} : ${erreur}`).join('\n'))
}
// Marp écrit lang="en" faute de directive : le contenu est en français.
html = html.replace(/<html\b[^>]*>/i, (balise) => /\slang=/.test(balise)
? balise.replace(/\slang=["'][^"']*["']/, ` lang="${presentation.langue}"`)
: balise.replace(/<html/i, `<html lang="${presentation.langue}"`))
writeFileSync(page, html)
const images = join(dirname(presentation.source), 'images')
if (existsSync(images)) cpSync(images, join(dossier, 'images'), { recursive: true })
}
// ── Page d'accueil, JSON et .htaccess ──────────────────────────────────────
const echapper = (texte) => String(texte)
.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')
.replaceAll('"', '&quot;').replaceAll("'", '&#39;')
/** Minuscules sans accents : « Débutant » et « debutant » se retrouvent. */
const normaliser = (texte) => texte.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase()
const dateLisible = (date) => new Intl.DateTimeFormat('fr-FR', { dateStyle: 'long', timeZone: 'UTC' })
.format(new Date(`${date}T00:00:00Z`))
function carte(presentation) {
const p = presentation
const recherche = normaliser([p.titre, ...p.tags].join(' '))
const tags = p.tags.map((tag) => `<li>${echapper(tag)}</li>`).join('')
const lienWiki = p.wiki
? `\n <a class="lien" href="${echapper(p.wiki)}">Fiche sur le wiki<span class="lu-seulement"> : ${echapper(p.titre)}</span></a>`
: ''
return ` <li class="carte" data-niveau="${p.niveau}" data-format="${p.format}" data-recherche="${echapper(recherche)}">
<article>
<h2><a href="${p.id}/">${echapper(p.titre)}</a></h2>
<p class="meta"><time datetime="${p.date}">${dateLisible(p.date)}</time> · ${echapper(p.auteur)}</p>
<p class="description">${echapper(p.description)}</p>
<ul class="etiquettes" aria-label="Niveau, format et mots-clés">
<li class="niveau">${NIVEAUX[p.niveau]}</li><li class="format">${FORMATS[p.format]}</li>${tags}
</ul>
<a class="lien principal" href="${p.id}/">Ouvrir le diaporama<span class="lu-seulement"> : ${echapper(p.titre)}</span></a>${lienWiki}
</article>
</li>`
}
const options = (valeurs) => Object.entries(valeurs)
.map(([valeur, libelle]) => `<option value="${valeur}">${libelle}</option>`).join('')
function pageAccueil(presentations) {
const total = presentations.length
const compte = total === 0 ? 'Aucune présentation' : total === 1 ? '1 présentation' : `${total} présentations`
return `<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Présentations d'Alpinux</title>
<meta name="description" content="Les diaporamas des réunions d'Alpinux, le groupe d'utilisateurs de logiciels libres de Savoie.">
<meta name="color-scheme" content="light dark">
<link rel="icon" href="${URL_LOGO}">
<link rel="alternate" type="application/json" href="presentations.json" title="Liste des présentations">
<style>
:root {
--fond: #f3f6fb; --surface: #ffffff; --texte: #1a1a2e; --texte-doux: #4a5568;
--bleu: #1a6bbf; --bleu-fonce: #0f4e8f; --trait: #c9d6e8; --lien: #0f4e8f;
--pastille: #e4ecf7; --pastille-texte: #0b2e55; --focus: #b45309;
}
@media (prefers-color-scheme: dark) {
:root {
--fond: #0e1726; --surface: #172338; --texte: #eef3fa; --texte-doux: #b4c2d6;
--trait: #2c3e5a; --lien: #9fd0ff; --pastille: #24456e; --pastille-texte: #eef3fa;
--focus: #ffc857;
}
}
*, *::before, *::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0; min-height: 100vh; display: flex; flex-direction: column;
font-family: "Segoe UI", system-ui, -apple-system, "Noto Sans", "DejaVu Sans", sans-serif;
font-size: 1.05rem; line-height: 1.55; background: var(--fond); color: var(--texte);
}
a { color: var(--lien); }
:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; border-radius: 4px; }
.lu-seulement {
position: absolute; width: 1px; height: 1px; overflow: hidden;
clip-path: inset(50%); white-space: nowrap;
}
.evitement {
position: absolute; left: .5rem; top: -4rem; padding: .5rem .9rem; border-radius: 6px;
background: #fff; color: #0f4e8f; font-weight: 600; z-index: 1;
}
.evitement:focus { top: .5rem; }
header.bandeau { background: var(--bleu); color: #fff; padding: 1.6rem 1.2rem; }
.bandeau .cadre { display: flex; align-items: center; gap: 1rem; }
.bandeau img { width: 64px; height: 64px; border-radius: 10px; background: #fff; flex: none; }
.bandeau h1 { margin: 0; font-size: clamp(1.5rem, 5vw, 2.1rem); line-height: 1.1; letter-spacing: -.01em; }
.bandeau p { margin: .25rem 0 0; color: #e6f0fb; }
.cadre { width: 100%; max-width: 62rem; margin: 0 auto; }
main { flex: 1; padding: 1.4rem 1.2rem 2.5rem; }
form.filtres {
display: grid; grid-template-columns: 2fr 1fr 1fr; gap: .9rem; margin-bottom: 1rem;
padding: 1rem; background: var(--surface); border: 1px solid var(--trait); border-radius: 12px;
}
.filtres label { display: block; font-size: .9rem; font-weight: 600; margin-bottom: .25rem; }
.filtres input, .filtres select {
width: 100%; min-height: 2.75rem; padding: .45rem .7rem; font: inherit;
color: var(--texte); background: var(--fond); border: 2px solid var(--trait); border-radius: 8px;
}
.compte { margin: 0 0 1rem; color: var(--texte-doux); }
ul.cartes { list-style: none; margin: 0; padding: 0; display: grid; gap: 1rem; grid-template-columns: repeat(auto-fill, minmax(min(100%, 19rem), 1fr)); }
.carte article {
height: 100%; display: flex; flex-direction: column; gap: .55rem; padding: 1.1rem 1.2rem 1.2rem;
background: var(--surface); border: 1px solid var(--trait); border-top: 5px solid var(--bleu); border-radius: 12px;
}
.carte h2 { margin: 0; font-size: 1.25rem; line-height: 1.25; }
.carte h2 a { text-decoration: none; }
.carte h2 a:hover { text-decoration: underline; }
.carte p { margin: 0; }
.meta { color: var(--texte-doux); font-size: .92rem; }
.description { flex: 1; }
.etiquettes { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: .35rem; font-size: .85rem; }
.etiquettes li { padding: .1rem .6rem; border-radius: 999px; background: var(--pastille); color: var(--pastille-texte); }
.etiquettes .niveau, .etiquettes .format { background: var(--bleu-fonce); color: #fff; font-weight: 600; }
.lien { font-weight: 600; }
.lien.principal {
align-self: flex-start; margin-top: .3rem; padding: .5rem 1rem; border-radius: 8px;
background: var(--bleu-fonce); color: #fff; text-decoration: none;
}
.lien.principal:hover { background: var(--bleu); }
.vide { padding: 2rem 1rem; text-align: center; background: var(--surface); border: 1px dashed var(--trait); border-radius: 12px; }
footer.pied { padding: 1.2rem; border-top: 1px solid var(--trait); color: var(--texte-doux); font-size: .92rem; text-align: center; }
[hidden] { display: none !important; }
@media (max-width: 40rem) { form.filtres { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<a class="evitement" href="#liste">Aller à la liste des présentations</a>
<header class="bandeau">
<div class="cadre">
<img src="${URL_LOGO}" alt="" width="64" height="64">
<div>
<h1>Présentations d'Alpinux</h1>
<p>Les diaporamas des réunions du groupe d'utilisateurs de logiciels libres de Savoie.</p>
</div>
</div>
</header>
<main>
<div class="cadre">
<form class="filtres" id="filtres" role="search" aria-label="Filtrer les présentations" hidden>
<div>
<label for="recherche">Rechercher dans les titres et les mots-clés</label>
<input type="search" id="recherche" autocomplete="off" spellcheck="false">
</div>
<div>
<label for="niveau">Niveau</label>
<select id="niveau"><option value="">Tous les niveaux</option>${options(NIVEAUX)}</select>
</div>
<div>
<label for="format">Format</label>
<select id="format"><option value="">Tous les formats</option>${options(FORMATS)}</select>
</div>
</form>
<p class="compte" id="compte" role="status">${compte}, de la plus récente à la plus ancienne.</p>
<ul class="cartes" id="liste" tabindex="-1">
${presentations.map(carte).join('\n')}
</ul>
<p class="vide" id="vide"${total === 0 ? '' : ' hidden'}>${total === 0 ? 'Aucune présentation n\'est encore publiée.' : 'Aucune présentation ne correspond à ces critères.'}</p>
</div>
</main>
<footer class="pied">
<a href="https://alpinux.org">alpinux.org</a> ·
<a href="https://wiki.alpinux.org">Le wiki</a> ·
<a href="https://gitea.alpinux.org/alpinux.cedrica5l/alpinux.presentations">Proposer une présentation</a>
</footer>
<script>
// Filtres de la liste. Sans JavaScript, la page reste complète : le formulaire est
// simplement masqué.
(function () {
var formulaire = document.getElementById('filtres');
var cartes = Array.prototype.slice.call(document.querySelectorAll('.carte'));
if (!formulaire || cartes.length === 0) return;
var recherche = document.getElementById('recherche');
var niveau = document.getElementById('niveau');
var format = document.getElementById('format');
var compte = document.getElementById('compte');
var vide = document.getElementById('vide');
function normaliser(texte) {
return texte.normalize('NFD').replace(/[\\u0300-\\u036f]/g, '').toLowerCase();
}
function filtrer() {
var mots = normaliser(recherche.value).split(/\\s+/).filter(Boolean);
var visibles = 0;
cartes.forEach(function (carte) {
var garde = (!niveau.value || carte.dataset.niveau === niveau.value)
&& (!format.value || carte.dataset.format === format.value)
&& mots.every(function (mot) { return carte.dataset.recherche.indexOf(mot) !== -1; });
carte.hidden = !garde;
if (garde) visibles += 1;
});
vide.hidden = visibles !== 0;
compte.textContent = visibles === cartes.length
? (visibles === 1 ? '1 présentation' : visibles + ' présentations') + ', de la plus récente à la plus ancienne.'
: visibles + ' sur ' + cartes.length + (visibles > 1 ? ' affichées.' : ' affichée.');
}
formulaire.hidden = false;
formulaire.addEventListener('submit', function (evenement) { evenement.preventDefault(); });
formulaire.addEventListener('input', filtrer);
formulaire.addEventListener('change', filtrer);
})();
</script>
</body>
</html>
`
}
function fichierJson(presentations) {
return JSON.stringify({
genere: new Date().toISOString(),
site: `${URL_SITE}/`,
presentations: presentations.map((p) => ({
id: p.id,
titre: p.titre,
description: p.description,
auteur: p.auteur,
date: p.date,
niveau: p.niveau,
format: p.format,
tags: p.tags,
url: `${URL_SITE}/${p.id}/`,
wiki: p.wiki,
})),
}, null, 2) + '\n'
}
const HTACCESS = `# Généré par scripts/build.mjs — ne pas modifier sur le serveur, le prochain
# déploiement l'écraserait.
AddDefaultCharset utf-8
# presentations.json est lu par alpinux.org depuis le navigateur du visiteur : sans cet
# en-tête, un autre domaine n'a pas le droit de le lire. Rien d'autre n'est ouvert.
<IfModule mod_headers.c>
<Files "presentations.json">
Header set Access-Control-Allow-Origin "*"
</Files>
</IfModule>
`
// ── Enchaînement ───────────────────────────────────────────────────────────
/** Refuse un répertoire de sortie dont le nettoyage emporterait les sources. */
function verifierLaSortie(sortie) {
const contient = (parent, enfant) => enfant === parent || enfant.startsWith(parent + sep)
if (contient(sortie, RACINE) || ['slides', 'theme', 'scripts', 'deploy', 'node_modules', '.git']
.some((dossier) => contient(join(RACINE, dossier), sortie))) {
throw new ErreurDeBuild(`répertoire de sortie refusé : ${sortie} — il serait vidé alors qu'il contient des sources`)
}
}
async function construire(sortie) {
verifierLaSortie(sortie)
const { presentations, brouillons } = collecterLesPresentations()
for (const id of brouillons) console.log(` (brouillon, non publié) ${id}`)
// Tout est généré à part, puis mis en place d'un coup : un échec en cours de route
// laisse le répertoire de sortie tel qu'il était.
mkdirSync(dirname(sortie), { recursive: true })
const chantier = mkdtempSync(join(dirname(sortie), '.build-'))
chmodSync(chantier, 0o755)
try {
for (const presentation of presentations) {
await genererLeDiaporama(presentation, chantier)
console.log(` diaporama ${presentation.id}/index.html`)
}
writeFileSync(join(chantier, 'index.html'), pageAccueil(presentations))
writeFileSync(join(chantier, 'presentations.json'), fichierJson(presentations))
writeFileSync(join(chantier, '.htaccess'), HTACCESS)
console.log(' accueil index.html\n données presentations.json\n serveur .htaccess')
rmSync(sortie, { recursive: true, force: true })
renameSync(chantier, sortie)
} finally {
rmSync(chantier, { recursive: true, force: true })
}
console.log(`Build réussi : ${presentations.length} présentation(s) dans ${relative(process.cwd(), sortie) || '.'}/`)
}
async function exporterEnPdf(id, sortie) {
if (!id) throw new ErreurDeBuild('identifiant manquant — usage : npm run pdf -- <identifiant>')
const source = join(SOURCES, id, 'slides.md')
if (!FORME_IDENTIFIANT.test(id) && id !== '_modele' || !existsSync(source)) {
throw new ErreurDeBuild(`présentation introuvable : slides/${id}/slides.md`)
}
const pdf = join(sortie, id, `${id}.pdf`)
mkdirSync(dirname(pdf), { recursive: true })
// Marp pilote un navigateur déjà installé (Chromium, Chrome, Edge ou Firefox) pour
// imprimer le diaporama : c'est pourquoi cet export ne se fait pas sur le serveur.
const { code, journal } = await lancerMarp([
source, '-o', pdf, '--pdf', '--allow-local-files', '--theme-set', THEME, '--theme', NOM_DU_THEME,
])
if (code !== 0 || !existsSync(pdf)) {
throw new ErreurDeBuild(`l'export PDF de « ${id} » a échoué. Un navigateur (Chromium ou Firefox) ` +
`doit être installé sur ce poste.\n${journal}`)
}
console.log(`PDF généré : ${relative(process.cwd(), pdf)}`)
}
const args = process.argv.slice(2)
try {
if (args[0] === '--pdf') {
await exporterEnPdf(args[1], resolve(args[2] ?? join(RACINE, 'public')))
} else if (args[0]?.startsWith('-')) {
throw new ErreurDeBuild(`option inconnue : ${args[0]} — usage : node scripts/build.mjs [sortie] | --pdf <identifiant> [sortie]`)
} else {
await construire(resolve(args[0] ?? join(RACINE, 'public')))
}
} catch (erreur) {
if (!(erreur instanceof ErreurDeBuild)) throw erreur
console.error(`\nÉCHEC du build — ${erreur.message}\n`)
process.exit(1)
}