From dcff4515721482e06c2ef1c1eb34f9e12754c07c Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Mon, 4 May 2026 22:27:52 +0200 Subject: [PATCH] feat(branding): per-family generic fallback via meta.json --- backend/assets/fonts/Comic-Neue/meta.json | 3 + .../assets/fonts/Playfair-Display/meta.json | 3 + backend/src/services/fontsService.js | 59 +++++++++++++++++-- .../admin/ThemeCustomizerEnhanced.tsx | 37 ++++++------ frontend/src/services/fonts.service.ts | 4 ++ 5 files changed, 85 insertions(+), 21 deletions(-) create mode 100644 backend/assets/fonts/Comic-Neue/meta.json create mode 100644 backend/assets/fonts/Playfair-Display/meta.json diff --git a/backend/assets/fonts/Comic-Neue/meta.json b/backend/assets/fonts/Comic-Neue/meta.json new file mode 100644 index 00000000..9ff729a5 --- /dev/null +++ b/backend/assets/fonts/Comic-Neue/meta.json @@ -0,0 +1,3 @@ +{ + "generic": "cursive" +} diff --git a/backend/assets/fonts/Playfair-Display/meta.json b/backend/assets/fonts/Playfair-Display/meta.json new file mode 100644 index 00000000..0b837dd7 --- /dev/null +++ b/backend/assets/fonts/Playfair-Display/meta.json @@ -0,0 +1,3 @@ +{ + "generic": "serif" +} diff --git a/backend/src/services/fontsService.js b/backend/src/services/fontsService.js index 56da9af0..5d658adf 100644 --- a/backend/src/services/fontsService.js +++ b/backend/src/services/fontsService.js @@ -21,11 +21,17 @@ const logger = require('../utils/logger'); * Folder layout in either location: * * /.woff2 + * /meta.json (optional) * * - Folder name → display family with hyphens replaced by spaces: * "Playfair-Display" → "Playfair Display" * - Weight files must be named ".woff2" (e.g. 400.woff2). * Other names are ignored, family entry still includes its other weights. + * - Optional meta.json: { "generic": "sans-serif" | "serif" | "cursive" | "monospace" } + * Tells the picker which CSS generic family to use as a fallback when + * building the font-family string. Defaults to "sans-serif" if absent + * or invalid. Avoids hardcoding family-name → generic lookups in the + * frontend, so any new family folder works without code changes. * * Result is cached in memory for FONTS_CACHE_TTL_MS so frequent * /api/public/fonts hits don't hit disk per request. New folders dropped @@ -35,11 +41,18 @@ const logger = require('../utils/logger'); */ const FONTS_CACHE_TTL_MS = 30_000; +const VALID_GENERICS = new Set(['sans-serif', 'serif', 'cursive', 'monospace']); +const DEFAULT_GENERIC = 'sans-serif'; let cachedFonts = null; let fontsCacheExpiresAt = 0; function getBundledFontsRoot() { + // Test seam: the unit test suite points this at an isolated temp dir so it + // can populate fixtures without polluting the real backend/assets tree. + if (process.env.PICPEAK_BUNDLED_FONTS_ROOT) { + return process.env.PICPEAK_BUNDLED_FONTS_ROOT; + } // backend/src/services/fontsService.js → backend/assets/fonts return path.resolve(__dirname, '../../assets/fonts'); } @@ -54,8 +67,45 @@ function familyDisplayName(folderName) { } /** - * Read one family folder and return { family, weights } or null if the - * folder has no usable .woff2 files. + * Try to read meta.json from the family folder. Returns the validated + * generic class or DEFAULT_GENERIC. Missing file → silent default. + * Unreadable / malformed / invalid value → warning + default. + */ +async function readFamilyMeta(folderAbs, folderName) { + const metaPath = path.join(folderAbs, 'meta.json'); + let raw; + try { + raw = await fs.readFile(metaPath, 'utf8'); + } catch (err) { + if (err.code === 'ENOENT') return DEFAULT_GENERIC; + logger.warn(`[fonts] Could not read meta.json for ${folderName}: ${err.message}`); + return DEFAULT_GENERIC; + } + + let parsed; + try { + parsed = JSON.parse(raw); + } catch (err) { + logger.warn(`[fonts] meta.json for ${folderName} is not valid JSON: ${err.message}`); + return DEFAULT_GENERIC; + } + + const generic = parsed && typeof parsed.generic === 'string' ? parsed.generic : null; + if (generic && VALID_GENERICS.has(generic)) { + return generic; + } + if (generic) { + logger.warn( + `[fonts] meta.json for ${folderName} has invalid generic "${generic}"; ` + + `expected one of ${Array.from(VALID_GENERICS).join(', ')}. Falling back to ${DEFAULT_GENERIC}.` + ); + } + return DEFAULT_GENERIC; +} + +/** + * Read one family folder and return { family, weights, generic } or null + * if the folder has no usable .woff2 files. */ async function readFamilyFolder(rootAbs, folderName) { const folderAbs = path.join(rootAbs, folderName); @@ -84,7 +134,8 @@ async function readFamilyFolder(rootAbs, folderName) { } weights.sort((a, b) => a - b); - return { family: familyDisplayName(folderName), weights }; + const generic = await readFamilyMeta(folderAbs, folderName); + return { family: familyDisplayName(folderName), weights, generic }; } /** @@ -129,7 +180,7 @@ async function scanRoot(rootAbs) { * List all available font families (bundled + user additions, merged). * Cached for FONTS_CACHE_TTL_MS. * - * @returns {Promise>} + * @returns {Promise>} */ async function listFonts() { if (Date.now() < fontsCacheExpiresAt && cachedFonts !== null) { diff --git a/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx b/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx index b4136774..8a0c8476 100644 --- a/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx +++ b/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx @@ -8,20 +8,16 @@ import { fontsService, extractFamilyName, type FontDefinition } from '../../serv import { useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; -// Generic CSS fallback per family. Sans by default; serif for known serif -// families; cursive for handwriting/display families. Used when building -// the dropdown