Files
portfolio-generator-app/server/lib/grav-reader.ts
Valentin Le Moign 59dfbe4155 Générateur de portfolios PDF Figures Libres (app Nuxt autonome)
App Nuxt 3 + Nuxt UI qui lit les projets du CMS Grav et compose des portfolios
A3 paysage exportés en PDF (gabarit book_v3) : composeur WYSIWYG, templates
cover/toc/projet/grille/page libre, pipeline Playwright + Ghostscript, gel du
contenu à la génération, partage public opt-in.

Dépôt autonome : Dockerfile (base Playwright + Ghostscript + ffmpeg + rsync) inclus.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 18:37:09 +02:00

199 lines
6.7 KiB
TypeScript

// Parseur du contenu Grav (lecture seule) — spec §7.1
// Lit user/pages/02.projets/<categorie>/<projet>/reader.md + médias.
import { readdir, readFile, stat } from 'node:fs/promises'
import { join } from 'node:path'
import { parse as parseYaml } from 'yaml'
import { marked } from 'marked'
import sharp from 'sharp'
import type { MediaAsset, MediaVariant, Project } from '~~/types'
const IMAGE_EXT = /\.(png|jpe?g|webp|gif)$/i
const VIDEO_EXT = /\.(mp4|webm)$/i
const VARIANT_RE = /^(.*)@(\d+)x(\.[^.]+)$/i
/** "01.culturelle" -> "culturelle" */
function stripOrderPrefix(dirname: string): string {
return dirname.replace(/^\d+\./, '')
}
/** Date Grav "09-12-2024 22:19" (DD-MM-YYYY HH:mm) -> ISO */
function parseGravDate(raw: unknown): string | undefined {
if (!raw) return undefined
const s = String(raw).trim()
const m = s.match(/^(\d{2})-(\d{2})-(\d{4})(?:\s+(\d{2}):(\d{2}))?$/)
if (m) {
const [, dd, mm, yyyy, hh = '00', min = '00'] = m
return `${yyyy}-${mm}-${dd}T${hh}:${min}:00`
}
const d = new Date(s)
return Number.isNaN(d.getTime()) ? undefined : d.toISOString()
}
function toArray(v: unknown): string[] {
if (Array.isArray(v)) return v.map(String).filter(Boolean)
if (typeof v === 'string' && v.trim()) return [v.trim()]
return []
}
/** Détecte l'URL externe du projet (souvent en fin de corps). */
function detectExternalUrl(body: string): string | undefined {
const matches = body.match(/https?:\/\/[^\s)>\]"']+/g)
if (!matches?.length) return undefined
return matches[matches.length - 1].replace(/[.,;]$/, '')
}
interface ParsedMarkdown {
frontmatter: Record<string, unknown>
body: string
}
function splitFrontmatter(raw: string): ParsedMarkdown {
const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/)
if (!m) return { frontmatter: {}, body: raw }
let frontmatter: Record<string, unknown> = {}
try {
frontmatter = (parseYaml(m[1]) as Record<string, unknown>) ?? {}
} catch {
frontmatter = {}
}
return { frontmatter, body: m[2].trim() }
}
/**
* Regroupe les fichiers d'un dossier projet en MediaAsset :
* base.png + base@2x.png/@3x/@4x -> un asset avec variantes,
* ordonnés selon media_order puis alphabétique pour le reste.
*/
function collectMedia(files: string[], mediaOrder: string[]): MediaAsset[] {
const assets = new Map<string, MediaAsset>()
// Le nom canonique est celui sans suffixe @Nx (c'est lui que référencent
// media_order et aura.image). Certains projets n'ont qu'un @1x sans base.
for (const f of files) {
if (!IMAGE_EXT.test(f) && !VIDEO_EXT.test(f)) continue
const m = f.match(VARIANT_RE)
const canonical = m ? m[1] + m[3] : f
const label = (m ? `${m[2]}x` : '1x') as MediaVariant['label']
let asset = assets.get(canonical)
if (!asset) {
asset = {
filename: canonical,
type: VIDEO_EXT.test(canonical) ? 'video' : 'image',
variants: [],
}
assets.set(canonical, asset)
}
const existing = asset.variants.find(v => v.label === label)
if (!existing) {
asset.variants.push({ label, path: f })
} else if (!m) {
existing.path = f // le fichier de base l'emporte sur un doublon @1x
}
}
for (const a of assets.values()) {
a.variants.sort((x, y) => parseInt(x.label) - parseInt(y.label))
}
// Tri : media_order d'abord, puis le reste en alphabétique
const orderIndex = new Map(mediaOrder.map((name, i) => [name, i]))
return [...assets.values()].sort((a, b) => {
const ia = orderIndex.has(a.filename) ? orderIndex.get(a.filename)! : Infinity
const ib = orderIndex.has(b.filename) ? orderIndex.get(b.filename)! : Infinity
if (ia !== ib) return ia - ib
return a.filename.localeCompare(b.filename)
})
}
async function readProject(
contentDir: string,
categoryDir: string,
projectDir: string,
): Promise<Project | null> {
const dir = join(contentDir, categoryDir, projectDir)
let raw: string
try {
raw = await readFile(join(dir, 'reader.md'), 'utf8')
} catch {
return null // pas un dossier projet (pas de reader.md)
}
const { frontmatter, body } = splitFrontmatter(raw)
const files = (await readdir(dir)).filter(f => f !== 'reader.md')
const taxonomy = (frontmatter.taxonomy ?? {}) as Record<string, unknown>
const mediaOrder = String(frontmatter.media_order ?? '')
.split(',')
.map(s => s.trim())
.filter(Boolean)
const media = collectMedia(files, mediaOrder)
// Dimensions des images (en-tête seulement, rapide) : le ratio est le même
// pour toutes les variantes @Nx, la 1re suffit.
for (const asset of media) {
if (asset.type !== 'image') continue
try {
const meta = await sharp(join(dir, asset.variants[0].path)).metadata()
asset.width = meta.width
asset.height = meta.height
} catch {
// dimensions indisponibles : le choix de grille retombera sur « mixte »
}
}
// Image de couverture = celle de la grille du CMS : la PREMIÈRE image de
// media_order (cf. projet_card.html.twig : p.media.images|first), pas
// aura.image qui sert aux partages sociaux.
const coverImage = media.find(m => m.type === 'image')?.filename
return {
id: `${categoryDir}/${projectDir}`,
category: stripOrderPrefix(categoryDir),
title: String(frontmatter.title ?? stripOrderPrefix(projectDir)),
bodyMarkdown: body,
bodyHtml: await marked.parse(body),
coverImage,
media,
categories: toArray(taxonomy.category),
tags: toArray(taxonomy.tag),
date: parseGravDate(frontmatter.date),
externalUrl: detectExternalUrl(body),
published: frontmatter.published !== false,
}
}
/** Lit tous les projets du dossier 02.projets (3 catégories). */
export async function readAllProjects(contentDir: string): Promise<Project[]> {
const projects: Project[] = []
let categoryDirs: string[] = []
try {
categoryDirs = (await readdir(contentDir, { withFileTypes: true }))
.filter(e => e.isDirectory())
.map(e => e.name)
.sort()
} catch {
return []
}
for (const categoryDir of categoryDirs) {
const entries = await readdir(join(contentDir, categoryDir), { withFileTypes: true })
for (const entry of entries.filter(e => e.isDirectory()).sort((a, b) => a.name.localeCompare(b.name))) {
const project = await readProject(contentDir, categoryDir, entry.name)
if (project) projects.push(project)
}
}
// Tri global : plus récent d'abord (comme les listings Grav)
projects.sort((a, b) => (b.date ?? '').localeCompare(a.date ?? ''))
return projects
}
/** mtime le plus récent des reader.md — sert à invalider le cache mémoire. */
export async function contentFingerprint(contentDir: string): Promise<string> {
try {
const s = await stat(contentDir)
return String(s.mtimeMs)
} catch {
return '0'
}
}