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>
199 lines
6.7 KiB
TypeScript
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'
|
|
}
|
|
}
|