#!/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 [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, /]*?\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(/]*>/i, (balise) => /\slang=/.test(balise) ? balise.replace(/\slang=["'][^"']*["']/, ` lang="${presentation.langue}"`) : balise.replace(/ 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) => `
  • ${echapper(tag)}
  • `).join('') const lienWiki = p.wiki ? `\n Fiche sur le wiki : ${echapper(p.titre)}` : '' return `
  • ` } const options = (valeurs) => Object.entries(valeurs) .map(([valeur, libelle]) => ``).join('') function pageAccueil(presentations) { const total = presentations.length const compte = total === 0 ? 'Aucune présentation' : total === 1 ? '1 présentation' : `${total} présentations` return ` Présentations d'Alpinux Aller à la liste des présentations

    Présentations d'Alpinux

    Les diaporamas des réunions du groupe d'utilisateurs de logiciels libres de Savoie.

    ${compte}, de la plus récente à la plus ancienne.

      ${presentations.map(carte).join('\n')}

    ${total === 0 ? 'Aucune présentation n\'est encore publiée.' : 'Aucune présentation ne correspond à ces critères.'}

    ` } 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. Header set Access-Control-Allow-Origin "*" ` // ── 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 -- ') 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 [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) }