// Parseur du contenu Grav (lecture seule) — spec §7.1 // Lit user/pages/02.projets///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 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 = {} try { frontmatter = (parseYaml(m[1]) as Record) ?? {} } 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() // 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 { 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 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 { 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 { try { const s = await stat(contentDir) return String(s.mtimeMs) } catch { return '0' } }