283 lines
8.6 KiB
TypeScript
283 lines
8.6 KiB
TypeScript
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 } );
|
|
} );
|