styles/source/server/routes/fonts.ts

283 lines
8.6 KiB
TypeScript
Raw Normal View History

2026-07-15 07:00:55 +00:00
import { Router, Request, Response } from 'express';
import fs from 'fs';
import path from 'path';
import https from 'https';
const FONTS_DIR = path.join( __dirname, '..', '..', '..', 'storage', 'fonts' );
const GOOGLE_FONTS_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
const ALL_WEIGHTS = [ 100, 200, 300, 400, 500, 600, 700, 800, 900 ];
// ─── types ───────────────────────────────────────────────────────────────────
interface FontMeta
{
family: string;
weights: string[];
italics: string[];
}
// ─── helpers ─────────────────────────────────────────────────────────────────
function fontsDir( family: string ): string
{
return path.join( FONTS_DIR, family.toLowerCase() );
}
function readMeta( family: string ): FontMeta | null
{
const p = path.join( fontsDir( family ), 'meta.json' );
if ( !fs.existsSync( p ) ) return null;
return JSON.parse( fs.readFileSync( p, 'utf8' ) );
}
function writeMeta( meta: FontMeta ): void
{
fs.writeFileSync(
path.join( fontsDir( meta.family ), 'meta.json' ),
JSON.stringify( meta, null, 2 )
);
}
function fontFileExists( family: string, weight: string, italic: boolean ): boolean
{
const name = italic ? `${weight}-italic.woff2` : `${weight}.woff2`;
return fs.existsSync( path.join( fontsDir( family ), name ) );
}
function buildFontFace( displayFamily: string, weight: string, italic: boolean ): string
{
const style = italic ? 'italic' : 'normal';
const fileName = italic ? `${weight}-italic.woff2` : `${weight}.woff2`;
const url = `/fonts/${displayFamily.toLowerCase()}/${fileName}`;
return [
'@font-face {',
` font-family: '${displayFamily}';`,
` font-style: ${style};`,
` font-weight: ${weight};`,
` src: url('${url}') format('woff2');`,
' font-display: swap;',
'}',
].join( '\n' );
}
function httpsGet( url: string, headers: Record<string, string> = {} ): Promise<string>
{
return new Promise( ( resolve, reject ) =>
{
const req = https.get( url, { headers }, res =>
{
if ( res.statusCode >= 300 && res.statusCode < 400 && res.headers.location )
{
resolve( httpsGet( res.headers.location, headers ) );
return;
}
let data = '';
res.on( 'data', chunk => { data += chunk; } );
res.on( 'end', () => resolve( data ) );
} );
req.on( 'error', reject );
} );
}
function httpsGetBuffer( url: string ): Promise<Buffer>
{
return new Promise( ( resolve, reject ) =>
{
const req = https.get( url, res =>
{
if ( res.statusCode >= 300 && res.statusCode < 400 && res.headers.location )
{
resolve( httpsGetBuffer( res.headers.location ) );
return;
}
const chunks: Buffer[] = [];
res.on( 'data', chunk => { chunks.push( chunk ); } );
res.on( 'end', () => resolve( Buffer.concat( chunks ) ) );
} );
req.on( 'error', reject );
} );
}
// ─── public router ────────────────────────────────────────────────────────────
//
// GET /get-font — returns a CSS file with @font-face rules
//
// Modes (mutually exclusive):
// ?family=barlow → all files in directory
// ?family=barlow&weights=400,700 → those weights + auto-italic if exists
// ?family=barlow&variations=400,400 italic → exactly those variations
export const publicFontsRouter = Router();
publicFontsRouter.get( '/get-font', ( req: Request, res: Response ): void =>
{
const familyParam = ( req.query.family as string | undefined )?.trim();
if ( !familyParam )
{
res.status( 400 ).send( '/* family param required */' );
return;
}
const meta = readMeta( familyParam );
if ( !meta )
{
res.status( 404 ).send( `/* font family '${familyParam}' not found */` );
return;
}
const blocks: string[] = [];
if ( req.query.variations )
{
const vars = ( req.query.variations as string )
.split( ',' )
.map( v => v.trim().toLowerCase() );
for ( const v of vars )
{
const italic = v.endsWith( ' italic' );
const weight = italic ? v.slice( 0, -7 ).trim() : v;
if ( fontFileExists( meta.family, weight, italic ) )
blocks.push( buildFontFace( meta.family, weight, italic ) );
}
}
else if ( req.query.weights )
{
const weights = ( req.query.weights as string )
.split( ',' )
.map( w => w.trim().toLowerCase() );
for ( const w of weights )
{
if ( fontFileExists( meta.family, w, false ) )
blocks.push( buildFontFace( meta.family, w, false ) );
if ( fontFileExists( meta.family, w, true ) )
blocks.push( buildFontFace( meta.family, w, true ) );
}
}
else
{
for ( const w of meta.weights )
blocks.push( buildFontFace( meta.family, w, false ) );
for ( const w of meta.italics )
blocks.push( buildFontFace( meta.family, w, true ) );
}
res
.set( 'Content-Type', 'text/css; charset=utf-8' )
.set( 'Cache-Control', 'public, max-age=86400' )
.send( blocks.join( '\n\n' ) );
} );
// ─── api router (auth-gated, mounted at /api/fonts) ──────────────────────────
export const apiFontsRouter = Router();
// GET / — list all available font families
apiFontsRouter.get( '/', ( _req: Request, res: Response ): void =>
{
if ( !fs.existsSync( FONTS_DIR ) )
{
res.json( [] );
return;
}
const families = fs.readdirSync( FONTS_DIR, { withFileTypes: true } )
.filter( d => d.isDirectory() )
.map( d =>
{
const meta = readMeta( d.name );
return meta ?? { family: d.name, weights: [], italics: [] };
} );
res.json( families );
} );
// POST /download — fetch from Google Fonts and save to storage
apiFontsRouter.post( '/download', async ( req: Request, res: Response ): Promise<void> =>
{
const { family, weights } = req.body as { family: string; weights?: number[] };
if ( !family || typeof family !== 'string' )
{
res.status( 400 ).json( { error: 'family is required' } );
return;
}
const requestedWeights = ( weights && weights.length > 0 ) ? weights : ALL_WEIGHTS;
const wghts = requestedWeights.map( w => `0,${w};1,${w}` ).join( ';' );
const googleUrl = `https://fonts.googleapis.com/css2?family=${encodeURIComponent( family )}:ital,wght@${wghts}&display=swap`;
let css: string;
try
{
css = await httpsGet( googleUrl, { 'User-Agent': GOOGLE_FONTS_UA } );
}
catch ( err )
{
res.status( 502 ).json( { error: 'Failed to reach Google Fonts', detail: String( err ) } );
return;
}
if ( css.trim() === '' )
{
res.status( 404 ).json( { error: `Font family '${family}' not found on Google Fonts` } );
return;
}
const faceRegex = /@font-face\s*\{([^}]+)\}/g;
const urlRegex = /src:\s*url\(([^)]+)\)/;
const weightRx = /font-weight:\s*(\d+)/;
const styleRx = /font-style:\s*(normal|italic)/;
const dir = fontsDir( family );
fs.mkdirSync( dir, { recursive: true } );
const meta: FontMeta = { family, weights: [], italics: [] };
const downloaded: string[] = [];
let match: RegExpExecArray | null;
while ( ( match = faceRegex.exec( css ) ) !== null )
{
const block = match[1];
const urlMatch = urlRegex.exec( block );
const weightMatch = weightRx.exec( block );
const styleMatch = styleRx.exec( block );
if ( !urlMatch || !weightMatch || !styleMatch ) continue;
const fontUrl = urlMatch[1].trim();
const weight = weightMatch[1];
const italic = styleMatch[1] === 'italic';
const fileName = italic ? `${weight}-italic.woff2` : `${weight}.woff2`;
try
{
const buffer = await httpsGetBuffer( fontUrl );
fs.writeFileSync( path.join( dir, fileName ), buffer );
downloaded.push( fileName );
if ( italic )
{
if ( !meta.italics.includes( weight ) ) meta.italics.push( weight );
}
else
{
if ( !meta.weights.includes( weight ) ) meta.weights.push( weight );
}
}
catch
{
// skip individual file failures
}
}
meta.weights.sort( ( a, b ) => Number( a ) - Number( b ) );
meta.italics.sort( ( a, b ) => Number( a ) - Number( b ) );
writeMeta( meta );
res.json( { ok: true, family, downloaded } );
} );