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>
This commit is contained in:
198
server/lib/grav-reader.ts
Normal file
198
server/lib/grav-reader.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
// 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'
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user