feat(branding): per-family generic fallback via meta.json
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"generic": "cursive"
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"generic": "serif"
|
||||||
|
}
|
||||||
@@ -21,11 +21,17 @@ const logger = require('../utils/logger');
|
|||||||
* Folder layout in either location:
|
* Folder layout in either location:
|
||||||
*
|
*
|
||||||
* <Family-Name>/<weight>.woff2
|
* <Family-Name>/<weight>.woff2
|
||||||
|
* <Family-Name>/meta.json (optional)
|
||||||
*
|
*
|
||||||
* - Folder name → display family with hyphens replaced by spaces:
|
* - Folder name → display family with hyphens replaced by spaces:
|
||||||
* "Playfair-Display" → "Playfair Display"
|
* "Playfair-Display" → "Playfair Display"
|
||||||
* - Weight files must be named "<integer>.woff2" (e.g. 400.woff2).
|
* - Weight files must be named "<integer>.woff2" (e.g. 400.woff2).
|
||||||
* Other names are ignored, family entry still includes its other weights.
|
* 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
|
* 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
|
* /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 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 cachedFonts = null;
|
||||||
let fontsCacheExpiresAt = 0;
|
let fontsCacheExpiresAt = 0;
|
||||||
|
|
||||||
function getBundledFontsRoot() {
|
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
|
// backend/src/services/fontsService.js → backend/assets/fonts
|
||||||
return path.resolve(__dirname, '../../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
|
* Try to read meta.json from the family folder. Returns the validated
|
||||||
* folder has no usable .woff2 files.
|
* 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) {
|
async function readFamilyFolder(rootAbs, folderName) {
|
||||||
const folderAbs = path.join(rootAbs, folderName);
|
const folderAbs = path.join(rootAbs, folderName);
|
||||||
@@ -84,7 +134,8 @@ async function readFamilyFolder(rootAbs, folderName) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
weights.sort((a, b) => a - b);
|
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).
|
* List all available font families (bundled + user additions, merged).
|
||||||
* Cached for FONTS_CACHE_TTL_MS.
|
* Cached for FONTS_CACHE_TTL_MS.
|
||||||
*
|
*
|
||||||
* @returns {Promise<Array<{ family: string, weights: number[] }>>}
|
* @returns {Promise<Array<{ family: string, weights: number[], generic: string }>>}
|
||||||
*/
|
*/
|
||||||
async function listFonts() {
|
async function listFonts() {
|
||||||
if (Date.now() < fontsCacheExpiresAt && cachedFonts !== null) {
|
if (Date.now() < fontsCacheExpiresAt && cachedFonts !== null) {
|
||||||
|
|||||||
@@ -8,20 +8,16 @@ import { fontsService, extractFamilyName, type FontDefinition } from '../../serv
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
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
|
* Build the CSS font-family value for a scanned font, using the generic
|
||||||
// the dropdown <option value> from a scanned family name.
|
* fallback the backend supplied (from each family's optional meta.json).
|
||||||
const SERIF_FAMILIES = new Set(['Playfair Display', 'Georgia']);
|
* Defaults to 'sans-serif' when the backend doesn't report one — keeps
|
||||||
const CURSIVE_FAMILIES = new Set(['Comic Neue']);
|
* compatibility with backends that predate the generic field.
|
||||||
|
*/
|
||||||
function buildFontFamilyValue(family: string): string {
|
function buildFontFamilyValue(font: FontDefinition): string {
|
||||||
const generic = SERIF_FAMILIES.has(family)
|
const generic = font.generic ?? 'sans-serif';
|
||||||
? 'serif'
|
|
||||||
: CURSIVE_FAMILIES.has(family)
|
|
||||||
? 'cursive'
|
|
||||||
: 'sans-serif';
|
|
||||||
// Always quote the family name (covers multi-word like 'Playfair Display').
|
// Always quote the family name (covers multi-word like 'Playfair Display').
|
||||||
return `'${family}', ${generic}`;
|
return `'${font.family}', ${generic}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -42,7 +38,7 @@ function resolveFontDropdownValue(
|
|||||||
const match = (available || []).find(
|
const match = (available || []).find(
|
||||||
(f) => f.family.toLowerCase() === family.toLowerCase()
|
(f) => f.family.toLowerCase() === family.toLowerCase()
|
||||||
);
|
);
|
||||||
return match ? buildFontFamilyValue(match.family) : saved;
|
return match ? buildFontFamilyValue(match) : saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ThemeCustomizerEnhancedProps {
|
interface ThemeCustomizerEnhancedProps {
|
||||||
@@ -978,14 +974,21 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
|||||||
value={resolveFontDropdownValue(
|
value={resolveFontDropdownValue(
|
||||||
localTheme.fontFamily,
|
localTheme.fontFamily,
|
||||||
availableFonts,
|
availableFonts,
|
||||||
buildFontFamilyValue('Inter')
|
// Fallback when no fontFamily is saved yet: prefer the
|
||||||
|
// scanned Inter (with its real generic), else a bare CSS
|
||||||
|
// string when the backend hasn't loaded yet.
|
||||||
|
(availableFonts || []).find((f) => f.family === 'Inter')
|
||||||
|
? buildFontFamilyValue(
|
||||||
|
(availableFonts || []).find((f) => f.family === 'Inter')!
|
||||||
|
)
|
||||||
|
: "'Inter', sans-serif"
|
||||||
)}
|
)}
|
||||||
onChange={(e) => handleChange('fontFamily', e.target.value)}
|
onChange={(e) => handleChange('fontFamily', e.target.value)}
|
||||||
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100"
|
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100"
|
||||||
>
|
>
|
||||||
<option value="system-ui, sans-serif">System UI</option>
|
<option value="system-ui, sans-serif">System UI</option>
|
||||||
{(availableFonts || []).map((f) => (
|
{(availableFonts || []).map((f) => (
|
||||||
<option key={f.family} value={buildFontFamilyValue(f.family)}>
|
<option key={f.family} value={buildFontFamilyValue(f)}>
|
||||||
{f.family}
|
{f.family}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
@@ -1008,7 +1011,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
|||||||
<option value="">{t('branding.sameAsBody')}</option>
|
<option value="">{t('branding.sameAsBody')}</option>
|
||||||
<option value="system-ui, sans-serif">System UI</option>
|
<option value="system-ui, sans-serif">System UI</option>
|
||||||
{(availableFonts || []).map((f) => (
|
{(availableFonts || []).map((f) => (
|
||||||
<option key={f.family} value={buildFontFamilyValue(f.family)}>
|
<option key={f.family} value={buildFontFamilyValue(f)}>
|
||||||
{f.family}
|
{f.family}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import { api } from '../config/api';
|
|||||||
export interface FontDefinition {
|
export interface FontDefinition {
|
||||||
family: string;
|
family: string;
|
||||||
weights: number[];
|
weights: number[];
|
||||||
|
// CSS generic to use as a fallback when building font-family strings.
|
||||||
|
// Optional in the type so older backends without this field still work;
|
||||||
|
// callers must default to 'sans-serif' when undefined.
|
||||||
|
generic?: 'sans-serif' | 'serif' | 'cursive' | 'monospace';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FontsListResponse {
|
export interface FontsListResponse {
|
||||||
|
|||||||
Reference in New Issue
Block a user