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>
141 lines
4.4 KiB
TypeScript
141 lines
4.4 KiB
TypeScript
// Abstraction ContentSource (spec §5) : DEV = FS local (grav-reference monté RO),
|
|
// PROD = cache local alimenté par rsync/SSH. Le reste de l'app ne voit que cette interface.
|
|
import { execFile } from 'node:child_process'
|
|
import { promisify } from 'node:util'
|
|
import { mkdir } from 'node:fs/promises'
|
|
import { join, normalize } from 'node:path'
|
|
import type { Project } from '~~/types'
|
|
import { readAllProjects } from './grav-reader'
|
|
|
|
const execFileAsync = promisify(execFile)
|
|
|
|
export interface SyncResult {
|
|
ok: boolean
|
|
summary: string
|
|
syncedAt: string
|
|
}
|
|
|
|
export interface ContentSource {
|
|
/** Racine FS des projets (catégories/<slug>/…) */
|
|
readonly rootDir: string
|
|
listProjects(): Promise<Project[]>
|
|
getProject(id: string): Promise<Project | null>
|
|
/** Chemin absolu d'un média, borné à rootDir (anti path-traversal). */
|
|
resolveMediaPath(projectId: string, filename: string): string | null
|
|
/** Re-synchronise le contenu (no-op en DEV). */
|
|
refresh(): Promise<SyncResult>
|
|
/** Invalide le cache mémoire. */
|
|
invalidate(): void
|
|
}
|
|
|
|
const CACHE_TTL_MS = 30_000
|
|
|
|
abstract class FsContentSource implements ContentSource {
|
|
private cache: { at: number; projects: Project[] } | null = null
|
|
|
|
constructor(public readonly rootDir: string) {}
|
|
|
|
async listProjects(): Promise<Project[]> {
|
|
if (this.cache && Date.now() - this.cache.at < CACHE_TTL_MS) {
|
|
return this.cache.projects
|
|
}
|
|
const projects = await readAllProjects(this.rootDir)
|
|
this.cache = { at: Date.now(), projects }
|
|
return projects
|
|
}
|
|
|
|
async getProject(id: string): Promise<Project | null> {
|
|
const projects = await this.listProjects()
|
|
return projects.find(p => p.id === id) ?? null
|
|
}
|
|
|
|
resolveMediaPath(projectId: string, filename: string): string | null {
|
|
const abs = normalize(join(this.rootDir, projectId, filename))
|
|
if (!abs.startsWith(normalize(this.rootDir) + '/')) return null
|
|
return abs
|
|
}
|
|
|
|
invalidate(): void {
|
|
this.cache = null
|
|
}
|
|
|
|
abstract refresh(): Promise<SyncResult>
|
|
}
|
|
|
|
/** Mode DEV : lecture directe du Grav local monté en lecture seule. */
|
|
export class DevContentSource extends FsContentSource {
|
|
async refresh(): Promise<SyncResult> {
|
|
this.invalidate()
|
|
const projects = await this.listProjects()
|
|
return {
|
|
ok: true,
|
|
summary: `Mode dev : contenu local relu (${projects.length} projets).`,
|
|
syncedAt: new Date().toISOString(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Mode PROD : cache local `data/content-cache/` alimenté par rsync/SSH
|
|
* depuis la prod (spec §6). Lecture seule côté prod, incrémental.
|
|
*/
|
|
export class ProdContentSource extends FsContentSource {
|
|
constructor(
|
|
rootDir: string,
|
|
private readonly remote: string, // ex. user@host:/chemin/user/pages/02.projets/
|
|
private readonly sshKey?: string,
|
|
) {
|
|
super(rootDir)
|
|
}
|
|
|
|
async refresh(): Promise<SyncResult> {
|
|
if (!this.remote) {
|
|
return {
|
|
ok: false,
|
|
summary: 'NUXT_SYNC_REMOTE non configuré : impossible de synchroniser.',
|
|
syncedAt: new Date().toISOString(),
|
|
}
|
|
}
|
|
await mkdir(this.rootDir, { recursive: true })
|
|
const ssh = ['ssh', '-o', 'StrictHostKeyChecking=accept-new', '-o', 'BatchMode=yes']
|
|
if (this.sshKey) ssh.push('-i', this.sshKey)
|
|
|
|
const started = Date.now()
|
|
try {
|
|
const { stdout } = await execFileAsync('rsync', [
|
|
'-az',
|
|
'--delete',
|
|
'--itemize-changes',
|
|
'-e', ssh.join(' '),
|
|
this.remote.endsWith('/') ? this.remote : `${this.remote}/`,
|
|
`${this.rootDir}/`,
|
|
], { timeout: 15 * 60_000 })
|
|
this.invalidate()
|
|
const changed = stdout.split('\n').filter(l => /^[<>ch.]\S+\s/.test(l)).length
|
|
const projects = await this.listProjects()
|
|
return {
|
|
ok: true,
|
|
summary: `${changed} fichier(s) mis à jour en ${Math.round((Date.now() - started) / 1000)} s — ${projects.length} projets.`,
|
|
syncedAt: new Date().toISOString(),
|
|
}
|
|
} catch (err) {
|
|
return {
|
|
ok: false,
|
|
summary: `Échec rsync : ${err instanceof Error ? err.message : String(err)}`,
|
|
syncedAt: new Date().toISOString(),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let source: ContentSource | null = null
|
|
|
|
export function useContentSource(): ContentSource {
|
|
if (source) return source
|
|
const config = useRuntimeConfig()
|
|
source = config.contentSource === 'prod'
|
|
? new ProdContentSource(join(config.dataDir, 'content-cache'), config.syncRemote, config.syncSshKey || undefined)
|
|
: new DevContentSource(config.contentDir)
|
|
return source
|
|
}
|