Un dossier par présentation en Markdown, converti en diaporama HTML autonome par Marp CLI, et une page d'accueil générée qui les liste toutes. - scripts/build.mjs : build strict (fiche validée, images vérifiées, aucune ressource externe hormis le logo), page d'accueil filtrable, presentations.json et .htaccess pour l'en-tête CORS - theme/alpinux.css : thème commun pour la vidéoprojection, classes titre et demo - slides/_modele : modèle commenté, non publié - slides/pdf-signer : « Lire, remplir et signer ses PDF », avec notes d'orateur - scripts/deploy-presentations.sh et deploy/ : déploiement par webhook Gitea, sur le modèle du wiki et de www Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M713yNBL8cssBA1Zri5QLT
599 lines
26 KiB
JavaScript
599 lines
26 KiB
JavaScript
#!/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('&', '&'))
|
||
|
||
/**
|
||
* 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*(?:"|["'])?([^"')&]+)/gi),
|
||
...adresses(html, /@import\s+(?:url\()?\s*(?:"|["'])([^"')&]+)/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('&', '&').replaceAll('<', '<').replaceAll('>', '>')
|
||
.replaceAll('"', '"').replaceAll("'", ''')
|
||
|
||
/** 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)
|
||
}
|