openvibers/openvibe.design Commits
Add open font library (#194)
Changes
nnew file mode 100644@@@ -0,0 +1,14 @@++-- phase: expand++-- Public font catalogue and a durable weekly refresh claim. Older releases ignore these tables.++CREATE TABLE IF NOT EXISTS font_families (++ id text COLLATE "C" PRIMARY KEY,++ metadata jsonb NOT NULL,++ updated_at timestamptz NOT NULL DEFAULT now()++);++CREATE TABLE IF NOT EXISTS font_catalog_state (++ id integer PRIMARY KEY CHECK (id = 1),++ last_success_at timestamptz,++ claimed_until timestamptz,++ claim_id text++);++INSERT INTO font_catalog_state (id) VALUES (1) ON CONFLICT (id) DO NOTHING;nnew file mode 100644@@@ -0,0 +1,61 @@++/* The font library: plain catalogue pages, ready for the final Design art direction. */++.fo{max-width:1180px;margin:auto;padding:clamp(22px,4vw,48px) clamp(16px,3vw,32px) 96px;color:var(--text-primary)}++.fo-hero{max-width:820px;margin:0 0 32px}.fo-eyebrow{margin:0 0 12px;color:var(--accent);font-size:12px;font-weight:800;letter-spacing:.12em;text-transform:uppercase}++.fo-hero h1{margin:0;font-size:clamp(36px,6vw,72px);line-height:1.08;letter-spacing:-.035em;overflow-wrap:anywhere}.fo-hero p:last-child{color:var(--text-secondary);font-size:clamp(16px,2vw,20px);line-height:1.5}++.fo-filters{display:grid;grid-template-columns:minmax(180px,2fr) repeat(3,minmax(120px,1fr)) auto;align-items:end;gap:12px;padding:18px;border:1px solid var(--border);border-radius:18px;background:var(--bg-secondary)}++.fo-filters label,.fo-try{display:grid;gap:7px;color:var(--text-secondary);font-size:13px;font-weight:700}.fo-filters input,.fo-filters select,.fo-try input{width:100%;min-width:0;min-height:44px;padding:9px 12px;border:1px solid var(--border);border-radius:10px;background:var(--bg-primary);color:var(--text-primary);font:inherit;font-size:15px}++.fo-filters button{min-height:44px;padding:9px 18px;border:0;border-radius:10px;background:var(--accent);color:var(--bg-primary);font-weight:800;cursor:pointer}.fo-try{margin:22px 0}.fo-try input{font-weight:400}.fo-count{color:var(--text-secondary);font-size:14px}++.fo-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(280px,100%),1fr));gap:16px}.fo-card{min-width:0;padding:22px;border:1px solid var(--border);border-radius:18px;background:var(--bg-secondary)}++.fo-card-top{display:flex;flex-wrap:wrap;justify-content:space-between;gap:8px;color:var(--text-secondary);font-size:12px;text-transform:capitalize}.fo-card h2{margin:22px 0 12px;font-size:22px}.fo-card h2 a,.fo-pairings a{color:inherit;text-decoration:none}.fo-card h2 a:hover,.fo-pairings a:hover{text-decoration:underline}.fo-sample{min-height:95px;margin:0;font-size:clamp(22px,2.4vw,28px);line-height:1.25;overflow-wrap:anywhere}.fo-meta{margin:18px 0 0;color:var(--text-secondary);font-size:13px}++.fo-back{display:inline-block;margin-bottom:26px;color:var(--accent)}.fo-detail section{margin-top:44px}.fo-detail h2{font-size:clamp(22px,3vw,30px)}.fo-styles{display:grid;gap:12px}.fo-style{min-width:0;padding:16px 20px;border:1px solid var(--border);border-radius:14px;background:var(--bg-secondary)}.fo-style>div{display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px;font-size:14px}.fo-style p{margin:15px 0 2px;font-size:clamp(22px,4vw,42px);line-height:1.3;overflow-wrap:anywhere}++.fo-details{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:24px}.fo-details>div{min-width:0}.fo-details pre{max-width:100%;overflow-x:auto;padding:18px;border:1px solid var(--border);border-radius:12px;background:var(--bg-secondary);font-size:13px}.fo-details p{overflow-wrap:anywhere}.fo-integrity{display:block;margin-top:12px;color:var(--text-secondary);font-size:12px;overflow-wrap:anywhere}.fo-pairings{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px}.fo-pairings article{min-width:0;padding:18px;border:1px solid var(--border);border-radius:14px;background:var(--bg-secondary)}.fo-pairings h3{margin:0 0 10px}.fo-pairings p{margin:0;color:var(--text-secondary);line-height:1.5}++@media(max-width:850px){.fo-filters{grid-template-columns:repeat(2,minmax(0,1fr))}.fo-filters button{grid-column:1/-1}.fo-details{grid-template-columns:1fr}}@media(max-width:550px){.fo-filters{grid-template-columns:1fr}.fo-pairings{grid-template-columns:1fr}}++++/* Design pass (Opus, 11-08): the catalogue as a specimen sheet. Each family's name is drawn in itself, two to a row on++ a phone; categories are chips; a family's styles go two to a row with a counted fold; a pairing is drawn as a pair. */++.fo-chips{display:flex;gap:6px;margin:0 0 12px;overflow-x:auto;scrollbar-width:none;padding:2px 0 4px}++.fo-chips::-webkit-scrollbar{display:none}++.fo-chips a{flex:none;display:inline-flex;align-items:center;gap:6px;min-height:36px;padding:0 14px;border:1px solid var(--border);border-radius:999px;color:var(--text-secondary);text-decoration:none;font-size:14px;font-weight:650;text-transform:capitalize}++.fo-chips a span{color:var(--text-muted,var(--text-secondary));font-weight:500;font-size:12px}++.fo-chips a[aria-current]{border-color:var(--accent);color:var(--text-primary);background:color-mix(in srgb,var(--accent) 14%,transparent)}++.fo-filters{grid-template-columns:minmax(200px,2fr) repeat(2,minmax(120px,1fr)) auto;padding:12px;gap:10px}++.fo-try{margin:14px 0 6px}++.fo-grid{grid-template-columns:repeat(auto-fill,minmax(min(240px,100%),1fr));gap:12px}++.fo-card{padding:0;border-radius:16px;transition:border-color .15s,transform .15s}++.fo-card:hover{border-color:var(--accent)}++.fo-card:active{transform:scale(.99)}++.fo-card-link{display:grid;gap:8px;padding:16px 16px 14px;color:inherit;text-decoration:none;height:100%;box-sizing:border-box}++.fo-card h2{margin:0;font-size:28px;line-height:1.1;font-weight:400;overflow-wrap:anywhere}++.fo-card .fo-sample{min-height:0;font-size:17px;line-height:1.35;color:var(--text-secondary)}++.fo-meta{margin:auto 0 0;font-size:12px;color:var(--text-muted,var(--text-secondary))}++.fo-meta span{text-transform:capitalize}++.fo-n{margin-left:6px;font-size:.6em;font-weight:600;color:var(--text-muted,var(--text-secondary))}++.fo-styles{grid-template-columns:repeat(auto-fill,minmax(min(420px,100%),1fr))}++.fo-style{padding:12px 16px}++.fo-style p{margin:6px 0 0;font-size:22px;line-height:1.25;overflow-wrap:anywhere}++.fo-style>div{font-size:13px;color:var(--text-secondary)}++.fo-style a{color:var(--accent);font-weight:650;text-decoration:none}++.fo-more{margin-top:12px}++.fo-more>summary{cursor:pointer;display:inline-flex;align-items:center;gap:6px;min-height:40px;padding:0 16px;border:1px solid var(--border);border-radius:999px;font-weight:650;list-style:none}++.fo-more>summary::-webkit-details-marker{display:none}++.fo-more>summary span{color:var(--text-secondary);font-weight:500}++.fo-more[open]>summary{margin-bottom:12px}++.fo-note{color:var(--text-secondary);max-width:70ch}++.fo-pairings{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(300px,100%),1fr));gap:12px}++.fo-pairings article{padding:18px;border:1px solid var(--border);border-radius:16px;background:var(--bg-secondary)}++.fo-pair-h{margin:0;font-size:26px;line-height:1.15}++.fo-pair-b{margin:8px 0 12px;font-size:16px;line-height:1.5;color:var(--text-secondary)}++.fo-pair-why{margin:0;font-size:12.5px;color:var(--text-muted,var(--text-secondary))}++@media(max-width:550px){++ .fo-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}++ .fo-card-link{padding:12px}++ .fo-card h2{font-size:22px}++ .fo-card .fo-sample{font-size:14px}++ .fo-filters{grid-template-columns:1fr 1fr}++ .fo-filters .fo-q,.fo-filters button{grid-column:1/-1}++ .fo-style p{font-size:19px}++}++/* The selects look like the search field, not like buttons (a theme's button colours reached them on a phone) */++.fo .fo-filters select{appearance:none;-webkit-appearance:none;background:var(--bg-primary) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8'%3E%3Cpath d='M1 1l5 5 5-5' fill='none' stroke='%2396a7c2' stroke-width='2'/%3E%3C/svg%3E") no-repeat right 12px center;color:var(--text-primary);border:1px solid var(--border);padding-right:32px;box-shadow:none}++.fo .fo-filters button{background:transparent;color:var(--text-primary);border:1px solid var(--accent)}++.fo .fo-filters button:hover{background:color-mix(in srgb,var(--accent) 14%,transparent)}nnew file mode 100644@@@ -0,0 +1,8 @@++'use strict';++document.addEventListener('DOMContentLoaded', () => {++ const input = document.querySelector('[data-font-sample]');++ if (!input) return;++ const samples = [...document.querySelectorAll('.fo-sample,.fo-style p,.fo-pair-b')];++ const original = samples.map(node => node.textContent);++ input.addEventListener('input', () => samples.forEach((node, index) => { node.textContent = input.value || original[index]; }));++});@@@ -45,6 +45,8 @@ const { createPageRoutes } = require('./http/pages');const { createTokenFileRoutes } = require('./http/tokens');const { createDiscoveryRoutes } = require('./http/discovery');const { createIconRoutes } = require('./http/icons');++const { createFontRoutes } = require('./http/fonts');++const { createFontCatalog } = require('./fonts/catalog');const { createServiceReadiness } = require('./observability');const { createArenaStore } = require('./arena/store');const { createArenaAi } = require('./arena/ai');@@@ -97,12 +99,14 @@ async function createApp(opts = {}) {const publisher = opts.publisher || require('./publish').createPublisher({ config, fetchImpl, log });const accountData = accountDataLib.create({ db: s.db, log });const picks = opts.picks || require('./brand-picks').createBrandPicks({ db: s.db, now });++ const fonts = opts.fonts || createFontCatalog({ db: s.db, fetchImpl, now, log });++ await fonts.seedMissing();const accountSend = opts.accountSend || (config.oauth.clientSecret? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl }): async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Design cannot answer account events'); });const ctx = {-- config, s, store: s, keys, sso, settings, catalog, searchIndex, arena, bench, ai, jobs, designs, sharing, publisher, accountData, accountSend, log, picks,++ config, s, store: s, keys, sso, settings, catalog, searchIndex, arena, bench, ai, jobs, designs, sharing, publisher, accountData, accountSend, log, picks, fonts,random: opts.random || Math.random,};@@@ -136,7 +140,7 @@ async function createApp(opts = {}) {// The OpenVibe Frame (theme-loader, navbar, footer) comes from the Network; the inline init is ours.scriptSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://static.cloudflareinsights.com', ...turnstileSrc],styleSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://fonts.googleapis.com', 'https://cdnjs.cloudflare.com'],-- fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com', 'https://cdnjs.cloudflare.com'],++ fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com', 'https://cdnjs.cloudflare.com', 'https://cdn.jsdelivr.net'],// Theme previews may carry inline data: images and any https image the catalogue references.imgSrc: ["'self'", 'data:', 'https:'],// The footer's release line hears OpenVibe.Events; Cloudflare's beacon reports page timings.@@@ -275,6 +279,7 @@ async function createApp(opts = {}) {app.use('/api/v1/brand', require('./http/brand-api').createBrandApi(ctx));app.get('/api/v1/prompts', (req, res) => { res.set('Cache-Control', 'public, max-age=300'); res.json({ prompts: require('./views/prompts').list() }); });app.use(createIconRoutes(ctx));++ app.use(createFontRoutes(ctx));app.use('/api', createApi(ctx));// GET /openapi.json (SEO-3): the API as OpenAPI 3.1. It is not in openvibe-contracts, so the document is read from// the routes this app mounts under /api/, with the summaries the API files' header comments give them@@@ -285,7 +290,7 @@ async function createApp(opts = {}) {app.get('/openapi.json', require('./http/openapi-routes').openapiRoute(app, {title: `${d.name} API`, description: d.purpose, version: require('../package.json').version, baseUrl: site,docs: `${site}/developers`,-- files: ['http/api.js', 'http/arena-api.js', 'http/brand-api.js', 'http/design-api.js'].map((f) => require('path').join(__dirname, f)),++ files: ['http/api.js', 'http/arena-api.js', 'http/brand-api.js', 'http/design-api.js', 'http/fonts.js'].map((f) => require('path').join(__dirname, f)),}));}app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', {nnew file mode 100644@@@ -0,0 +1,154 @@++'use strict';++++const crypto = require('node:crypto');++const seed = require('./seed.json');++++const WEEK = 7 * 24 * 60 * 60 * 1000;++const CLAIM = 10 * 60 * 1000;++const ALLOWED = new Set(['OFL-1.1', 'Apache-2.0', 'UFL-1.0']);++const CDN = /^https:\/\/cdn\.jsdelivr\.net\/fontsource\/fonts\/[a-z0-9-]+@[0-9][a-z0-9.-]*\/[a-z0-9-]+\.woff2$/;++const IDS = seed.map(f => f.id);++++function validFont(f) {++ return f && /^[a-z0-9-]+$/.test(f.id) && typeof f.family === 'string' && /^[A-Za-z0-9 .'-]{1,100}$/.test(f.family)++ && typeof f.designer === 'string' && f.designer.length > 0++ && ALLOWED.has(f.license) && /^https:\/\/github\.com\/google\/fonts\/tree\/main\/(ofl|apache|ufl)\/[a-z0-9]+$/.test(f.license_url)++ && /^https:\/\//.test(f.source) && typeof f.version === 'string'++ && Array.isArray(f.weights) && f.weights.every(w => Number.isInteger(w) && w >= 100 && w <= 900)++ && Array.isArray(f.styles) && f.styles.every(s => ['normal', 'italic'].includes(s))++ && Array.isArray(f.subsets) && f.subsets.every(s => /^[a-z0-9-]+$/.test(s))++ && f.files && Object.keys(f.files).length > 0 && Object.values(f.files).every(url => CDN.test(url));++}++++function due(state, now) {++ if (state.claimed_until && new Date(state.claimed_until).getTime() > now) return false;++ return !state.last_success_at || now - new Date(state.last_success_at).getTime() >= WEEK;++}++++function profile(f) {++ const classic = ['serif', 'display', 'handwriting'].includes(f.category) || ['lato', 'oswald', 'josefin-sans', 'ubuntu'].includes(f.id);++ const short = ['serif', 'display', 'handwriting'].includes(f.category) || ['raleway', 'oswald', 'abril-fatface'].includes(f.id);++ return { era: classic ? 'classic' : 'modern', xHeight: short ? 'low' : 'high' };++}++++function pairings(f, all) {++ const a = profile(f);++ const candidates = all.filter(g => g.id !== f.id).map(g => {++ const b = profile(g);++ const category = f.category !== g.category;++ const height = a.xHeight !== b.xHeight;++ const era = a.era !== b.era;++ const headWeight = g.files['700-normal'] ? 700 : g.files['600-normal'] ? 600 : g.files['400-normal'] ? 400 : Number(String(Object.keys(g.files)[0] || '400').split('-')[0]);++ return { family: g.family, id: g.id, category: g.category, file: g.files[`${headWeight}-normal`] || Object.values(g.files)[0], weight: headWeight, score: (category ? 4 : 0) + (height ? 2 : 0) + (era ? 1 : 0),++ reason: [category && `contrasting ${g.category} and ${f.category} forms`, height && 'different x-height', era && 'classic and modern character'].filter(Boolean).join('; ') || 'a quieter companion' };++ }).sort((x, y) => y.score - x.score || x.family.localeCompare(y.family));++ const chosen = [];++ for (const candidate of candidates) if (!chosen.some(p => p.category === candidate.category)) {++ chosen.push(candidate);++ if (chosen.length === 3) break;++ }++ for (const candidate of candidates) if (chosen.length < 3 && !chosen.includes(candidate)) chosen.push(candidate);++ return chosen.map(({ score, category, ...p }) => p);++}++++function createFontCatalog({ db, fetchImpl = fetch, now = () => Date.now(), log = console } = {}) {++ let timer = null;++ let running = false;++ async function seedMissing() {++ // A completed refresh may have removed a family whose licence changed. Never restore it on restart.++ const state = await db.maybe('SELECT last_success_at FROM font_catalog_state WHERE id = 1');++ if (state?.last_success_at) return;++ for (const f of seed) {++ if (!validFont(f)) throw new Error(`Invalid font seed: ${f.id}`);++ await db.exec('INSERT INTO font_families (id, metadata) VALUES ($1, $2::jsonb) ON CONFLICT (id) DO NOTHING', [f.id, JSON.stringify(f)]);++ }++ }++ async function list(filters = {}) {++ const rows = await db.many('SELECT metadata FROM font_families ORDER BY id');++ const q = String(filters.q || '').trim().toLowerCase().slice(0, 100);++ const weight = filters.weight ? Number(filters.weight) : null;++ const fonts = rows.map(r => r.metadata).filter(validFont).filter(f =>++ (!q || `${f.family} ${f.designer}`.toLowerCase().includes(q))++ && (!filters.category || f.category === filters.category)++ && (!filters.script || f.subsets.includes(filters.script))++ && (!weight || f.weights.includes(weight)));++ return fonts;++ }++ async function get(id) {++ if (!/^[a-z0-9-]{1,100}$/.test(id)) return null;++ const row = await db.maybe('SELECT metadata FROM font_families WHERE id = $1', [id]);++ if (!row || !validFont(row.metadata)) return null;++ return { ...row.metadata, pairings: pairings(row.metadata, await list()) };++ }++ async function fetchFont(old) {++ const response = await fetchImpl(`https://api.fontsource.org/v1/fonts/${old.id}`, { signal: AbortSignal.timeout(15_000) });++ if (!response.ok) throw new Error(`Fontsource ${old.id}: HTTP ${response.status}`);++ const f = await response.json();++ if (!ALLOWED.has(f.license)) return null;++ const dir = f.license === 'Apache-2.0' ? 'apache' : f.license === 'UFL-1.0' ? 'ufl' : 'ofl';++ const licenseUrl = `https://github.com/google/fonts/tree/main/${dir}/${old.id.replace(/-/g, '')}`;++ const metaUrl = `${licenseUrl.replace('https://github.com/google/fonts/tree/main/', 'https://raw.githubusercontent.com/google/fonts/main/')}/METADATA.pb`;++ const metadata = await fetchImpl(metaUrl, { signal: AbortSignal.timeout(15_000) });++ if (!metadata.ok) throw new Error(`Font metadata ${old.id}: HTTP ${metadata.status}`);++ const designer = (String(await metadata.text()).match(/^designer: "([^"]+)"/m) || [])[1];++ const files = {};++ for (const weight of f.weights || []) for (const style of f.styles || []) {++ const url = f.variants?.[weight]?.[style]?.[f.defSubset]?.url?.woff2;++ if (url) files[`${weight}-${style}`] = url;++ }++ const result = { id: f.id, family: f.family, designer, category: f.category,++ weights: f.weights, styles: f.styles, subsets: f.subsets, license: f.license,++ version: f.version, source: f.source, license_url: licenseUrl, files };++ if (!validFont(result)) throw new Error(`Invalid metadata for ${old.id}`);++ const preview = result.files['400-normal'] || Object.values(result.files)[0];++ const asset = await fetchImpl(preview, { signal: AbortSignal.timeout(15_000) });++ if (asset.ok) result.sri = `sha384-${crypto.createHash('sha384').update(Buffer.from(await asset.arrayBuffer())).digest('base64')}`;++ return result;++ }++ async function tick() {++ if (running) return false;++ running = true;++ const time = now();++ const claimId = crypto.randomUUID();++ try {++ const claim = await db.maybe(`UPDATE font_catalog_state SET claim_id = $1, claimed_until = $2::timestamptz++ WHERE id = 1 AND (claimed_until IS NULL OR claimed_until <= $3::timestamptz)++ AND (last_success_at IS NULL OR last_success_at <= $4::timestamptz) RETURNING id`,++ [claimId, new Date(time + CLAIM).toISOString(), new Date(time).toISOString(), new Date(time - WEEK).toISOString()]);++ if (!claim) return false;++ try {++ const results = [];++ // Keep the remote request rate modest; the claim spans the whole refresh.++ for (const id of IDS) {++ const old = seed.find(f => f.id === id);++ const result = await fetchFont(old);++ if (result) results.push(result);++ }++ await db.tx(async () => {++ const held = await db.maybe('SELECT id FROM font_catalog_state WHERE id = 1 AND claim_id = $1 FOR UPDATE', [claimId]);++ if (!held) return;++ for (const f of results) await db.exec(`INSERT INTO font_families (id, metadata, updated_at) VALUES ($1, $2::jsonb, $3::timestamptz)++ ON CONFLICT (id) DO UPDATE SET metadata = EXCLUDED.metadata, updated_at = EXCLUDED.updated_at`,++ [f.id, JSON.stringify(f), new Date(time).toISOString()]);++ await db.exec('DELETE FROM font_families WHERE id <> ALL($1::text[])', [results.map(f => f.id)]);++ await db.exec('UPDATE font_catalog_state SET last_success_at = $1::timestamptz, claimed_until = NULL, claim_id = NULL WHERE id = 1 AND claim_id = $2', [new Date(time).toISOString(), claimId]);++ });++ return true;++ } catch (err) {++ await db.exec('UPDATE font_catalog_state SET claimed_until = NULL, claim_id = NULL WHERE id = 1 AND claim_id = $1', [claimId]);++ throw err;++ }++ } finally { running = false; }++ }++ function start() {++ if (timer) return;++ const run = () => tick().catch(e => log.warn('[OpenVibe.Design] font refresh:', e.message));++ run();++ timer = setInterval(run, 60 * 60 * 1000);++ timer.unref?.();++ }++ function stop() { if (timer) clearInterval(timer); timer = null; }++ return { seedMissing, list, get, tick, start, stop };++}++++module.exports = { createFontCatalog, validFont, due, pairings, WEEK };nnew file mode 100644@@@ -0,0 +1,1678 @@++[++ {++ "id": "abril-fatface",++ "family": "Abril Fatface",++ "designer": "TypeTogether",++ "category": "display",++ "weights": [++ 400++ ],++ "styles": [++ "normal"++ ],++ "subsets": [++ "latin",++ "latin-ext"++ ],++ "license": "OFL-1.1",++ "version": "v25",++ "source": "https://github.com/google/fonts",++ "license_url": "https://github.com/google/fonts/tree/main/ofl/abrilfatface",++ "files": {++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2"++ },++ "sri": "sha384-wpDjVWX1raklpoCthpT+SqPotxqGYsT8rnl9vOxxtp35U2RCo75d5JEHgDqOl7YV"++ },++ {++ "id": "cormorant-garamond",++ "family": "Cormorant Garamond",++ "designer": "Christian Thalmann",++ "category": "serif",++ "weights": [++ 300,++ 400,++ 500,++ 600,++ 700++ ],++ "styles": [++ "italic",++ "normal"++ ],++ "subsets": [++ "cyrillic",++ "cyrillic-ext",++ "latin",++ "latin-ext",++ "vietnamese"++ ],++ "license": "OFL-1.1",++ "version": "v21",++ "source": "https://github.com/google/fonts",++ "license_url": "https://github.com/google/fonts/tree/main/ofl/cormorantgaramond",++ "files": {++ "300-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-italic.woff2",++ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",++ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",++ "500-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-italic.woff2",++ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",++ "600-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-italic.woff2",++ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",++ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2"++ },++ "sri": "sha384-C7LOiVF8vrW1L9HuZmyWfOOwJ/AklDdrU0e8qUh/vUCDauoEUDgdnz84hC8u5UKR"++ },++ {++ "id": "dancing-script",++ "family": "Dancing Script",++ "designer": "Impallari Type",++ "category": "handwriting",++ "weights": [++ 400,++ 500,++ 600,++ 700++ ],++ "styles": [++ "normal"++ ],++ "subsets": [++ "latin",++ "latin-ext",++ "vietnamese"++ ],++ "license": "OFL-1.1",++ "version": "v29",++ "source": "https://github.com/google/fonts",++ "license_url": "https://github.com/google/fonts/tree/main/ofl/dancingscript",++ "files": {++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",++ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",++ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2"++ },++ "sri": "sha384-lrSz6x6b6m90dJcsoNLhQhuKJXyrVxpHZVtubEuWgw2Pdg1znt+/jxv9YGhfvjJ0"++ },++ {++ "id": "dm-sans",++ "family": "DM Sans",++ "designer": "Colophon Foundry",++ "category": "sans-serif",++ "weights": [++ 100,++ 200,++ 300,++ 400,++ 500,++ 600,++ 700,++ 800,++ 900++ ],++ "styles": [++ "italic",++ "normal"++ ],++ "subsets": [++ "latin",++ "latin-ext"++ ],++ "license": "OFL-1.1",++ "version": "v17",++ "source": "https://github.com/google/fonts",++ "license_url": "https://github.com/google/fonts/tree/main/ofl/dmsans",++ "files": {++ "100-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-italic.woff2",++ "100-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-normal.woff2",++ "200-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-italic.woff2",++ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",++ "300-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-italic.woff2",++ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",++ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",++ "500-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-italic.woff2",++ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",++ "600-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-italic.woff2",++ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",++ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",++ "800-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-italic.woff2",++ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2",++ "900-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-italic.woff2",++ "900-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-normal.woff2"++ },++ "sri": "sha384-ZdBBucdtQA81cDAiMrRzG0sKtablpH7avbTQ8sL/bLTJaRLqKw3iRRIk7BlZXmBL"++ },++ {++ "id": "fira-code",++ "family": "Fira Code",++ "designer": "The Mozilla Foundation, Telefonica S.A., Nikita Prokopov",++ "category": "monospace",++ "weights": [++ 300,++ 400,++ 500,++ 600,++ 700++ ],++ "styles": [++ "normal"++ ],++ "subsets": [++ "cyrillic",++ "cyrillic-ext",++ "greek",++ "greek-ext",++ "latin",++ "latin-ext",++ "symbols2"++ ],++ "license": "OFL-1.1",++ "version": "v27",++ "source": "https://github.com/google/fonts",++ "license_url": "https://github.com/google/fonts/tree/main/ofl/firacode",++ "files": {++ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",++ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",++ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2"++ },++ "sri": "sha384-8yWSvZzF1qfjfXS1YbHQUZEefwH+Xr7wHeqdaG0dWfVTJyqZrzfkxNN1K9Sn1WFG"++ },++ {++ "id": "fira-sans",++ "family": "Fira Sans",++ "designer": "Carrois Apostrophe",++ "category": "sans-serif",++ "weights": [++ 100,++ 200,++ 300,++ 400,++ 500,++ 600,++ 700,++ 800,++ 900++ ],++ "styles": [++ "italic",++ "normal"++ ],++ "subsets": [++ "cyrillic",++ "cyrillic-ext",++ "greek",++ "greek-ext",++ "latin",++ "latin-ext",++ "vietnamese"++ ],++ "license": "OFL-1.1",++ "version": "v18",++ "source": "https://github.com/google/fonts",++ "license_url": "https://github.com/google/fonts/tree/main/ofl/firasans",++ "files": {++ "100-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-italic.woff2",++ "100-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-normal.woff2",++ "200-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-italic.woff2",++ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",++ "300-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-italic.woff2",++ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",++ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",++ "500-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-italic.woff2",++ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",++ "600-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-italic.woff2",++ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",++ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",++ "800-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-italic.woff2",++ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2",++ "900-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-italic.woff2",++ "900-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-normal.woff2"++ },++ "sri": "sha384-+mFWbPBWAziTjtBaXJHKLKt8ciu/OCjv0taTqX0caIJ0siDoWne4W6miuaELAXvj"++ },++ {++ "id": "ibm-plex-mono",++ "family": "IBM Plex Mono",++ "designer": "Mike Abbink, Bold Monday",++ "category": "monospace",++ "weights": [++ 100,++ 200,++ 300,++ 400,++ 500,++ 600,++ 700++ ],++ "styles": [++ "italic",++ "normal"++ ],++ "subsets": [++ "cyrillic",++ "cyrillic-ext",++ "latin",++ "latin-ext",++ "vietnamese"++ ],++ "license": "OFL-1.1",++ "version": "v20",++ "source": "https://github.com/google/fonts",++ "license_url": "https://github.com/google/fonts/tree/main/ofl/ibmplexmono",++ "files": {++ "100-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-italic.woff2",++ "100-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-normal.woff2",++ "200-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-italic.woff2",++ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",++ "300-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-italic.woff2",++ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",++ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",++ "500-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-italic.woff2",++ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",++ "600-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-italic.woff2",++ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",++ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2"++ },++ "sri": "sha384-oFQAuS7n3CFHgvwGpuoFV1kPR39MQLTlQIL+UnoEvMkkFZsrFNOZy/f7x+DofQNH"++ },++ {++ "id": "inconsolata",++ "family": "Inconsolata",++ "designer": "Raph Levien",++ "category": "monospace",++ "weights": [++ 200,++ 300,++ 400,++ 500,++ 600,++ 700,++ 800,++ 900++ ],++ "styles": [++ "normal"++ ],++ "subsets": [++ "latin",++ "latin-ext",++ "vietnamese"++ ],++ "license": "OFL-1.1",++ "version": "v37",++ "source": "https://github.com/google/fonts",++ "license_url": "https://github.com/google/fonts/tree/main/ofl/inconsolata",++ "files": {++ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",++ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",++ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",++ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",++ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2",++ "900-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-normal.woff2"++ },++ "sri": "sha384-SadIt3drVYfBAGMttu7SYdoD0icQVq35V3MfX8oSCrAJCwrQtE19i4TDCYb9ZTHZ"++ },++ {++ "id": "inter",++ "family": "Inter",++ "designer": "Rasmus Andersson",++ "category": "sans-serif",++ "weights": [++ 100,++ 200,++ 300,++ 400,++ 500,++ 600,++ 700,++ 800,++ 900++ ],++ "styles": [++ "italic",++ "normal"++ ],++ "subsets": [++ "cyrillic",++ "cyrillic-ext",++ "greek",++ "greek-ext",++ "latin",++ "latin-ext",++ "vietnamese"++ ],++ "license": "OFL-1.1",++ "version": "v20",++ "source": "https://github.com/google/fonts",++ "license_url": "https://github.com/google/fonts/tree/main/ofl/inter",++ "files": {++ "100-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-italic.woff2",++ "100-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-normal.woff2",++ "200-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-italic.woff2",++ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",++ "300-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-italic.woff2",++ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",++ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",++ "500-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-italic.woff2",++ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",++ "600-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-italic.woff2",++ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",++ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",++ "800-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-italic.woff2",++ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2",++ "900-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-italic.woff2",++ "900-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-normal.woff2"++ },++ "sri": "sha384-tqh0FAOjwwo8QcrfmptsbU/OH9394dupGj2d2zJQ7lFJdE5ouzRr13+2JlwV7vrE"++ },++ {++ "id": "jetbrains-mono",++ "family": "JetBrains Mono",++ "designer": "JetBrains, Philipp Nurullin, Konstantin Bulenkov",++ "category": "monospace",++ "weights": [++ 100,++ 200,++ 300,++ 400,++ 500,++ 600,++ 700,++ 800++ ],++ "styles": [++ "italic",++ "normal"++ ],++ "subsets": [++ "cyrillic",++ "cyrillic-ext",++ "greek",++ "latin",++ "latin-ext",++ "vietnamese"++ ],++ "license": "OFL-1.1",++ "version": "v24",++ "source": "https://github.com/google/fonts",++ "license_url": "https://github.com/google/fonts/tree/main/ofl/jetbrainsmono",++ "files": {++ "100-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-italic.woff2",++ "100-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-normal.woff2",++ "200-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-italic.woff2",++ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",++ "300-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-italic.woff2",++ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",++ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",++ "500-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-italic.woff2",++ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",++ "600-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-italic.woff2",++ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",++ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",++ "800-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-italic.woff2",++ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2"++ },++ "sri": "sha384-/4dCc3INKaFZBsZMku6bd44i8Gr1uXW1GYvdcNjsbHXHh2nGnk9G4RAhHf8hx3GT"++ },++ {++ "id": "josefin-sans",++ "family": "Josefin Sans",++ "designer": "Santiago Orozco",++ "category": "sans-serif",++ "weights": [++ 100,++ 200,++ 300,++ 400,++ 500,++ 600,++ 700++ ],++ "styles": [++ "italic",++ "normal"++ ],++ "subsets": [++ "latin",++ "latin-ext",++ "vietnamese"++ ],++ "license": "OFL-1.1",++ "version": "v34",++ "source": "https://github.com/google/fonts",++ "license_url": "https://github.com/google/fonts/tree/main/ofl/josefinsans",++ "files": {++ "100-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-italic.woff2",++ "100-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-normal.woff2",++ "200-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-italic.woff2",++ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",++ "300-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-italic.woff2",++ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",++ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",++ "500-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-italic.woff2",++ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",++ "600-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-italic.woff2",++ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",++ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2"++ },++ "sri": "sha384-dnO5dwLeQGRECJGM/PT1eRqKbkQTCefJr96a9obMYjlDnlpYSmCqZYnA3n2LxpXU"++ },++ {++ "id": "lato",++ "family": "Lato",++ "designer": "Łukasz Dziedzic",++ "category": "sans-serif",++ "weights": [++ 100,++ 300,++ 400,++ 700,++ 900++ ],++ "styles": [++ "italic",++ "normal"++ ],++ "subsets": [++ "latin",++ "latin-ext"++ ],++ "license": "OFL-1.1",++ "version": "v25",++ "source": "https://github.com/google/fonts",++ "license_url": "https://github.com/google/fonts/tree/main/ofl/lato",++ "files": {++ "100-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-italic.woff2",++ "100-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-normal.woff2",++ "300-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-italic.woff2",++ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",++ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",++ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",++ "900-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-italic.woff2",++ "900-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-normal.woff2"++ },++ "sri": "sha384-+chn3xqCWRPSXDBtVEFQn+0J9Gb0SYR2FO1WrXo+L/arnyr8lXLVX2p0BqZIUemc"++ },++ {++ "id": "libre-baskerville",++ "family": "Libre Baskerville",++ "designer": "Impallari Type",++ "category": "serif",++ "weights": [++ 400,++ 500,++ 600,++ 700++ ],++ "styles": [++ "italic",++ "normal"++ ],++ "subsets": [++ "latin",++ "latin-ext"++ ],++ "license": "OFL-1.1",++ "version": "v24",++ "source": "https://github.com/google/fonts",++ "license_url": "https://github.com/google/fonts/tree/main/ofl/librebaskerville",++ "files": {++ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",++ "500-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-italic.woff2",++ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",++ "600-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-italic.woff2",++ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",++ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2"++ },++ "sri": "sha384-uRckE2OjWTlzltjCXxrZffS/bXnLA6oEECyxYUb3eix/O1iaEoOdEnkaHoB2R36J"++ },++ {++ "id": "lora",++ "family": "Lora",++ "designer": "Cyreal",++ "category": "serif",++ "weights": [++ 400,++ 500,++ 600,++ 700++ ],++ "styles": [++ "italic",++ "normal"++ ],++ "subsets": [++ "cyrillic",++ "cyrillic-ext",++ "latin",++ "latin-ext",++ "math",++ "symbols",++ "vietnamese"++ ],++ "license": "OFL-1.1",++ "version": "v37",++ "source": "https://github.com/google/fonts",++ "license_url": "https://github.com/google/fonts/tree/main/ofl/lora",++ "files": {++ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",++ "500-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-italic.woff2",++ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",++ "600-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-italic.woff2",++ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",++ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2"++ },++ "sri": "sha384-4K0WjzceC/SOy/usx6omCD3VyEOFfhpO97BismDBHNTJVOkoaAqmoEOWkiDGJr/8"++ },++ {++ "id": "manrope",++ "family": "Manrope",++ "designer": "Mikhail Sharanda",++ "category": "sans-serif",++ "weights": [++ 200,++ 300,++ 400,++ 500,++ 600,++ 700,++ 800++ ],++ "styles": [++ "normal"++ ],++ "subsets": [++ "cyrillic",++ "cyrillic-ext",++ "greek",++ "latin",++ "latin-ext",++ "vietnamese"++ ],++ "license": "OFL-1.1",++ "version": "v20",++ "source": "https://github.com/google/fonts",++ "license_url": "https://github.com/google/fonts/tree/main/ofl/manrope",++ "files": {++ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",++ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",++ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",++ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",++ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2"++ },++ "sri": "sha384-MZMzLPF8CUlQGCqf+feQOIKEzmoRP/SDPngpfNIiv7DasqbtsikGsheLZWB98pAR"++ },++ {++ "id": "merriweather",++ "family": "Merriweather",++ "designer": "Sorkin Type",++ "category": "serif",++ "weights": [++ 300,++ 400,++ 500,++ 600,++ 700,++ 800,++ 900++ ],++ "styles": [++ "italic",++ "normal"++ ],++ "subsets": [++ "cyrillic",++ "cyrillic-ext",++ "latin",++ "latin-ext",++ "vietnamese"++ ],++ "license": "OFL-1.1",++ "version": "v33",++ "source": "https://github.com/google/fonts",++ "license_url": "https://github.com/google/fonts/tree/main/ofl/merriweather",++ "files": {++ "300-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-italic.woff2",++ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",++ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",++ "500-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-italic.woff2",++ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",++ "600-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-italic.woff2",++ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",++ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",++ "800-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-italic.woff2",++ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2",++ "900-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-italic.woff2",++ "900-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-normal.woff2"++ },++ "sri": "sha384-ZgZJGQ6rET5qzeALEh9evIZFPZkUYTzCcGEuYSyWnqSwzXc9Qyefyu+AJ+ynNhyi"++ },++ {++ "id": "montserrat",++ "family": "Montserrat",++ "designer": "Julieta Ulanovsky, Sol Matas, Juan Pablo del Peral, Jacques Le Bailly",++ "category": "sans-serif",++ "weights": [++ 100,++ 200,++ 300,++ 400,++ 500,++ 600,++ 700,++ 800,++ 900++ ],++ "styles": [++ "italic",++ "normal"++ ],++ "subsets": [++ "cyrillic",++ "cyrillic-ext",++ "latin",++ "latin-ext",++ "vietnamese"++ ],++ "license": "OFL-1.1",++ "version": "v31",++ "source": "https://github.com/google/fonts",++ "license_url": "https://github.com/google/fonts/tree/main/ofl/montserrat",++ "files": {++ "100-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-italic.woff2",++ "100-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-normal.woff2",++ "200-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-italic.woff2",++ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",++ "300-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-italic.woff2",++ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",++ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",++ "500-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-italic.woff2",++ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",++ "600-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-italic.woff2",++ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",++ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",++ "800-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-italic.woff2",++ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2",++ "900-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-italic.woff2",++ "900-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-normal.woff2"++ },++ "sri": "sha384-TZp3MNli7D4I9HF7ut1KGl19b69Ah6oafBq+SgFy8VzLt3vFx74DrLJ2Jt/z5gUY"++ },++ {++ "id": "noto-sans-arabic",++ "family": "Noto Sans Arabic",++ "designer": "Google",++ "category": "sans-serif",++ "weights": [++ 100,++ 200,++ 300,++ 400,++ 500,++ 600,++ 700,++ 800,++ 900++ ],++ "styles": [++ "normal"++ ],++ "subsets": [++ "arabic",++ "latin",++ "latin-ext",++ "math",++ "symbols"++ ],++ "license": "OFL-1.1",++ "version": "v33",++ "source": "https://github.com/google/fonts",++ "license_url": "https://github.com/google/fonts/tree/main/ofl/notosansarabic",++ "files": {++ "100-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-normal.woff2",++ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",++ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",++ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",++ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",++ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2",++ "900-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-normal.woff2"++ },++ "sri": "sha384-ktrmBjgMzmznHZm9VmzUAl/2ht+E/QB11F4mctEPuHTmK7VU8KeItUlJ945Qv6FI"++ },++ {++ "id": "noto-sans-devanagari",++ "family": "Noto Sans Devanagari",++ "designer": "Google",++ "category": "sans-serif",++ "weights": [++ 100,++ 200,++ 300,++ 400,++ 500,++ 600,++ 700,++ 800,++ 900++ ],++ "styles": [++ "normal"++ ],++ "subsets": [++ "devanagari",++ "latin",++ "latin-ext"++ ],++ "license": "OFL-1.1",++ "version": "v30",++ "source": "https://github.com/google/fonts",++ "license_url": "https://github.com/google/fonts/tree/main/ofl/notosansdevanagari",++ "files": {++ "100-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-normal.woff2",++ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",++ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",++ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",++ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",++ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2",++ "900-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-normal.woff2"++ },++ "sri": "sha384-6qvgeq5MT8DL150wuVD7QhgdnpuMOQcit0+3WA+BihqdLH9QNWAIJVwOkPLByDHK"++ },++ {++ "id": "noto-sans-jp",++ "family": "Noto Sans JP",++ "designer": "Google",++ "category": "sans-serif",++ "weights": [++ 100,++ 200,++ 300,++ 400,++ 500,++ 600,++ 700,++ 800,++ 900++ ],++ "styles": [++ "normal"++ ],++ "subsets": [++ "cyrillic",++ "japanese",++ "latin",++ "latin-ext",++ "vietnamese"++ ],++ "license": "OFL-1.1",++ "version": "v57",++ "source": "https://github.com/google/fonts",++ "license_url": "https://github.com/google/fonts/tree/main/ofl/notosansjp",++ "files": {++ "100-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-normal.woff2",++ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",++ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",++ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",++ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",++ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2",++ "900-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-normal.woff2"++ },++ "sri": "sha384-ZMGxbGF40IH+TlOdWEUxyz1qJOy2eWeNBhWXMgE3ytakMsKeDuj4RoAPPwuk8THu"++ },++ {++ "id": "noto-sans-kr",++ "family": "Noto Sans KR",++ "designer": "Google",++ "category": "sans-serif",++ "weights": [++ 100,++ 200,++ 300,++ 400,++ 500,++ 600,++ 700,++ 800,++ 900++ ],++ "styles": [++ "normal"++ ],++ "subsets": [++ "cyrillic",++ "korean",++ "latin",++ "latin-ext",++ "vietnamese"++ ],++ "license": "OFL-1.1",++ "version": "v40",++ "source": "https://github.com/google/fonts",++ "license_url": "https://github.com/google/fonts/tree/main/ofl/notosanskr",++ "files": {++ "100-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-normal.woff2",++ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",++ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",++ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",++ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",++ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2",++ "900-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-normal.woff2"++ },++ "sri": "sha384-MQrIcZ3XdaBQuSgSMpZ5h4v6YE0xA6sy9r3IKqiQsb3b6tD6npc3FosmBEm/DHbo"++ },++ {++ "id": "noto-serif",++ "family": "Noto Serif",++ "designer": "Google",++ "category": "serif",++ "weights": [++ 100,++ 200,++ 300,++ 400,++ 500,++ 600,++ 700,++ 800,++ 900++ ],++ "styles": [++ "italic",++ "normal"++ ],++ "subsets": [++ "cyrillic",++ "cyrillic-ext",++ "greek",++ "greek-ext",++ "latin",++ "latin-ext",++ "math",++ "vietnamese"++ ],++ "license": "OFL-1.1",++ "version": "v33",++ "source": "https://github.com/google/fonts",++ "license_url": "https://github.com/google/fonts/tree/main/ofl/notoserif",++ "files": {++ "100-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-italic.woff2",++ "100-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-normal.woff2",++ "200-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-italic.woff2",++ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",++ "300-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-italic.woff2",++ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",++ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",++ "500-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-italic.woff2",++ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",++ "600-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-italic.woff2",++ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",++ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",++ "800-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-italic.woff2",++ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2",++ "900-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-italic.woff2",++ "900-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-normal.woff2"++ },++ "sri": "sha384-mErvIjhu9VEDQVGNFT+/Ax8p/AQ8jA03O3yWHCu9Gbkds3UzFuAaOPJgshiPzsOZ"++ },++ {++ "id": "nunito",++ "family": "Nunito",++ "designer": "Vernon Adams, Cyreal, Jacques Le Bailly",++ "category": "sans-serif",++ "weights": [++ 200,++ 300,++ 400,++ 500,++ 600,++ 700,++ 800,++ 900++ ],++ "styles": [++ "italic",++ "normal"++ ],++ "subsets": [++ "cyrillic",++ "cyrillic-ext",++ "latin",++ "latin-ext",++ "vietnamese"++ ],++ "license": "OFL-1.1",++ "version": "v32",++ "source": "https://github.com/google/fonts",++ "license_url": "https://github.com/google/fonts/tree/main/ofl/nunito",++ "files": {++ "200-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-italic.woff2",++ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",++ "300-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-italic.woff2",++ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",++ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",++ "500-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-italic.woff2",++ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",++ "600-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-italic.woff2",++ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",++ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",++ "800-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-italic.woff2",++ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2",++ "900-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-italic.woff2",++ "900-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-normal.woff2"++ },++ "sri": "sha384-/Q+nUswFTZOi6aXONHIluy9SKfcuGoXESMYZkW//IgytrlO29H5JEwZ7KyeFzHJf"++ },++ {++ "id": "open-sans",++ "family": "Open Sans",++ "designer": "Steve Matteson",++ "category": "sans-serif",++ "weights": [++ 300,++ 400,++ 500,++ 600,++ 700,++ 800++ ],++ "styles": [++ "italic",++ "normal"++ ],++ "subsets": [++ "cyrillic",++ "cyrillic-ext",++ "greek",++ "greek-ext",++ "hebrew",++ "latin",++ "latin-ext",++ "math",++ "symbols",++ "vietnamese"++ ],++ "license": "OFL-1.1",++ "version": "v44",++ "source": "https://github.com/google/fonts",++ "license_url": "https://github.com/google/fonts/tree/main/ofl/opensans",++ "files": {++ "300-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-italic.woff2",++ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",++ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",++ "500-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-italic.woff2",++ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",++ "600-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-italic.woff2",++ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",++ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",++ "800-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-italic.woff2",++ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2"++ },++ "sri": "sha384-y5c6efoQ3OOtWXIDzjW2R3FJVtHOFICtnJqM5+8NHiPlG95BTMDNxiS862cIaIyx"++ },++ {++ "id": "oswald",++ "family": "Oswald",++ "designer": "Vernon Adams, Kalapi Gajjar, Cyreal",++ "category": "sans-serif",++ "weights": [++ 200,++ 300,++ 400,++ 500,++ 600,++ 700++ ],++ "styles": [++ "normal"++ ],++ "subsets": [++ "cyrillic",++ "cyrillic-ext",++ "latin",++ "latin-ext",++ "vietnamese"++ ],++ "license": "OFL-1.1",++ "version": "v57",++ "source": "https://github.com/google/fonts",++ "license_url": "https://github.com/google/fonts/tree/main/ofl/oswald",++ "files": {++ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",++ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",++ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",++ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2"++ },++ "sri": "sha384-pAZFASbk49K48F9Ak4QWKRMPCS+PUVOTyZ8lbOmavPcxXfSm2MXJz7RYx/sj79m4"++ },++ {++ "id": "outfit",++ "family": "Outfit",++ "designer": "Smartsheet Inc, Rodrigo Fuenzalida",++ "category": "sans-serif",++ "weights": [++ 100,++ 200,++ 300,++ 400,++ 500,++ 600,++ 700,++ 800,++ 900++ ],++ "styles": [++ "normal"++ ],++ "subsets": [++ "latin",++ "latin-ext"++ ],++ "license": "OFL-1.1",++ "version": "v15",++ "source": "https://github.com/google/fonts",++ "license_url": "https://github.com/google/fonts/tree/main/ofl/outfit",++ "files": {++ "100-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-normal.woff2",++ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",++ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",++ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",++ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",++ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2",++ "900-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-normal.woff2"++ },++ "sri": "sha384-LAIKRxjNJSUYh5JU9qx1to4Cg5fiTZmEHpe+giOnsw5m/9nofEsQw+b65hqvaQio"++ },++ {++ "id": "permanent-marker",++ "family": "Permanent Marker",++ "designer": "Font Diner",++ "category": "handwriting",++ "weights": [++ 400++ ],++ "styles": [++ "normal"++ ],++ "subsets": [++ "latin"++ ],++ "license": "Apache-2.0",++ "version": "v16",++ "source": "https://github.com/google/fonts",++ "license_url": "https://github.com/google/fonts/tree/main/apache/permanentmarker",++ "files": {++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2"++ },++ "sri": "sha384-vT5xTr25YOIF7ASHObdDkQnLzyunaZ1r9FRHtYCen88HCgzujW8tWPo6rzU2vt77"++ },++ {++ "id": "playfair-display",++ "family": "Playfair Display",++ "designer": "Claus Eggers Sørensen",++ "category": "serif",++ "weights": [++ 400,++ 500,++ 600,++ 700,++ 800,++ 900++ ],++ "styles": [++ "italic",++ "normal"++ ],++ "subsets": [++ "cyrillic",++ "latin",++ "latin-ext",++ "vietnamese"++ ],++ "license": "OFL-1.1",++ "version": "v40",++ "source": "https://github.com/google/fonts",++ "license_url": "https://github.com/google/fonts/tree/main/ofl/playfairdisplay",++ "files": {++ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",++ "500-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-italic.woff2",++ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",++ "600-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-italic.woff2",++ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",++ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",++ "800-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-italic.woff2",++ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2",++ "900-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-italic.woff2",++ "900-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-normal.woff2"++ },++ "sri": "sha384-eIw2aTe54daHavGiBUeDMN9SiosN3q5uI4UASs2J59iv0Rje8ZTBs6dq/jw8WKEI"++ },++ {++ "id": "poppins",++ "family": "Poppins",++ "designer": "Indian Type Foundry, Jonny Pinhorn, Ninad Kale",++ "category": "sans-serif",++ "weights": [++ 100,++ 200,++ 300,++ 400,++ 500,++ 600,++ 700,++ 800,++ 900++ ],++ "styles": [++ "italic",++ "normal"++ ],++ "subsets": [++ "devanagari",++ "latin",++ "latin-ext"++ ],++ "license": "OFL-1.1",++ "version": "v24",++ "source": "https://github.com/google/fonts",++ "license_url": "https://github.com/google/fonts/tree/main/ofl/poppins",++ "files": {++ "100-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-italic.woff2",++ "100-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-normal.woff2",++ "200-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-italic.woff2",++ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",++ "300-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-italic.woff2",++ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",++ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",++ "500-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-italic.woff2",++ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",++ "600-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-italic.woff2",++ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",++ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",++ "800-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-italic.woff2",++ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2",++ "900-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-italic.woff2",++ "900-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-normal.woff2"++ },++ "sri": "sha384-W4IwjlabTeW68fblL1nwDvUYgUVRfBlSg7TwKfQGYAuYTkO+zEQFxqxcc6hCAyp4"++ },++ {++ "id": "raleway",++ "family": "Raleway",++ "designer": "Matt McInerney, Pablo Impallari, Rodrigo Fuenzalida",++ "category": "sans-serif",++ "weights": [++ 100,++ 200,++ 300,++ 400,++ 500,++ 600,++ 700,++ 800,++ 900++ ],++ "styles": [++ "italic",++ "normal"++ ],++ "subsets": [++ "cyrillic",++ "cyrillic-ext",++ "latin",++ "latin-ext",++ "vietnamese"++ ],++ "license": "OFL-1.1",++ "version": "v37",++ "source": "https://github.com/google/fonts",++ "license_url": "https://github.com/google/fonts/tree/main/ofl/raleway",++ "files": {++ "100-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-italic.woff2",++ "100-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-normal.woff2",++ "200-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-italic.woff2",++ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",++ "300-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-italic.woff2",++ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",++ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",++ "500-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-italic.woff2",++ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",++ "600-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-italic.woff2",++ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",++ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",++ "800-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-italic.woff2",++ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2",++ "900-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-italic.woff2",++ "900-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-normal.woff2"++ },++ "sri": "sha384-3TJ5i8gimHsS/euAR5EX6GeSQlDDV1iYtoHAzCtoza0M5y4Jy7FELGLUQiFFbgbd"++ },++ {++ "id": "roboto",++ "family": "Roboto",++ "designer": "Christian Robertson, ParaType, Font Bureau",++ "category": "sans-serif",++ "weights": [++ 100,++ 200,++ 300,++ 400,++ 500,++ 600,++ 700,++ 800,++ 900++ ],++ "styles": [++ "italic",++ "normal"++ ],++ "subsets": [++ "cyrillic",++ "cyrillic-ext",++ "greek",++ "greek-ext",++ "latin",++ "latin-ext",++ "math",++ "symbols",++ "vietnamese"++ ],++ "license": "OFL-1.1",++ "version": "v51",++ "source": "https://github.com/google/fonts",++ "license_url": "https://github.com/google/fonts/tree/main/ofl/roboto",++ "files": {++ "100-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-italic.woff2",++ "100-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-normal.woff2",++ "200-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-italic.woff2",++ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",++ "300-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-italic.woff2",++ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",++ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",++ "500-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-italic.woff2",++ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",++ "600-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-italic.woff2",++ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",++ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",++ "800-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-italic.woff2",++ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2",++ "900-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-italic.woff2",++ "900-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-normal.woff2"++ },++ "sri": "sha384-cL8xRHWIL6DOapsGZvH0PmgY8FHJfx1/oZFwZsJdZtGrbgWNFiUSs6P2RQldUJuZ"++ },++ {++ "id": "roboto-slab",++ "family": "Roboto Slab",++ "designer": "Christian Robertson",++ "category": "serif",++ "weights": [++ 100,++ 200,++ 300,++ 400,++ 500,++ 600,++ 700,++ 800,++ 900++ ],++ "styles": [++ "normal"++ ],++ "subsets": [++ "cyrillic",++ "cyrillic-ext",++ "greek",++ "greek-ext",++ "latin",++ "latin-ext",++ "vietnamese"++ ],++ "license": "Apache-2.0",++ "version": "v36",++ "source": "https://github.com/google/fonts",++ "license_url": "https://github.com/google/fonts/tree/main/apache/robotoslab",++ "files": {++ "100-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-normal.woff2",++ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",++ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",++ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",++ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",++ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2",++ "900-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-normal.woff2"++ },++ "sri": "sha384-8jbBGEUvybflDyOzrH9bExjVKMnrgI5Pno4lP5PVxiesK+IBAmy3bUnJqitpZlBu"++ },++ {++ "id": "rubik",++ "family": "Rubik",++ "designer": "Hubert and Fischer, Meir Sadan, Cyreal, Daniel Grumer, Omaima Dajani",++ "category": "sans-serif",++ "weights": [++ 300,++ 400,++ 500,++ 600,++ 700,++ 800,++ 900++ ],++ "styles": [++ "italic",++ "normal"++ ],++ "subsets": [++ "arabic",++ "cyrillic",++ "cyrillic-ext",++ "hebrew",++ "latin",++ "latin-ext"++ ],++ "license": "OFL-1.1",++ "version": "v31",++ "source": "https://github.com/google/fonts",++ "license_url": "https://github.com/google/fonts/tree/main/ofl/rubik",++ "files": {++ "300-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-italic.woff2",++ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",++ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",++ "500-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-italic.woff2",++ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",++ "600-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-italic.woff2",++ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",++ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",++ "800-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-italic.woff2",++ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2",++ "900-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-italic.woff2",++ "900-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-normal.woff2"++ },++ "sri": "sha384-h6cxownIAO3b8G3yOMfioFISos4smxsXsfM2SuVeBOt+EWzON5L8IrtaLvpmwcvk"++ },++ {++ "id": "source-sans-3",++ "family": "Source Sans 3",++ "designer": "Paul D. Hunt",++ "category": "sans-serif",++ "weights": [++ 200,++ 300,++ 400,++ 500,++ 600,++ 700,++ 800,++ 900++ ],++ "styles": [++ "italic",++ "normal"++ ],++ "subsets": [++ "cyrillic",++ "cyrillic-ext",++ "greek",++ "greek-ext",++ "latin",++ "latin-ext",++ "vietnamese"++ ],++ "license": "OFL-1.1",++ "version": "v20",++ "source": "https://github.com/google/fonts",++ "license_url": "https://github.com/google/fonts/tree/main/ofl/sourcesans3",++ "files": {++ "200-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-italic.woff2",++ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",++ "300-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-italic.woff2",++ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",++ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",++ "500-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-italic.woff2",++ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",++ "600-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-italic.woff2",++ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",++ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",++ "800-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-italic.woff2",++ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2",++ "900-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-italic.woff2",++ "900-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-normal.woff2"++ },++ "sri": "sha384-YbPa4/38CWQVCsfXlRcOJywm27n3RERUyzIuomA7HNoa7hcXwEy5uL9nHMyXj8Hb"++ },++ {++ "id": "source-serif-4",++ "family": "Source Serif 4",++ "designer": "Frank Grießhammer",++ "category": "serif",++ "weights": [++ 200,++ 300,++ 400,++ 500,++ 600,++ 700,++ 800,++ 900++ ],++ "styles": [++ "italic",++ "normal"++ ],++ "subsets": [++ "cyrillic",++ "cyrillic-ext",++ "greek",++ "latin",++ "latin-ext",++ "vietnamese"++ ],++ "license": "OFL-1.1",++ "version": "v15",++ "source": "https://github.com/google/fonts",++ "license_url": "https://github.com/google/fonts/tree/main/ofl/sourceserif4",++ "files": {++ "200-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-italic.woff2",++ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",++ "300-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-italic.woff2",++ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",++ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",++ "500-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-italic.woff2",++ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",++ "600-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-italic.woff2",++ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",++ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",++ "800-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-italic.woff2",++ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2",++ "900-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-italic.woff2",++ "900-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-normal.woff2"++ },++ "sri": "sha384-QH8kQ1dp35FXXdgaYtl6lRIMtjT62fMRls72L4GH5Apv6brOezk4QZWWj/Sam95h"++ },++ {++ "id": "space-grotesk",++ "family": "Space Grotesk",++ "designer": "Florian Karsten",++ "category": "sans-serif",++ "weights": [++ 300,++ 400,++ 500,++ 600,++ 700++ ],++ "styles": [++ "normal"++ ],++ "subsets": [++ "latin",++ "latin-ext",++ "vietnamese"++ ],++ "license": "OFL-1.1",++ "version": "v22",++ "source": "https://github.com/google/fonts",++ "license_url": "https://github.com/google/fonts/tree/main/ofl/spacegrotesk",++ "files": {++ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",++ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",++ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2"++ },++ "sri": "sha384-LN9HSpXtfJY+J/HQtn1ux4LTp3GzWE5bXPvrP3x2ydU9Fj4Ie3RJ8RUqZX8c834d"++ },++ {++ "id": "ubuntu",++ "family": "Ubuntu",++ "designer": "Dalton Maag",++ "category": "sans-serif",++ "weights": [++ 300,++ 400,++ 500,++ 700++ ],++ "styles": [++ "italic",++ "normal"++ ],++ "subsets": [++ "cyrillic",++ "cyrillic-ext",++ "greek",++ "greek-ext",++ "latin",++ "latin-ext"++ ],++ "license": "UFL-1.0",++ "version": "v21",++ "source": "https://github.com/google/fonts",++ "license_url": "https://github.com/google/fonts/tree/main/ufl/ubuntu",++ "files": {++ "300-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-italic.woff2",++ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",++ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",++ "500-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-italic.woff2",++ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",++ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2"++ },++ "sri": "sha384-jGsKWVoTkCuJGW0HOJgSCbQQtkTaHgnKbzKERY4HBWzy6WWCGvTYfvkugA578CAT"++ },++ {++ "id": "work-sans",++ "family": "Work Sans",++ "designer": "Wei Huang",++ "category": "sans-serif",++ "weights": [++ 100,++ 200,++ 300,++ 400,++ 500,++ 600,++ 700,++ 800,++ 900++ ],++ "styles": [++ "italic",++ "normal"++ ],++ "subsets": [++ "latin",++ "latin-ext",++ "vietnamese"++ ],++ "license": "OFL-1.1",++ "version": "v24",++ "source": "https://github.com/google/fonts",++ "license_url": "https://github.com/google/fonts/tree/main/ofl/worksans",++ "files": {++ "100-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-italic.woff2",++ "100-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-normal.woff2",++ "200-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-italic.woff2",++ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",++ "300-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-italic.woff2",++ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",++ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",++ "500-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-italic.woff2",++ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",++ "600-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-italic.woff2",++ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",++ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",++ "800-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-italic.woff2",++ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2",++ "900-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-italic.woff2",++ "900-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-normal.woff2"++ },++ "sri": "sha384-x6cgTi/NG/0nnQyCVkB8w/JUcS7wSz8cG5AEGjk2qktSQfVtBz259hNqnzRKhRnV"++ }++]@@@ -40,6 +40,7 @@ function publicPages(themes = []) {{ path: '/native', changefreq: 'monthly', priority: 0.6, title: 'Apps for Android and iOS', text: 'How an OpenVibe page becomes a real Android or iOS screen: one set of tokens, components, patterns and icons, three size classes, what each platform does its own way, and the road from the web to apps.' },{ path: '/motion', changefreq: 'monthly', priority: 0.6, title: 'Motion', text: 'How OpenVibe moves: the mark\'s motion and one shared kit of effects (glint, glow, lift, press, ripple, tilt, reveal, count, confetti), the timing every site shares, the rules and the code (openvibe-shared/ov-fx).' },{ path: '/icons', changefreq: 'weekly', priority: 0.7, title: 'The OpenVibe glyphs', text: 'Every OpenVibe icon in one hand, the mark\'s: search them, copy or download their SVG, use them in place of Font Awesome, and draw new ones with OpenVibe.AI.' },++ { path: '/fonts', changefreq: 'weekly', priority: 0.7, title: 'Open font library', text: 'Browse open licence font families by category, script and weight; preview every style and find its source and licence.' },{ path: '/icons/font-awesome-alternative', changefreq: 'monthly', priority: 0.6, title: 'A free Font Awesome alternative', text: 'Keep your Font Awesome markup and show the OpenVibe glyphs: one script, the same classes, animations and transforms, plus free web and desktop fonts.' },{ path: '/icons/migrate', changefreq: 'monthly', priority: 0.5, title: 'Switch from Font Awesome, Lucide or Material', text: 'Paste your HTML or code and see every Font Awesome, Lucide, Material or MUI icon it uses with its free OpenVibe twin.' },{ path: '/arena', changefreq: 'daily', priority: 0.9, title: 'The Design Arena', text: 'One prompt, three AI designers, blind votes and an Elo leaderboard per category, with cost and time per generation.' },@@@ -90,6 +91,7 @@ function createDiscoveryRoutes(ctx) {{ title: 'The studio', url: abs('/studio'), note: 'try, edit, use, submit and roll back a theme' },{ title: 'Tokens', url: abs('/tokens'), note: 'any theme as CSS, Tailwind, Compose (Android), SwiftUI (iOS), Flutter, Figma or DTCG JSON; the API is GET /api/v1/tokens/:theme?format=' },{ title: 'The OpenVibe glyphs', url: abs('/icons'), note: 'every icon in one hand, each with its own page (/icons/<name>): SVG, PNG at any size and colour (/icons/<name>.png?size=&color=&stroke=&pad=&ground=&bg=), the WOFF2/TTF/OTF fonts and the sprite (/icons/pack/<file>); style=auto|line|solid (auto: the solid drawing at 18 px and under); /icons/<name>.mjs (ES module), .xml (Android VectorDrawable), /icons/<name>/favicon.zip; the <ov-icon name=\"…\"> web component at /ov-icon.js; the registry as data at GET /api/v1/glyphs (?names=a,fa-b,lucide:c,material:d for just those); POST /api/v1/glyphs/migrate {text} lists every Font Awesome, Lucide, Material or MUI icon in a page or code with its OpenVibe twin (/icons/migrate is the page). All open, no sign-in, CORS *', html: true },++ { title: 'Open font library', url: abs('/fonts'), note: 'Open licence typefaces with live previews, every style, licence, source, CSS and download. Search and filters at /fonts; JSON at GET /api/v1/fonts and /api/v1/fonts/<family>; MCP tools list and get fonts.' },{ title: 'A free Font Awesome alternative', url: abs('/icons/font-awesome-alternative'), note: 'one script keeps Font Awesome markup, classes, animations and data-fa-transform, and shows the OpenVibe glyphs', html: true },{ title: 'Loading', url: abs('/loading'), note: 'how OpenVibe waits: placeholders shaped like the content (kit.skeleton: card, row, tile, chip, line, chat, stat), the mark at work instead of a spinner (OpenVibeLoading), the navbar while a page moves (OpenVibeNavbar.busy), and the rules' },{ title: 'Forms', url: abs('/forms'), note: 'OpenVibe’s own select (a menu on a computer, a sheet on a phone; options with state, badge and a setup link), switches, fields and choice cards: ov-forms.js and ov-forms.css' },@@@ -101,6 +103,7 @@ function createDiscoveryRoutes(ctx) {// The data an agent came for is a main section, not something to skip.{ title: 'Machine-readable', links: [{ title: 'The theme catalogue (JSON)', url: abs('/api/themes'), note: 'public, cached for 5 minutes' },++ { title: 'Font catalogue (JSON)', url: abs('/api/v1/fonts'), note: 'open licence families, cached for 5 minutes' },{ title: 'The Arena dataset (JSONL)', url: abs('/api/v1/arena/dataset.jsonl'), note: 'prompts, outputs and votes, no voters' },{ title: 'Agent manifest', url: abs('/.well-known/agent.json') },] },nnew file mode 100644@@@ -0,0 +1,29 @@++'use strict';++++// GET /api/v1/fonts?q=&category=&script=&weight= — open licence font families.++// GET /api/v1/fonts/:family — metadata, CDN file URLs and three rule based pairings.++const { asyncRouter } = require('./router');++const views = require('../views/fonts');++++function createFontRoutes({ fonts }) {++ const r = asyncRouter();++ const filters = req => ({ q: String(req.query.q || '').slice(0, 100), category: String(req.query.category || '').slice(0, 40),++ script: String(req.query.script || '').slice(0, 40), weight: String(req.query.weight || '').slice(0, 4) });++ r.get('/api/v1/fonts', async (req, res) => {++ const list = await fonts.list(filters(req));++ res.set('Access-Control-Allow-Origin', '*').set('Cache-Control', 'public, max-age=300').json({ fonts: list, count: list.length });++ });++ r.get('/api/v1/fonts/:family', async (req, res) => {++ const font = await fonts.get(req.params.family);++ res.set('Access-Control-Allow-Origin', '*').set('Cache-Control', 'public, max-age=300');++ return font ? res.json(font) : res.status(404).json({ error: 'Font family not found' });++ });++ r.get('/fonts', async (req, res) => res.set('Cache-Control', 'public, max-age=120').send(views.index(await fonts.list(), filters(req))));++ r.get('/fonts/:family', async (req, res) => {++ const font = await fonts.get(req.params.family);++ return font ? res.set('Cache-Control', 'public, max-age=120').send(views.detail(font)) : res.status(404).send('Font family not found');++ });++ return r;++}++++module.exports = { createFontRoutes };@@@ -53,6 +53,13 @@ const TOOLS = [{ name: 'openvibe_design_icon_migrate', description: 'Read HTML or code and list every Font Awesome, Lucide, Material or MUI icon it uses, with its OpenVibe twin and how often it appears. Read only.', inputSchema: schema(['text'], {text: { type: 'string', minLength: 1, maxLength: 400000 },}) },++ { name: 'openvibe_design_list_fonts', description: 'List open licence font families with category, scripts, weights, licence and source.', inputSchema: schema([], {++ query: { type: 'string', maxLength: 100 }, category: { type: 'string', maxLength: 40 },++ script: { type: 'string', maxLength: 40 }, weight: { type: 'integer', minimum: 100, maximum: 900 },++ }) },++ { name: 'openvibe_design_get_font', description: 'Get one font family with every style, CSS files, licence and three suggested pairings.', inputSchema: schema(['family'], {++ family: { type: 'string', minLength: 1, maxLength: 100 },++ }) },].map(tool => ({ ...tool, annotations: { readOnlyHint: true, openWorldHint: false } }));const rpc = (id, result) => ({ jsonrpc: '2.0', id, result });@@@ -66,7 +73,7 @@ function toolResult(value) {};}--async function callTool(name, args, { catalog }) {++async function callTool(name, args, { catalog, fonts }) {if (!object(args)) return toolError('Arguments must be an object.');switch (name) {case 'openvibe_design_list_themes': {@@@ -130,6 +137,17 @@ async function callTool(name, args, { catalog }) {case 'openvibe_design_icon_migrate':if (typeof args.text !== 'string' || !args.text || args.text.length > 400000) return toolError('text must be a nonempty string of at most 400,000 characters.');return toolResult({ icons: aliases.scan(args.text), page: `${BASE}/icons/migrate` });++ case 'openvibe_design_list_fonts': {++ if (typeof args.query !== 'undefined' && (typeof args.query !== 'string' || args.query.length > 100)) return toolError('query must be at most 100 characters.');++ if (args.weight !== undefined && (!Number.isInteger(args.weight) || args.weight < 100 || args.weight > 900)) return toolError('weight must be 100 to 900.');++ const list = await fonts.list({ q: args.query, category: args.category, script: args.script, weight: args.weight });++ return toolResult({ fonts: list, count: list.length });++ }++ case 'openvibe_design_get_font': {++ if (typeof args.family !== 'string' || !/^[a-z0-9-]{1,100}$/.test(args.family)) return toolError('family must be a font slug.');++ const font = await fonts.get(args.family);++ return font ? toolResult(font) : toolError('No font family by that slug.');++ }default:return null;}@@@ -22,6 +22,7 @@ function createLifecycle({ server, ctx, exit, signals } = {}) {deadlineMs: 10_000,close: [() => ctx.jobs.stop(),++ () => ctx.fonts.stop(),() => ctx.searchIndex.stop(),() => ctx.keys.client.stop(),() => ctx.s.close(),@@@ -41,6 +42,7 @@ async function start() {ctx.keys.ensure().catch(() => {});// Generate queued Arena rounds. DESIGN_DRILL=1 keeps the loop off (a test or a drill drives tick()).ctx.jobs.start();++ if (!config.drill) ctx.fonts.start();// The icon and theme pages in OpenVibe.Search: a sweep a minute after boot and every six hours.if (!config.drill) ctx.searchIndex.start();ctx.designs.resume().catch((err) => console.warn('[OpenVibe.Design] resume designs:', err.message));@@@ -33,6 +33,7 @@ const NAV = [{ label: 'Loading', href: '/loading', icon: 'fa-hourglass-half', more: true },{ label: 'Forms', href: '/forms', icon: 'fa-list-check', more: true },{ label: 'Icons', href: '/icons', icon: 'fa-shapes', more: true },++ { label: 'Fonts', href: '/fonts', icon: 'fa-font', more: true },{ label: 'Patterns', href: '/patterns', icon: 'fa-shapes', more: true },{ label: 'Kit', href: '/kit', icon: 'fa-cubes', more: true },{ label: 'Components', href: '/components', icon: 'fa-puzzle-piece', more: true },@@@ -49,7 +50,7 @@ const baseUrl = () => BASE;const canonical = (p) => `${BASE}${p || '/'}`;/** The page around a body: the shared shell with the room's stylesheets and behaviour. */--function layout({ title, description, canonical, path, robots = 'index, follow', body, active = '/', scripts = [], styles = [], jsonLd = [], inlineStyles = [], image, imageAlt, shared = [], sharedStyles = [] } = {}) {++function layout({ title, description, canonical, path, robots = 'index, follow', body, active = '/', scripts = [], styles = [], jsonLd = [], inlineStyles = [], preloads = [], image, imageAlt, shared = [], sharedStyles = [] } = {}) {const pathNow = path || active || '/';canonical = canonical || (path ? `${BASE}${path}` : undefined);const navbar = {@@@ -87,6 +88,7 @@ function layout({ title, description, canonical, path, robots = 'index, follow',${(sharedStyles || []).map((file) => `<link rel="stylesheet" href="${esc(ovServe.url(file))}">`).join('\n')}${(styles || []).map((rel) => `<link rel="stylesheet" href="${esc(asset(rel))}">`).join('\n')}${(inlineStyles || []).map((css) => `<style>${css}</style>`).join('\n')}++${preloads.map(p => `<link rel="preload" as="font" type="font/woff2" href="${esc(p.url)}" crossorigin integrity="${esc(p.sri)}">`).join('\n')}<script src="${esc(ovServe.url('kit-web.js'))}" defer></script><script src="${esc(ovServe.url('motion.js'))}" defer></script>${(shared || []).map((file) => `<script src="${esc(ovServe.url(file))}" defer></script>`).join('\n')}nnew file mode 100644@@@ -0,0 +1,60 @@++'use strict';++++const { layout, esc } = require('../render/layout');++++const previewFile = f => f.files['400-normal'] || Object.values(f.files)[0];++const face = (f, weight = 400, style = 'normal') => {++ const url = f.files[`${weight}-${style}`];++ return url ? `@font-face{font-family:${JSON.stringify(f.family)};src:url(${JSON.stringify(url)}) format("woff2");font-weight:${weight};font-style:${style};font-display:swap}` : '';++};++++function index(fonts, filters) {++ const categories = ['sans-serif', 'serif', 'display', 'handwriting', 'monospace'];++ const scripts = [...new Set(fonts.flatMap(f => f.subsets))].sort();++ const shown = fonts.filter(f => (!filters.q || `${f.family} ${f.designer}`.toLowerCase().includes(filters.q.toLowerCase()))++ && (!filters.category || f.category === filters.category)++ && (!filters.script || f.subsets.includes(filters.script))++ && (!filters.weight || f.weights.includes(Number(filters.weight))));++ const option = (values, selected, label) => `<label>${label}<select name="${label.toLowerCase()}"><option value="">All ${label.toLowerCase()}s</option>${values.map(v => `<option value="${esc(v)}"${v === selected ? ' selected' : ''}>${esc(String(v))}</option>`).join('')}</select></label>`;++ const cards = shown.map(f => `<article class="fo-card"><a class="fo-card-link" href="/fonts/${encodeURIComponent(f.id)}">++<h2 style="font-family:${esc(JSON.stringify(f.family))}">${esc(f.family)}</h2>++<p class="fo-sample" style="font-family:${esc(JSON.stringify(f.family))}">${esc('The quick brown fox jumps over the lazy dog.')}</p>++<p class="fo-meta"><span>${esc(f.category)}</span> · ${f.weights.length} ${f.weights.length === 1 ? 'weight' : 'weights'} · ${esc(f.license)}</p></a></article>`).join('');++ return layout({ title: 'Open font library', description: 'Browse open licence typefaces by category, script and weight.',++ path: '/fonts', active: '/fonts', styles: ['css/fonts.css'], scripts: ['js/fonts.js'],++ inlineStyles: [shown.map(f => face(f, f.files['400-normal'] ? 400 : f.weights[0], 'normal')).join('\n')],++ body: `<div class="fo"><header class="fo-hero"><p class="fo-eyebrow">The font library</p><h1>Find a face for your idea.</h1><p>Open licence typefaces with their source and licence beside them.</p></header>++<nav class="fo-chips" aria-label="Category"><a href="/fonts${filters.q ? `?q=${encodeURIComponent(filters.q)}` : ''}"${!filters.category ? ' aria-current="page"' : ''}>All <span>${fonts.length}</span></a>${categories.filter(c => fonts.some(f => f.category === c)).map(c => `<a href="/fonts?category=${encodeURIComponent(c)}${filters.q ? `&q=${encodeURIComponent(filters.q)}` : ''}"${filters.category === c ? ' aria-current="page"' : ''}>${esc(c)} <span>${fonts.filter(f => f.category === c).length}</span></a>`).join('')}</nav>++<form class="fo-filters" method="get" action="/fonts">${filters.category ? `<input type="hidden" name="category" value="${esc(filters.category)}">` : ''}<label class="fo-q">Search<input type="search" name="q" value="${esc(filters.q || '')}" placeholder="Family or designer"></label>++${option(scripts, filters.script, 'Script')}${option([100,200,300,400,500,600,700,800,900], Number(filters.weight) || '', 'Weight')}++<button type="submit">Filter</button></form>++<label class="fo-try">Try your own words<input type="text" data-font-sample maxlength="140" placeholder="The quick brown fox jumps over the lazy dog."></label>++<p class="fo-count">${shown.length} of ${fonts.length} families</p><div class="fo-grid">${cards || '<p>No families match these filters.</p>'}</div></div>` });++}++++function detail(f) {++ const style = ([key, url]) => {++ const [weight, kind] = key.split('-');++ return `<div class="fo-style"><div><b>${esc(weight)}${kind === 'italic' ? ' italic' : ''}</b><a href="${esc(url)}" download aria-label="Download ${esc(f.family)} ${esc(weight)} ${esc(kind)} (WOFF2)">WOFF2</a></div><p style="font-family:${esc(JSON.stringify(f.family))};font-weight:${weight};font-style:${kind}">The quick brown fox jumps over the lazy dog.</p></div>`;++ };++ // Upright first, lightest to heaviest, then the italics: the order a person reads a family in.++ const entries = Object.entries(f.files).sort(([a], [b]) => { const [wa, sa] = a.split('-'), [wb, sb] = b.split('-'); return (sa === sb ? 0 : sa === 'normal' ? -1 : 1) || Number(wa) - Number(wb); });++ const firstStyles = entries.slice(0, 6), moreStyles = entries.slice(6);++ const styles = `${firstStyles.map(style).join('')}`;++ const stylesMore = moreStyles.length ? `<details class="fo-more"><summary>More styles <span>(${moreStyles.length})</span></summary><div class="fo-styles">${moreStyles.map(style).join('')}</div></details>` : '';++ const url = previewFile(f);++ const css = `@font-face {\n font-family: ${JSON.stringify(f.family)};\n src: url("${url}") format("woff2");\n font-weight: 400;\n font-style: normal;\n font-display: swap;\n}`;++ return layout({ title: f.family, description: `${f.family} by ${f.designer}: ${f.license} font, styles, CSS and download.`,++ path: `/fonts/${f.id}`, active: '/fonts', styles: ['css/fonts.css'], scripts: ['js/fonts.js'],++ preloads: f.sri ? [{ url, sri: f.sri }] : [],++ inlineStyles: [Object.keys(f.files).map(key => { const [w, s] = key.split('-'); return face(f, Number(w), s); }).join('\n')++ + f.pairings.filter(p => p.file).map(p => `@font-face{font-family:${JSON.stringify(p.family)};src:url(${JSON.stringify(p.file)}) format("woff2");font-weight:${Number(p.weight) || 700};font-style:normal;font-display:swap}`).join('\n')],++ body: `<div class="fo fo-detail"><a class="fo-back" href="/fonts">← All fonts</a><header class="fo-hero"><p class="fo-eyebrow">${esc(f.category)} · ${esc(f.version || '')}</p><h1 style="font-family:${esc(JSON.stringify(f.family))}">${esc(f.family)}</h1><p>Designed by ${esc(f.designer)}</p></header>++<label class="fo-try">Try your own words<input type="text" data-font-sample maxlength="140" placeholder="The quick brown fox jumps over the lazy dog."></label>++<section><h2>Every style <span class="fo-n">${entries.length}</span></h2><div class="fo-styles">${styles}</div>${stylesMore}</section>++<section class="fo-details"><div><h2>Use this font</h2><pre><code>${esc(css)}</code></pre><p><a href="${esc(url)}" download>Download the regular WOFF2</a>${f.sri ? `<span class="fo-integrity">SHA-384 integrity: <code>${esc(f.sri)}</code></span>` : ''}</p></div>++<div><h2>Licence and source</h2><p>${esc(f.license)} · <a href="${esc(f.license_url)}" rel="noopener">Licence and source files</a></p><p><a href="${esc(f.source)}" rel="noopener">${esc(f.source)}</a></p><p>Scripts: ${f.subsets.map(esc).join(', ')}</p><p>Weights: ${f.weights.join(', ')} · Styles: ${f.styles.map(esc).join(', ')}</p></div></section>++<section><h2>Suggested pairings</h2><p class="fo-note">A heading in the other face over ${esc(f.family)} for the text, chosen by contrast of form, x-height and era. Suggestions, not rules.</p><div class="fo-pairings">${f.pairings.map(p => `<article><p class="fo-pair-h" style="font-family:${esc(JSON.stringify(p.family))};font-weight:${Number(p.weight) || 700}">A heading in ${esc(p.family)}</p><p class="fo-pair-b" style="font-family:${esc(JSON.stringify(f.family))}">And the text under it in ${esc(f.family)}, the way a reader meets them together on a page.</p><p class="fo-pair-why"><a href="/fonts/${encodeURIComponent(p.id)}">${esc(p.family)}</a> · ${esc(p.reason)}</p></article>`).join('')}</div></section></div>` });++}++++module.exports = { index, detail };nnew file mode 100644@@@ -0,0 +1,87 @@++'use strict';++++const assert = require('node:assert/strict');++const { boot, check, done } = require('./helpers/boot');++const { testDb } = require('./helpers/db');++const { createFontCatalog, validFont, due, WEEK } = require('../server/fonts/catalog');++const seed = require('../server/fonts/seed.json');++++(async () => {++ const t = await boot();++ try {++ await check('the seed contains only licensed families with source and downloadable files', () => {++ assert.equal(seed.length, 38);++ assert.ok(seed.every(validFont));++ assert.ok(seed.every(f => f.designer && f.version && f.source && f.sri));++ assert.deepEqual(new Set(seed.map(f => f.license)), new Set(['OFL-1.1', 'Apache-2.0', 'UFL-1.0']));++ });++ await check('font pages show live samples, every style, CSS, licence, download and pairings', async () => {++ const index = await t.get('/fonts');++ assert.equal(index.status, 200);++ assert.match(index.text, /Find a face for your idea/);++ assert.match(index.text, /data-font-sample/);++ assert.match(index.text, /fonts\/inter/);++ const detail = await t.get('/fonts/inter');++ assert.equal(detail.status, 200);++ for (const text of ['Every style', 'OFL-1.1', 'Download the regular WOFF2', 'aria-label="Download Inter 400 normal (WOFF2)"', 'More styles <span>(', 'A heading in ', '@font-face', 'Suggested pairings', 'SHA-384 integrity', 'integrity="sha384-']) assert.ok(detail.text.includes(text), text);++ assert.equal((await t.get('/fonts/no-such-family')).status, 404);++ });++ await check('the API filters by category, script and weight and rejects missing families', async () => {++ const all = await t.get('/api/v1/fonts');++ assert.equal(all.status, 200);++ assert.equal(all.json().count, 38);++ assert.ok(all.json().fonts.every(f => ['OFL-1.1', 'Apache-2.0', 'UFL-1.0'].includes(f.license)));++ const arabic = (await t.get('/api/v1/fonts?script=arabic&weight=400')).json().fonts;++ assert.ok(arabic.length && arabic.every(f => f.subsets.includes('arabic') && f.weights.includes(400)));++ const serif = (await t.get('/api/v1/fonts?category=serif')).json().fonts;++ assert.ok(serif.length && serif.every(f => f.category === 'serif'));++ const inter = (await t.get('/api/v1/fonts/inter')).json();++ assert.equal(inter.pairings.length, 3);++ assert.ok(inter.pairings.every(p => p.reason && p.id !== 'inter'));++ assert.equal((await t.get('/api/v1/fonts/no-such-family')).status, 404);++ });++ await check('MCP and llms.txt expose the catalogue', async () => {++ const mcp = await t.get('/mcp', { json: { jsonrpc: '2.0', id: 1, method: 'tools/list' } });++ assert.ok(mcp.json().result.tools.some(x => x.name === 'openvibe_design_get_font'));++ assert.ok((await t.get('/llms.txt')).text.includes('/api/v1/fonts'));++ });++ } finally { await t.close(); }++++ await check('weekly refresh is database claimed and filters revoked licences', async () => {++ const test = await testDb();++ try {++ let time = Date.UTC(2026, 10, 8);++ let blocked = 'inter';++ const fakeFetch = async url => {++ if (url.startsWith('https://api.fontsource.org/')) {++ const f = seed.find(x => url.endsWith(`/${x.id}`));++ const variants = {};++ for (const [key, link] of Object.entries(f.files)) {++ const [weight, style] = key.split('-');++ variants[weight] ||= {};++ variants[weight][style] = { latin: { url: { woff2: link } } };++ }++ return { ok: true, json: async () => ({ ...f, license: f.id === blocked ? 'CC0-1.0' : f.license,++ defSubset: 'latin', variants }) };++ }++ if (url.endsWith('/METADATA.pb')) return { ok: true, text: async () => 'designer: "Test Designer"\n' };++ return { ok: true, arrayBuffer: async () => Buffer.from('font bytes') };++ };++ const options = { db: test.db, fetchImpl: fakeFetch, now: () => time };++ const a = createFontCatalog(options), b = createFontCatalog(options);++ await a.seedMissing();++ assert.equal(due({ last_success_at: null, claimed_until: null }, time), true);++ assert.equal((await Promise.all([a.tick(), b.tick()])).filter(Boolean).length, 1);++ assert.equal((await a.list()).length, 37);++ assert.equal(await a.get(blocked), null);++ assert.equal(await a.tick(), false);++ await a.seedMissing();++ assert.equal(await a.get(blocked), null, 'restart seeding cannot restore a revoked licence');++ time += WEEK + 1;++ blocked = null;++ assert.equal(await b.tick(), true);++ assert.equal((await b.list()).length, 38);++ } finally { await test.close(); }++ });++ done();++})().catch(e => { console.error(e); process.exit(1); });@@@ -23,6 +23,7 @@ const components = require('openvibe-shared/components');'openvibe_design_list_components', 'openvibe_design_get_component','openvibe_design_validate_kit_document','openvibe_design_icon_search', 'openvibe_design_icon_svg', 'openvibe_design_icon_code', 'openvibe_design_icon_migrate',++ 'openvibe_design_list_fonts', 'openvibe_design_get_font',]);assert.ok(listed.every(x => x.inputSchema && x.annotations.readOnlyHint));assert.strictEqual((await t.get('/mcp', { method: 'OPTIONS' })).status, 204);
new file mode 100644
new file mode 100644
@@ -0,0 +1,14 @@
@@ -0,0 +1,14 @@
+-- phase: expand
+-- Public font catalogue and a durable weekly refresh claim. Older releases ignore these tables.
+CREATE TABLE IF NOT EXISTS font_families (
+ id text COLLATE "C" PRIMARY KEY,
+ metadata jsonb NOT NULL,
+ updated_at timestamptz NOT NULL DEFAULT now()
+);
+CREATE TABLE IF NOT EXISTS font_catalog_state (
+ id integer PRIMARY KEY CHECK (id = 1),
+ last_success_at timestamptz,
+ claimed_until timestamptz,
+ claim_id text
+);
+INSERT INTO font_catalog_state (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
new file mode 100644
new file mode 100644
@@ -0,0 +1,61 @@
@@ -0,0 +1,61 @@
+/* The font library: plain catalogue pages, ready for the final Design art direction. */
+.fo{max-width:1180px;margin:auto;padding:clamp(22px,4vw,48px) clamp(16px,3vw,32px) 96px;color:var(--text-primary)}
+.fo-hero{max-width:820px;margin:0 0 32px}.fo-eyebrow{margin:0 0 12px;color:var(--accent);font-size:12px;font-weight:800;letter-spacing:.12em;text-transform:uppercase}
+.fo-hero h1{margin:0;font-size:clamp(36px,6vw,72px);line-height:1.08;letter-spacing:-.035em;overflow-wrap:anywhere}.fo-hero p:last-child{color:var(--text-secondary);font-size:clamp(16px,2vw,20px);line-height:1.5}
+.fo-filters{display:grid;grid-template-columns:minmax(180px,2fr) repeat(3,minmax(120px,1fr)) auto;align-items:end;gap:12px;padding:18px;border:1px solid var(--border);border-radius:18px;background:var(--bg-secondary)}
+.fo-filters label,.fo-try{display:grid;gap:7px;color:var(--text-secondary);font-size:13px;font-weight:700}.fo-filters input,.fo-filters select,.fo-try input{width:100%;min-width:0;min-height:44px;padding:9px 12px;border:1px solid var(--border);border-radius:10px;background:var(--bg-primary);color:var(--text-primary);font:inherit;font-size:15px}
+.fo-filters button{min-height:44px;padding:9px 18px;border:0;border-radius:10px;background:var(--accent);color:var(--bg-primary);font-weight:800;cursor:pointer}.fo-try{margin:22px 0}.fo-try input{font-weight:400}.fo-count{color:var(--text-secondary);font-size:14px}
+.fo-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(280px,100%),1fr));gap:16px}.fo-card{min-width:0;padding:22px;border:1px solid var(--border);border-radius:18px;background:var(--bg-secondary)}
+.fo-card-top{display:flex;flex-wrap:wrap;justify-content:space-between;gap:8px;color:var(--text-secondary);font-size:12px;text-transform:capitalize}.fo-card h2{margin:22px 0 12px;font-size:22px}.fo-card h2 a,.fo-pairings a{color:inherit;text-decoration:none}.fo-card h2 a:hover,.fo-pairings a:hover{text-decoration:underline}.fo-sample{min-height:95px;margin:0;font-size:clamp(22px,2.4vw,28px);line-height:1.25;overflow-wrap:anywhere}.fo-meta{margin:18px 0 0;color:var(--text-secondary);font-size:13px}
+.fo-back{display:inline-block;margin-bottom:26px;color:var(--accent)}.fo-detail section{margin-top:44px}.fo-detail h2{font-size:clamp(22px,3vw,30px)}.fo-styles{display:grid;gap:12px}.fo-style{min-width:0;padding:16px 20px;border:1px solid var(--border);border-radius:14px;background:var(--bg-secondary)}.fo-style>div{display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px;font-size:14px}.fo-style p{margin:15px 0 2px;font-size:clamp(22px,4vw,42px);line-height:1.3;overflow-wrap:anywhere}
+.fo-details{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:24px}.fo-details>div{min-width:0}.fo-details pre{max-width:100%;overflow-x:auto;padding:18px;border:1px solid var(--border);border-radius:12px;background:var(--bg-secondary);font-size:13px}.fo-details p{overflow-wrap:anywhere}.fo-integrity{display:block;margin-top:12px;color:var(--text-secondary);font-size:12px;overflow-wrap:anywhere}.fo-pairings{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px}.fo-pairings article{min-width:0;padding:18px;border:1px solid var(--border);border-radius:14px;background:var(--bg-secondary)}.fo-pairings h3{margin:0 0 10px}.fo-pairings p{margin:0;color:var(--text-secondary);line-height:1.5}
+@media(max-width:850px){.fo-filters{grid-template-columns:repeat(2,minmax(0,1fr))}.fo-filters button{grid-column:1/-1}.fo-details{grid-template-columns:1fr}}@media(max-width:550px){.fo-filters{grid-template-columns:1fr}.fo-pairings{grid-template-columns:1fr}}
+
+/* Design pass (Opus, 11-08): the catalogue as a specimen sheet. Each family's name is drawn in itself, two to a row on
+ a phone; categories are chips; a family's styles go two to a row with a counted fold; a pairing is drawn as a pair. */
+.fo-chips{display:flex;gap:6px;margin:0 0 12px;overflow-x:auto;scrollbar-width:none;padding:2px 0 4px}
+.fo-chips::-webkit-scrollbar{display:none}
+.fo-chips a{flex:none;display:inline-flex;align-items:center;gap:6px;min-height:36px;padding:0 14px;border:1px solid var(--border);border-radius:999px;color:var(--text-secondary);text-decoration:none;font-size:14px;font-weight:650;text-transform:capitalize}
+.fo-chips a span{color:var(--text-muted,var(--text-secondary));font-weight:500;font-size:12px}
+.fo-chips a[aria-current]{border-color:var(--accent);color:var(--text-primary);background:color-mix(in srgb,var(--accent) 14%,transparent)}
+.fo-filters{grid-template-columns:minmax(200px,2fr) repeat(2,minmax(120px,1fr)) auto;padding:12px;gap:10px}
+.fo-try{margin:14px 0 6px}
+.fo-grid{grid-template-columns:repeat(auto-fill,minmax(min(240px,100%),1fr));gap:12px}
+.fo-card{padding:0;border-radius:16px;transition:border-color .15s,transform .15s}
+.fo-card:hover{border-color:var(--accent)}
+.fo-card:active{transform:scale(.99)}
+.fo-card-link{display:grid;gap:8px;padding:16px 16px 14px;color:inherit;text-decoration:none;height:100%;box-sizing:border-box}
+.fo-card h2{margin:0;font-size:28px;line-height:1.1;font-weight:400;overflow-wrap:anywhere}
+.fo-card .fo-sample{min-height:0;font-size:17px;line-height:1.35;color:var(--text-secondary)}
+.fo-meta{margin:auto 0 0;font-size:12px;color:var(--text-muted,var(--text-secondary))}
+.fo-meta span{text-transform:capitalize}
+.fo-n{margin-left:6px;font-size:.6em;font-weight:600;color:var(--text-muted,var(--text-secondary))}
+.fo-styles{grid-template-columns:repeat(auto-fill,minmax(min(420px,100%),1fr))}
+.fo-style{padding:12px 16px}
+.fo-style p{margin:6px 0 0;font-size:22px;line-height:1.25;overflow-wrap:anywhere}
+.fo-style>div{font-size:13px;color:var(--text-secondary)}
+.fo-style a{color:var(--accent);font-weight:650;text-decoration:none}
+.fo-more{margin-top:12px}
+.fo-more>summary{cursor:pointer;display:inline-flex;align-items:center;gap:6px;min-height:40px;padding:0 16px;border:1px solid var(--border);border-radius:999px;font-weight:650;list-style:none}
+.fo-more>summary::-webkit-details-marker{display:none}
+.fo-more>summary span{color:var(--text-secondary);font-weight:500}
+.fo-more[open]>summary{margin-bottom:12px}
+.fo-note{color:var(--text-secondary);max-width:70ch}
+.fo-pairings{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(300px,100%),1fr));gap:12px}
+.fo-pairings article{padding:18px;border:1px solid var(--border);border-radius:16px;background:var(--bg-secondary)}
+.fo-pair-h{margin:0;font-size:26px;line-height:1.15}
+.fo-pair-b{margin:8px 0 12px;font-size:16px;line-height:1.5;color:var(--text-secondary)}
+.fo-pair-why{margin:0;font-size:12.5px;color:var(--text-muted,var(--text-secondary))}
+@media(max-width:550px){
+ .fo-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}
+ .fo-card-link{padding:12px}
+ .fo-card h2{font-size:22px}
+ .fo-card .fo-sample{font-size:14px}
+ .fo-filters{grid-template-columns:1fr 1fr}
+ .fo-filters .fo-q,.fo-filters button{grid-column:1/-1}
+ .fo-style p{font-size:19px}
+}
+/* The selects look like the search field, not like buttons (a theme's button colours reached them on a phone) */
+.fo .fo-filters select{appearance:none;-webkit-appearance:none;background:var(--bg-primary) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8'%3E%3Cpath d='M1 1l5 5 5-5' fill='none' stroke='%2396a7c2' stroke-width='2'/%3E%3C/svg%3E") no-repeat right 12px center;color:var(--text-primary);border:1px solid var(--border);padding-right:32px;box-shadow:none}
+.fo .fo-filters button{background:transparent;color:var(--text-primary);border:1px solid var(--accent)}
+.fo .fo-filters button:hover{background:color-mix(in srgb,var(--accent) 14%,transparent)}
new file mode 100644
new file mode 100644
@@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
+'use strict';
+document.addEventListener('DOMContentLoaded', () => {
+ const input = document.querySelector('[data-font-sample]');
+ if (!input) return;
+ const samples = [...document.querySelectorAll('.fo-sample,.fo-style p,.fo-pair-b')];
+ const original = samples.map(node => node.textContent);
+ input.addEventListener('input', () => samples.forEach((node, index) => { node.textContent = input.value || original[index]; }));
+});
@@ -45,6 +45,8 @@ const { createPageRoutes } = require('./http/pages');
@@ -45,6 +45,8 @@ const { createPageRoutes } = require('./http/pages');
const { createTokenFileRoutes } = require('./http/tokens');
const { createTokenFileRoutes } = require('./http/tokens');
const { createDiscoveryRoutes } = require('./http/discovery');
const { createDiscoveryRoutes } = require('./http/discovery');
const { createIconRoutes } = require('./http/icons');
const { createIconRoutes } = require('./http/icons');
+const { createFontRoutes } = require('./http/fonts');
+const { createFontCatalog } = require('./fonts/catalog');
const { createServiceReadiness } = require('./observability');
const { createServiceReadiness } = require('./observability');
const { createArenaStore } = require('./arena/store');
const { createArenaStore } = require('./arena/store');
const { createArenaAi } = require('./arena/ai');
const { createArenaAi } = require('./arena/ai');
@@ -97,12 +99,14 @@ async function createApp(opts = {}) {
@@ -97,12 +99,14 @@ async function createApp(opts = {}) {
const publisher = opts.publisher || require('./publish').createPublisher({ config, fetchImpl, log });
const publisher = opts.publisher || require('./publish').createPublisher({ config, fetchImpl, log });
const accountData = accountDataLib.create({ db: s.db, log });
const accountData = accountDataLib.create({ db: s.db, log });
const picks = opts.picks || require('./brand-picks').createBrandPicks({ db: s.db, now });
const picks = opts.picks || require('./brand-picks').createBrandPicks({ db: s.db, now });
+ const fonts = opts.fonts || createFontCatalog({ db: s.db, fetchImpl, now, log });
+ await fonts.seedMissing();
const accountSend = opts.accountSend || (config.oauth.clientSecret
const accountSend = opts.accountSend || (config.oauth.clientSecret
? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })
? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Design cannot answer account events'); });
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Design cannot answer account events'); });
const ctx = {
const ctx = {
- config, s, store: s, keys, sso, settings, catalog, searchIndex, arena, bench, ai, jobs, designs, sharing, publisher, accountData, accountSend, log, picks,
+ config, s, store: s, keys, sso, settings, catalog, searchIndex, arena, bench, ai, jobs, designs, sharing, publisher, accountData, accountSend, log, picks, fonts,
random: opts.random || Math.random,
random: opts.random || Math.random,
};
};
@@ -136,7 +140,7 @@ async function createApp(opts = {}) {
@@ -136,7 +140,7 @@ async function createApp(opts = {}) {
// The OpenVibe Frame (theme-loader, navbar, footer) comes from the Network; the inline init is ours.
// The OpenVibe Frame (theme-loader, navbar, footer) comes from the Network; the inline init is ours.
scriptSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://static.cloudflareinsights.com', ...turnstileSrc],
scriptSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://static.cloudflareinsights.com', ...turnstileSrc],
styleSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://fonts.googleapis.com', 'https://cdnjs.cloudflare.com'],
styleSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://fonts.googleapis.com', 'https://cdnjs.cloudflare.com'],
- fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com', 'https://cdnjs.cloudflare.com'],
+ fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com', 'https://cdnjs.cloudflare.com', 'https://cdn.jsdelivr.net'],
// Theme previews may carry inline data: images and any https image the catalogue references.
// Theme previews may carry inline data: images and any https image the catalogue references.
imgSrc: ["'self'", 'data:', 'https:'],
imgSrc: ["'self'", 'data:', 'https:'],
// The footer's release line hears OpenVibe.Events; Cloudflare's beacon reports page timings.
// The footer's release line hears OpenVibe.Events; Cloudflare's beacon reports page timings.
@@ -275,6 +279,7 @@ async function createApp(opts = {}) {
@@ -275,6 +279,7 @@ async function createApp(opts = {}) {
app.use('/api/v1/brand', require('./http/brand-api').createBrandApi(ctx));
app.use('/api/v1/brand', require('./http/brand-api').createBrandApi(ctx));
app.get('/api/v1/prompts', (req, res) => { res.set('Cache-Control', 'public, max-age=300'); res.json({ prompts: require('./views/prompts').list() }); });
app.get('/api/v1/prompts', (req, res) => { res.set('Cache-Control', 'public, max-age=300'); res.json({ prompts: require('./views/prompts').list() }); });
app.use(createIconRoutes(ctx));
app.use(createIconRoutes(ctx));
+ app.use(createFontRoutes(ctx));
app.use('/api', createApi(ctx));
app.use('/api', createApi(ctx));
// GET /openapi.json (SEO-3): the API as OpenAPI 3.1. It is not in openvibe-contracts, so the document is read from
// GET /openapi.json (SEO-3): the API as OpenAPI 3.1. It is not in openvibe-contracts, so the document is read from
// the routes this app mounts under /api/, with the summaries the API files' header comments give them
// the routes this app mounts under /api/, with the summaries the API files' header comments give them
@@ -285,7 +290,7 @@ async function createApp(opts = {}) {
@@ -285,7 +290,7 @@ async function createApp(opts = {}) {
app.get('/openapi.json', require('./http/openapi-routes').openapiRoute(app, {
app.get('/openapi.json', require('./http/openapi-routes').openapiRoute(app, {
title: `${d.name} API`, description: d.purpose, version: require('../package.json').version, baseUrl: site,
title: `${d.name} API`, description: d.purpose, version: require('../package.json').version, baseUrl: site,
docs: `${site}/developers`,
docs: `${site}/developers`,
- files: ['http/api.js', 'http/arena-api.js', 'http/brand-api.js', 'http/design-api.js'].map((f) => require('path').join(__dirname, f)),
+ files: ['http/api.js', 'http/arena-api.js', 'http/brand-api.js', 'http/design-api.js', 'http/fonts.js'].map((f) => require('path').join(__dirname, f)),
}));
}));
}
}
app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', {
app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', {
new file mode 100644
new file mode 100644
@@ -0,0 +1,154 @@
@@ -0,0 +1,154 @@
+'use strict';
+
+const crypto = require('node:crypto');
+const seed = require('./seed.json');
+
+const WEEK = 7 * 24 * 60 * 60 * 1000;
+const CLAIM = 10 * 60 * 1000;
+const ALLOWED = new Set(['OFL-1.1', 'Apache-2.0', 'UFL-1.0']);
+const CDN = /^https:\/\/cdn\.jsdelivr\.net\/fontsource\/fonts\/[a-z0-9-]+@[0-9][a-z0-9.-]*\/[a-z0-9-]+\.woff2$/;
+const IDS = seed.map(f => f.id);
+
+function validFont(f) {
+ return f && /^[a-z0-9-]+$/.test(f.id) && typeof f.family === 'string' && /^[A-Za-z0-9 .'-]{1,100}$/.test(f.family)
+ && typeof f.designer === 'string' && f.designer.length > 0
+ && ALLOWED.has(f.license) && /^https:\/\/github\.com\/google\/fonts\/tree\/main\/(ofl|apache|ufl)\/[a-z0-9]+$/.test(f.license_url)
+ && /^https:\/\//.test(f.source) && typeof f.version === 'string'
+ && Array.isArray(f.weights) && f.weights.every(w => Number.isInteger(w) && w >= 100 && w <= 900)
+ && Array.isArray(f.styles) && f.styles.every(s => ['normal', 'italic'].includes(s))
+ && Array.isArray(f.subsets) && f.subsets.every(s => /^[a-z0-9-]+$/.test(s))
+ && f.files && Object.keys(f.files).length > 0 && Object.values(f.files).every(url => CDN.test(url));
+}
+
+function due(state, now) {
+ if (state.claimed_until && new Date(state.claimed_until).getTime() > now) return false;
+ return !state.last_success_at || now - new Date(state.last_success_at).getTime() >= WEEK;
+}
+
+function profile(f) {
+ const classic = ['serif', 'display', 'handwriting'].includes(f.category) || ['lato', 'oswald', 'josefin-sans', 'ubuntu'].includes(f.id);
+ const short = ['serif', 'display', 'handwriting'].includes(f.category) || ['raleway', 'oswald', 'abril-fatface'].includes(f.id);
+ return { era: classic ? 'classic' : 'modern', xHeight: short ? 'low' : 'high' };
+}
+
+function pairings(f, all) {
+ const a = profile(f);
+ const candidates = all.filter(g => g.id !== f.id).map(g => {
+ const b = profile(g);
+ const category = f.category !== g.category;
+ const height = a.xHeight !== b.xHeight;
+ const era = a.era !== b.era;
+ const headWeight = g.files['700-normal'] ? 700 : g.files['600-normal'] ? 600 : g.files['400-normal'] ? 400 : Number(String(Object.keys(g.files)[0] || '400').split('-')[0]);
+ return { family: g.family, id: g.id, category: g.category, file: g.files[`${headWeight}-normal`] || Object.values(g.files)[0], weight: headWeight, score: (category ? 4 : 0) + (height ? 2 : 0) + (era ? 1 : 0),
+ reason: [category && `contrasting ${g.category} and ${f.category} forms`, height && 'different x-height', era && 'classic and modern character'].filter(Boolean).join('; ') || 'a quieter companion' };
+ }).sort((x, y) => y.score - x.score || x.family.localeCompare(y.family));
+ const chosen = [];
+ for (const candidate of candidates) if (!chosen.some(p => p.category === candidate.category)) {
+ chosen.push(candidate);
+ if (chosen.length === 3) break;
+ }
+ for (const candidate of candidates) if (chosen.length < 3 && !chosen.includes(candidate)) chosen.push(candidate);
+ return chosen.map(({ score, category, ...p }) => p);
+}
+
+function createFontCatalog({ db, fetchImpl = fetch, now = () => Date.now(), log = console } = {}) {
+ let timer = null;
+ let running = false;
+ async function seedMissing() {
+ // A completed refresh may have removed a family whose licence changed. Never restore it on restart.
+ const state = await db.maybe('SELECT last_success_at FROM font_catalog_state WHERE id = 1');
+ if (state?.last_success_at) return;
+ for (const f of seed) {
+ if (!validFont(f)) throw new Error(`Invalid font seed: ${f.id}`);
+ await db.exec('INSERT INTO font_families (id, metadata) VALUES ($1, $2::jsonb) ON CONFLICT (id) DO NOTHING', [f.id, JSON.stringify(f)]);
+ }
+ }
+ async function list(filters = {}) {
+ const rows = await db.many('SELECT metadata FROM font_families ORDER BY id');
+ const q = String(filters.q || '').trim().toLowerCase().slice(0, 100);
+ const weight = filters.weight ? Number(filters.weight) : null;
+ const fonts = rows.map(r => r.metadata).filter(validFont).filter(f =>
+ (!q || `${f.family} ${f.designer}`.toLowerCase().includes(q))
+ && (!filters.category || f.category === filters.category)
+ && (!filters.script || f.subsets.includes(filters.script))
+ && (!weight || f.weights.includes(weight)));
+ return fonts;
+ }
+ async function get(id) {
+ if (!/^[a-z0-9-]{1,100}$/.test(id)) return null;
+ const row = await db.maybe('SELECT metadata FROM font_families WHERE id = $1', [id]);
+ if (!row || !validFont(row.metadata)) return null;
+ return { ...row.metadata, pairings: pairings(row.metadata, await list()) };
+ }
+ async function fetchFont(old) {
+ const response = await fetchImpl(`https://api.fontsource.org/v1/fonts/${old.id}`, { signal: AbortSignal.timeout(15_000) });
+ if (!response.ok) throw new Error(`Fontsource ${old.id}: HTTP ${response.status}`);
+ const f = await response.json();
+ if (!ALLOWED.has(f.license)) return null;
+ const dir = f.license === 'Apache-2.0' ? 'apache' : f.license === 'UFL-1.0' ? 'ufl' : 'ofl';
+ const licenseUrl = `https://github.com/google/fonts/tree/main/${dir}/${old.id.replace(/-/g, '')}`;
+ const metaUrl = `${licenseUrl.replace('https://github.com/google/fonts/tree/main/', 'https://raw.githubusercontent.com/google/fonts/main/')}/METADATA.pb`;
+ const metadata = await fetchImpl(metaUrl, { signal: AbortSignal.timeout(15_000) });
+ if (!metadata.ok) throw new Error(`Font metadata ${old.id}: HTTP ${metadata.status}`);
+ const designer = (String(await metadata.text()).match(/^designer: "([^"]+)"/m) || [])[1];
+ const files = {};
+ for (const weight of f.weights || []) for (const style of f.styles || []) {
+ const url = f.variants?.[weight]?.[style]?.[f.defSubset]?.url?.woff2;
+ if (url) files[`${weight}-${style}`] = url;
+ }
+ const result = { id: f.id, family: f.family, designer, category: f.category,
+ weights: f.weights, styles: f.styles, subsets: f.subsets, license: f.license,
+ version: f.version, source: f.source, license_url: licenseUrl, files };
+ if (!validFont(result)) throw new Error(`Invalid metadata for ${old.id}`);
+ const preview = result.files['400-normal'] || Object.values(result.files)[0];
+ const asset = await fetchImpl(preview, { signal: AbortSignal.timeout(15_000) });
+ if (asset.ok) result.sri = `sha384-${crypto.createHash('sha384').update(Buffer.from(await asset.arrayBuffer())).digest('base64')}`;
+ return result;
+ }
+ async function tick() {
+ if (running) return false;
+ running = true;
+ const time = now();
+ const claimId = crypto.randomUUID();
+ try {
+ const claim = await db.maybe(`UPDATE font_catalog_state SET claim_id = $1, claimed_until = $2::timestamptz
+ WHERE id = 1 AND (claimed_until IS NULL OR claimed_until <= $3::timestamptz)
+ AND (last_success_at IS NULL OR last_success_at <= $4::timestamptz) RETURNING id`,
+ [claimId, new Date(time + CLAIM).toISOString(), new Date(time).toISOString(), new Date(time - WEEK).toISOString()]);
+ if (!claim) return false;
+ try {
+ const results = [];
+ // Keep the remote request rate modest; the claim spans the whole refresh.
+ for (const id of IDS) {
+ const old = seed.find(f => f.id === id);
+ const result = await fetchFont(old);
+ if (result) results.push(result);
+ }
+ await db.tx(async () => {
+ const held = await db.maybe('SELECT id FROM font_catalog_state WHERE id = 1 AND claim_id = $1 FOR UPDATE', [claimId]);
+ if (!held) return;
+ for (const f of results) await db.exec(`INSERT INTO font_families (id, metadata, updated_at) VALUES ($1, $2::jsonb, $3::timestamptz)
+ ON CONFLICT (id) DO UPDATE SET metadata = EXCLUDED.metadata, updated_at = EXCLUDED.updated_at`,
+ [f.id, JSON.stringify(f), new Date(time).toISOString()]);
+ await db.exec('DELETE FROM font_families WHERE id <> ALL($1::text[])', [results.map(f => f.id)]);
+ await db.exec('UPDATE font_catalog_state SET last_success_at = $1::timestamptz, claimed_until = NULL, claim_id = NULL WHERE id = 1 AND claim_id = $2', [new Date(time).toISOString(), claimId]);
+ });
+ return true;
+ } catch (err) {
+ await db.exec('UPDATE font_catalog_state SET claimed_until = NULL, claim_id = NULL WHERE id = 1 AND claim_id = $1', [claimId]);
+ throw err;
+ }
+ } finally { running = false; }
+ }
+ function start() {
+ if (timer) return;
+ const run = () => tick().catch(e => log.warn('[OpenVibe.Design] font refresh:', e.message));
+ run();
+ timer = setInterval(run, 60 * 60 * 1000);
+ timer.unref?.();
+ }
+ function stop() { if (timer) clearInterval(timer); timer = null; }
+ return { seedMissing, list, get, tick, start, stop };
+}
+
+module.exports = { createFontCatalog, validFont, due, pairings, WEEK };
new file mode 100644
new file mode 100644
@@ -0,0 +1,1678 @@
@@ -0,0 +1,1678 @@
+[
+ {
+ "id": "abril-fatface",
+ "family": "Abril Fatface",
+ "designer": "TypeTogether",
+ "category": "display",
+ "weights": [
+ 400
+ ],
+ "styles": [
+ "normal"
+ ],
+ "subsets": [
+ "latin",
+ "latin-ext"
+ ],
+ "license": "OFL-1.1",
+ "version": "v25",
+ "source": "https://github.com/google/fonts",
+ "license_url": "https://github.com/google/fonts/tree/main/ofl/abrilfatface",
+ "files": {
+ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2"
+ },
+ "sri": "sha384-wpDjVWX1raklpoCthpT+SqPotxqGYsT8rnl9vOxxtp35U2RCo75d5JEHgDqOl7YV"
+ },
+ {
+ "id": "cormorant-garamond",
+ "family": "Cormorant Garamond",
+ "designer": "Christian Thalmann",
+ "category": "serif",
+ "weights": [
+ 300,
+ 400,
+ 500,
+ 600,
+ 700
+ ],
+ "styles": [
+ "italic",
+ "normal"
+ ],
+ "subsets": [
+ "cyrillic",
+ "cyrillic-ext",
+ "latin",
+ "latin-ext",
+ "vietnamese"
+ ],
+ "license": "OFL-1.1",
+ "version": "v21",
+ "source": "https://github.com/google/fonts",
+ "license_url": "https://github.com/google/fonts/tree/main/ofl/cormorantgaramond",
+ "files": {
+ "300-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-italic.woff2",
+ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",
+ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",
+ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
+ "500-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-italic.woff2",
+ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",
+ "600-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-italic.woff2",
+ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",
+ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",
+ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2"
+ },
+ "sri": "sha384-C7LOiVF8vrW1L9HuZmyWfOOwJ/AklDdrU0e8qUh/vUCDauoEUDgdnz84hC8u5UKR"
+ },
+ {
+ "id": "dancing-script",
+ "family": "Dancing Script",
+ "designer": "Impallari Type",
+ "category": "handwriting",
+ "weights": [
+ 400,
+ 500,
+ 600,
+ 700
+ ],
+ "styles": [
+ "normal"
+ ],
+ "subsets": [
+ "latin",
+ "latin-ext",
+ "vietnamese"
+ ],
+ "license": "OFL-1.1",
+ "version": "v29",
+ "source": "https://github.com/google/fonts",
+ "license_url": "https://github.com/google/fonts/tree/main/ofl/dancingscript",
+ "files": {
+ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
+ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",
+ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",
+ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2"
+ },
+ "sri": "sha384-lrSz6x6b6m90dJcsoNLhQhuKJXyrVxpHZVtubEuWgw2Pdg1znt+/jxv9YGhfvjJ0"
+ },
+ {
+ "id": "dm-sans",
+ "family": "DM Sans",
+ "designer": "Colophon Foundry",
+ "category": "sans-serif",
+ "weights": [
+ 100,
+ 200,
+ 300,
+ 400,
+ 500,
+ 600,
+ 700,
+ 800,
+ 900
+ ],
+ "styles": [
+ "italic",
+ "normal"
+ ],
+ "subsets": [
+ "latin",
+ "latin-ext"
+ ],
+ "license": "OFL-1.1",
+ "version": "v17",
+ "source": "https://github.com/google/fonts",
+ "license_url": "https://github.com/google/fonts/tree/main/ofl/dmsans",
+ "files": {
+ "100-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-italic.woff2",
+ "100-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-normal.woff2",
+ "200-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-italic.woff2",
+ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",
+ "300-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-italic.woff2",
+ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",
+ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",
+ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
+ "500-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-italic.woff2",
+ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",
+ "600-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-italic.woff2",
+ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",
+ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",
+ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",
+ "800-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-italic.woff2",
+ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2",
+ "900-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-italic.woff2",
+ "900-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-normal.woff2"
+ },
+ "sri": "sha384-ZdBBucdtQA81cDAiMrRzG0sKtablpH7avbTQ8sL/bLTJaRLqKw3iRRIk7BlZXmBL"
+ },
+ {
+ "id": "fira-code",
+ "family": "Fira Code",
+ "designer": "The Mozilla Foundation, Telefonica S.A., Nikita Prokopov",
+ "category": "monospace",
+ "weights": [
+ 300,
+ 400,
+ 500,
+ 600,
+ 700
+ ],
+ "styles": [
+ "normal"
+ ],
+ "subsets": [
+ "cyrillic",
+ "cyrillic-ext",
+ "greek",
+ "greek-ext",
+ "latin",
+ "latin-ext",
+ "symbols2"
+ ],
+ "license": "OFL-1.1",
+ "version": "v27",
+ "source": "https://github.com/google/fonts",
+ "license_url": "https://github.com/google/fonts/tree/main/ofl/firacode",
+ "files": {
+ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",
+ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
+ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",
+ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",
+ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2"
+ },
+ "sri": "sha384-8yWSvZzF1qfjfXS1YbHQUZEefwH+Xr7wHeqdaG0dWfVTJyqZrzfkxNN1K9Sn1WFG"
+ },
+ {
+ "id": "fira-sans",
+ "family": "Fira Sans",
+ "designer": "Carrois Apostrophe",
+ "category": "sans-serif",
+ "weights": [
+ 100,
+ 200,
+ 300,
+ 400,
+ 500,
+ 600,
+ 700,
+ 800,
+ 900
+ ],
+ "styles": [
+ "italic",
+ "normal"
+ ],
+ "subsets": [
+ "cyrillic",
+ "cyrillic-ext",
+ "greek",
+ "greek-ext",
+ "latin",
+ "latin-ext",
+ "vietnamese"
+ ],
+ "license": "OFL-1.1",
+ "version": "v18",
+ "source": "https://github.com/google/fonts",
+ "license_url": "https://github.com/google/fonts/tree/main/ofl/firasans",
+ "files": {
+ "100-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-italic.woff2",
+ "100-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-normal.woff2",
+ "200-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-italic.woff2",
+ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",
+ "300-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-italic.woff2",
+ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",
+ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",
+ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
+ "500-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-italic.woff2",
+ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",
+ "600-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-italic.woff2",
+ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",
+ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",
+ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",
+ "800-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-italic.woff2",
+ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2",
+ "900-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-italic.woff2",
+ "900-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-normal.woff2"
+ },
+ "sri": "sha384-+mFWbPBWAziTjtBaXJHKLKt8ciu/OCjv0taTqX0caIJ0siDoWne4W6miuaELAXvj"
+ },
+ {
+ "id": "ibm-plex-mono",
+ "family": "IBM Plex Mono",
+ "designer": "Mike Abbink, Bold Monday",
+ "category": "monospace",
+ "weights": [
+ 100,
+ 200,
+ 300,
+ 400,
+ 500,
+ 600,
+ 700
+ ],
+ "styles": [
+ "italic",
+ "normal"
+ ],
+ "subsets": [
+ "cyrillic",
+ "cyrillic-ext",
+ "latin",
+ "latin-ext",
+ "vietnamese"
+ ],
+ "license": "OFL-1.1",
+ "version": "v20",
+ "source": "https://github.com/google/fonts",
+ "license_url": "https://github.com/google/fonts/tree/main/ofl/ibmplexmono",
+ "files": {
+ "100-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-italic.woff2",
+ "100-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-normal.woff2",
+ "200-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-italic.woff2",
+ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",
+ "300-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-italic.woff2",
+ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",
+ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",
+ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
+ "500-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-italic.woff2",
+ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",
+ "600-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-italic.woff2",
+ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",
+ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",
+ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2"
+ },
+ "sri": "sha384-oFQAuS7n3CFHgvwGpuoFV1kPR39MQLTlQIL+UnoEvMkkFZsrFNOZy/f7x+DofQNH"
+ },
+ {
+ "id": "inconsolata",
+ "family": "Inconsolata",
+ "designer": "Raph Levien",
+ "category": "monospace",
+ "weights": [
+ 200,
+ 300,
+ 400,
+ 500,
+ 600,
+ 700,
+ 800,
+ 900
+ ],
+ "styles": [
+ "normal"
+ ],
+ "subsets": [
+ "latin",
+ "latin-ext",
+ "vietnamese"
+ ],
+ "license": "OFL-1.1",
+ "version": "v37",
+ "source": "https://github.com/google/fonts",
+ "license_url": "https://github.com/google/fonts/tree/main/ofl/inconsolata",
+ "files": {
+ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",
+ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",
+ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
+ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",
+ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",
+ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",
+ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2",
+ "900-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-normal.woff2"
+ },
+ "sri": "sha384-SadIt3drVYfBAGMttu7SYdoD0icQVq35V3MfX8oSCrAJCwrQtE19i4TDCYb9ZTHZ"
+ },
+ {
+ "id": "inter",
+ "family": "Inter",
+ "designer": "Rasmus Andersson",
+ "category": "sans-serif",
+ "weights": [
+ 100,
+ 200,
+ 300,
+ 400,
+ 500,
+ 600,
+ 700,
+ 800,
+ 900
+ ],
+ "styles": [
+ "italic",
+ "normal"
+ ],
+ "subsets": [
+ "cyrillic",
+ "cyrillic-ext",
+ "greek",
+ "greek-ext",
+ "latin",
+ "latin-ext",
+ "vietnamese"
+ ],
+ "license": "OFL-1.1",
+ "version": "v20",
+ "source": "https://github.com/google/fonts",
+ "license_url": "https://github.com/google/fonts/tree/main/ofl/inter",
+ "files": {
+ "100-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-italic.woff2",
+ "100-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-normal.woff2",
+ "200-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-italic.woff2",
+ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",
+ "300-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-italic.woff2",
+ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",
+ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",
+ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
+ "500-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-italic.woff2",
+ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",
+ "600-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-italic.woff2",
+ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",
+ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",
+ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",
+ "800-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-italic.woff2",
+ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2",
+ "900-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-italic.woff2",
+ "900-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-normal.woff2"
+ },
+ "sri": "sha384-tqh0FAOjwwo8QcrfmptsbU/OH9394dupGj2d2zJQ7lFJdE5ouzRr13+2JlwV7vrE"
+ },
+ {
+ "id": "jetbrains-mono",
+ "family": "JetBrains Mono",
+ "designer": "JetBrains, Philipp Nurullin, Konstantin Bulenkov",
+ "category": "monospace",
+ "weights": [
+ 100,
+ 200,
+ 300,
+ 400,
+ 500,
+ 600,
+ 700,
+ 800
+ ],
+ "styles": [
+ "italic",
+ "normal"
+ ],
+ "subsets": [
+ "cyrillic",
+ "cyrillic-ext",
+ "greek",
+ "latin",
+ "latin-ext",
+ "vietnamese"
+ ],
+ "license": "OFL-1.1",
+ "version": "v24",
+ "source": "https://github.com/google/fonts",
+ "license_url": "https://github.com/google/fonts/tree/main/ofl/jetbrainsmono",
+ "files": {
+ "100-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-italic.woff2",
+ "100-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-normal.woff2",
+ "200-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-italic.woff2",
+ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",
+ "300-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-italic.woff2",
+ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",
+ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",
+ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
+ "500-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-italic.woff2",
+ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",
+ "600-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-italic.woff2",
+ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",
+ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",
+ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",
+ "800-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-italic.woff2",
+ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2"
+ },
+ "sri": "sha384-/4dCc3INKaFZBsZMku6bd44i8Gr1uXW1GYvdcNjsbHXHh2nGnk9G4RAhHf8hx3GT"
+ },
+ {
+ "id": "josefin-sans",
+ "family": "Josefin Sans",
+ "designer": "Santiago Orozco",
+ "category": "sans-serif",
+ "weights": [
+ 100,
+ 200,
+ 300,
+ 400,
+ 500,
+ 600,
+ 700
+ ],
+ "styles": [
+ "italic",
+ "normal"
+ ],
+ "subsets": [
+ "latin",
+ "latin-ext",
+ "vietnamese"
+ ],
+ "license": "OFL-1.1",
+ "version": "v34",
+ "source": "https://github.com/google/fonts",
+ "license_url": "https://github.com/google/fonts/tree/main/ofl/josefinsans",
+ "files": {
+ "100-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-italic.woff2",
+ "100-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-normal.woff2",
+ "200-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-italic.woff2",
+ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",
+ "300-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-italic.woff2",
+ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",
+ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",
+ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
+ "500-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-italic.woff2",
+ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",
+ "600-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-italic.woff2",
+ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",
+ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",
+ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2"
+ },
+ "sri": "sha384-dnO5dwLeQGRECJGM/PT1eRqKbkQTCefJr96a9obMYjlDnlpYSmCqZYnA3n2LxpXU"
+ },
+ {
+ "id": "lato",
+ "family": "Lato",
+ "designer": "Łukasz Dziedzic",
+ "category": "sans-serif",
+ "weights": [
+ 100,
+ 300,
+ 400,
+ 700,
+ 900
+ ],
+ "styles": [
+ "italic",
+ "normal"
+ ],
+ "subsets": [
+ "latin",
+ "latin-ext"
+ ],
+ "license": "OFL-1.1",
+ "version": "v25",
+ "source": "https://github.com/google/fonts",
+ "license_url": "https://github.com/google/fonts/tree/main/ofl/lato",
+ "files": {
+ "100-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-italic.woff2",
+ "100-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-normal.woff2",
+ "300-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-italic.woff2",
+ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",
+ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",
+ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
+ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",
+ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",
+ "900-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-italic.woff2",
+ "900-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-normal.woff2"
+ },
+ "sri": "sha384-+chn3xqCWRPSXDBtVEFQn+0J9Gb0SYR2FO1WrXo+L/arnyr8lXLVX2p0BqZIUemc"
+ },
+ {
+ "id": "libre-baskerville",
+ "family": "Libre Baskerville",
+ "designer": "Impallari Type",
+ "category": "serif",
+ "weights": [
+ 400,
+ 500,
+ 600,
+ 700
+ ],
+ "styles": [
+ "italic",
+ "normal"
+ ],
+ "subsets": [
+ "latin",
+ "latin-ext"
+ ],
+ "license": "OFL-1.1",
+ "version": "v24",
+ "source": "https://github.com/google/fonts",
+ "license_url": "https://github.com/google/fonts/tree/main/ofl/librebaskerville",
+ "files": {
+ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",
+ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
+ "500-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-italic.woff2",
+ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",
+ "600-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-italic.woff2",
+ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",
+ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",
+ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2"
+ },
+ "sri": "sha384-uRckE2OjWTlzltjCXxrZffS/bXnLA6oEECyxYUb3eix/O1iaEoOdEnkaHoB2R36J"
+ },
+ {
+ "id": "lora",
+ "family": "Lora",
+ "designer": "Cyreal",
+ "category": "serif",
+ "weights": [
+ 400,
+ 500,
+ 600,
+ 700
+ ],
+ "styles": [
+ "italic",
+ "normal"
+ ],
+ "subsets": [
+ "cyrillic",
+ "cyrillic-ext",
+ "latin",
+ "latin-ext",
+ "math",
+ "symbols",
+ "vietnamese"
+ ],
+ "license": "OFL-1.1",
+ "version": "v37",
+ "source": "https://github.com/google/fonts",
+ "license_url": "https://github.com/google/fonts/tree/main/ofl/lora",
+ "files": {
+ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",
+ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
+ "500-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-italic.woff2",
+ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",
+ "600-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-italic.woff2",
+ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",
+ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",
+ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2"
+ },
+ "sri": "sha384-4K0WjzceC/SOy/usx6omCD3VyEOFfhpO97BismDBHNTJVOkoaAqmoEOWkiDGJr/8"
+ },
+ {
+ "id": "manrope",
+ "family": "Manrope",
+ "designer": "Mikhail Sharanda",
+ "category": "sans-serif",
+ "weights": [
+ 200,
+ 300,
+ 400,
+ 500,
+ 600,
+ 700,
+ 800
+ ],
+ "styles": [
+ "normal"
+ ],
+ "subsets": [
+ "cyrillic",
+ "cyrillic-ext",
+ "greek",
+ "latin",
+ "latin-ext",
+ "vietnamese"
+ ],
+ "license": "OFL-1.1",
+ "version": "v20",
+ "source": "https://github.com/google/fonts",
+ "license_url": "https://github.com/google/fonts/tree/main/ofl/manrope",
+ "files": {
+ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",
+ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",
+ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
+ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",
+ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",
+ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",
+ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2"
+ },
+ "sri": "sha384-MZMzLPF8CUlQGCqf+feQOIKEzmoRP/SDPngpfNIiv7DasqbtsikGsheLZWB98pAR"
+ },
+ {
+ "id": "merriweather",
+ "family": "Merriweather",
+ "designer": "Sorkin Type",
+ "category": "serif",
+ "weights": [
+ 300,
+ 400,
+ 500,
+ 600,
+ 700,
+ 800,
+ 900
+ ],
+ "styles": [
+ "italic",
+ "normal"
+ ],
+ "subsets": [
+ "cyrillic",
+ "cyrillic-ext",
+ "latin",
+ "latin-ext",
+ "vietnamese"
+ ],
+ "license": "OFL-1.1",
+ "version": "v33",
+ "source": "https://github.com/google/fonts",
+ "license_url": "https://github.com/google/fonts/tree/main/ofl/merriweather",
+ "files": {
+ "300-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-italic.woff2",
+ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",
+ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",
+ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
+ "500-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-italic.woff2",
+ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",
+ "600-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-italic.woff2",
+ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",
+ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",
+ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",
+ "800-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-italic.woff2",
+ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2",
+ "900-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-italic.woff2",
+ "900-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-normal.woff2"
+ },
+ "sri": "sha384-ZgZJGQ6rET5qzeALEh9evIZFPZkUYTzCcGEuYSyWnqSwzXc9Qyefyu+AJ+ynNhyi"
+ },
+ {
+ "id": "montserrat",
+ "family": "Montserrat",
+ "designer": "Julieta Ulanovsky, Sol Matas, Juan Pablo del Peral, Jacques Le Bailly",
+ "category": "sans-serif",
+ "weights": [
+ 100,
+ 200,
+ 300,
+ 400,
+ 500,
+ 600,
+ 700,
+ 800,
+ 900
+ ],
+ "styles": [
+ "italic",
+ "normal"
+ ],
+ "subsets": [
+ "cyrillic",
+ "cyrillic-ext",
+ "latin",
+ "latin-ext",
+ "vietnamese"
+ ],
+ "license": "OFL-1.1",
+ "version": "v31",
+ "source": "https://github.com/google/fonts",
+ "license_url": "https://github.com/google/fonts/tree/main/ofl/montserrat",
+ "files": {
+ "100-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-italic.woff2",
+ "100-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-normal.woff2",
+ "200-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-italic.woff2",
+ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",
+ "300-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-italic.woff2",
+ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",
+ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",
+ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
+ "500-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-italic.woff2",
+ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",
+ "600-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-italic.woff2",
+ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",
+ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",
+ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",
+ "800-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-italic.woff2",
+ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2",
+ "900-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-italic.woff2",
+ "900-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-normal.woff2"
+ },
+ "sri": "sha384-TZp3MNli7D4I9HF7ut1KGl19b69Ah6oafBq+SgFy8VzLt3vFx74DrLJ2Jt/z5gUY"
+ },
+ {
+ "id": "noto-sans-arabic",
+ "family": "Noto Sans Arabic",
+ "designer": "Google",
+ "category": "sans-serif",
+ "weights": [
+ 100,
+ 200,
+ 300,
+ 400,
+ 500,
+ 600,
+ 700,
+ 800,
+ 900
+ ],
+ "styles": [
+ "normal"
+ ],
+ "subsets": [
+ "arabic",
+ "latin",
+ "latin-ext",
+ "math",
+ "symbols"
+ ],
+ "license": "OFL-1.1",
+ "version": "v33",
+ "source": "https://github.com/google/fonts",
+ "license_url": "https://github.com/google/fonts/tree/main/ofl/notosansarabic",
+ "files": {
+ "100-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-normal.woff2",
+ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",
+ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",
+ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
+ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",
+ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",
+ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",
+ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2",
+ "900-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-normal.woff2"
+ },
+ "sri": "sha384-ktrmBjgMzmznHZm9VmzUAl/2ht+E/QB11F4mctEPuHTmK7VU8KeItUlJ945Qv6FI"
+ },
+ {
+ "id": "noto-sans-devanagari",
+ "family": "Noto Sans Devanagari",
+ "designer": "Google",
+ "category": "sans-serif",
+ "weights": [
+ 100,
+ 200,
+ 300,
+ 400,
+ 500,
+ 600,
+ 700,
+ 800,
+ 900
+ ],
+ "styles": [
+ "normal"
+ ],
+ "subsets": [
+ "devanagari",
+ "latin",
+ "latin-ext"
+ ],
+ "license": "OFL-1.1",
+ "version": "v30",
+ "source": "https://github.com/google/fonts",
+ "license_url": "https://github.com/google/fonts/tree/main/ofl/notosansdevanagari",
+ "files": {
+ "100-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-normal.woff2",
+ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",
+ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",
+ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
+ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",
+ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",
+ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",
+ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2",
+ "900-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-normal.woff2"
+ },
+ "sri": "sha384-6qvgeq5MT8DL150wuVD7QhgdnpuMOQcit0+3WA+BihqdLH9QNWAIJVwOkPLByDHK"
+ },
+ {
+ "id": "noto-sans-jp",
+ "family": "Noto Sans JP",
+ "designer": "Google",
+ "category": "sans-serif",
+ "weights": [
+ 100,
+ 200,
+ 300,
+ 400,
+ 500,
+ 600,
+ 700,
+ 800,
+ 900
+ ],
+ "styles": [
+ "normal"
+ ],
+ "subsets": [
+ "cyrillic",
+ "japanese",
+ "latin",
+ "latin-ext",
+ "vietnamese"
+ ],
+ "license": "OFL-1.1",
+ "version": "v57",
+ "source": "https://github.com/google/fonts",
+ "license_url": "https://github.com/google/fonts/tree/main/ofl/notosansjp",
+ "files": {
+ "100-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-normal.woff2",
+ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",
+ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",
+ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
+ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",
+ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",
+ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",
+ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2",
+ "900-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-normal.woff2"
+ },
+ "sri": "sha384-ZMGxbGF40IH+TlOdWEUxyz1qJOy2eWeNBhWXMgE3ytakMsKeDuj4RoAPPwuk8THu"
+ },
+ {
+ "id": "noto-sans-kr",
+ "family": "Noto Sans KR",
+ "designer": "Google",
+ "category": "sans-serif",
+ "weights": [
+ 100,
+ 200,
+ 300,
+ 400,
+ 500,
+ 600,
+ 700,
+ 800,
+ 900
+ ],
+ "styles": [
+ "normal"
+ ],
+ "subsets": [
+ "cyrillic",
+ "korean",
+ "latin",
+ "latin-ext",
+ "vietnamese"
+ ],
+ "license": "OFL-1.1",
+ "version": "v40",
+ "source": "https://github.com/google/fonts",
+ "license_url": "https://github.com/google/fonts/tree/main/ofl/notosanskr",
+ "files": {
+ "100-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-normal.woff2",
+ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",
+ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",
+ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
+ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",
+ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",
+ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",
+ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2",
+ "900-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-normal.woff2"
+ },
+ "sri": "sha384-MQrIcZ3XdaBQuSgSMpZ5h4v6YE0xA6sy9r3IKqiQsb3b6tD6npc3FosmBEm/DHbo"
+ },
+ {
+ "id": "noto-serif",
+ "family": "Noto Serif",
+ "designer": "Google",
+ "category": "serif",
+ "weights": [
+ 100,
+ 200,
+ 300,
+ 400,
+ 500,
+ 600,
+ 700,
+ 800,
+ 900
+ ],
+ "styles": [
+ "italic",
+ "normal"
+ ],
+ "subsets": [
+ "cyrillic",
+ "cyrillic-ext",
+ "greek",
+ "greek-ext",
+ "latin",
+ "latin-ext",
+ "math",
+ "vietnamese"
+ ],
+ "license": "OFL-1.1",
+ "version": "v33",
+ "source": "https://github.com/google/fonts",
+ "license_url": "https://github.com/google/fonts/tree/main/ofl/notoserif",
+ "files": {
+ "100-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-italic.woff2",
+ "100-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-normal.woff2",
+ "200-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-italic.woff2",
+ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",
+ "300-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-italic.woff2",
+ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",
+ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",
+ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
+ "500-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-italic.woff2",
+ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",
+ "600-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-italic.woff2",
+ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",
+ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",
+ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",
+ "800-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-italic.woff2",
+ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2",
+ "900-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-italic.woff2",
+ "900-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-normal.woff2"
+ },
+ "sri": "sha384-mErvIjhu9VEDQVGNFT+/Ax8p/AQ8jA03O3yWHCu9Gbkds3UzFuAaOPJgshiPzsOZ"
+ },
+ {
+ "id": "nunito",
+ "family": "Nunito",
+ "designer": "Vernon Adams, Cyreal, Jacques Le Bailly",
+ "category": "sans-serif",
+ "weights": [
+ 200,
+ 300,
+ 400,
+ 500,
+ 600,
+ 700,
+ 800,
+ 900
+ ],
+ "styles": [
+ "italic",
+ "normal"
+ ],
+ "subsets": [
+ "cyrillic",
+ "cyrillic-ext",
+ "latin",
+ "latin-ext",
+ "vietnamese"
+ ],
+ "license": "OFL-1.1",
+ "version": "v32",
+ "source": "https://github.com/google/fonts",
+ "license_url": "https://github.com/google/fonts/tree/main/ofl/nunito",
+ "files": {
+ "200-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-italic.woff2",
+ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",
+ "300-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-italic.woff2",
+ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",
+ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",
+ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
+ "500-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-italic.woff2",
+ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",
+ "600-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-italic.woff2",
+ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",
+ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",
+ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",
+ "800-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-italic.woff2",
+ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2",
+ "900-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-italic.woff2",
+ "900-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-normal.woff2"
+ },
+ "sri": "sha384-/Q+nUswFTZOi6aXONHIluy9SKfcuGoXESMYZkW//IgytrlO29H5JEwZ7KyeFzHJf"
+ },
+ {
+ "id": "open-sans",
+ "family": "Open Sans",
+ "designer": "Steve Matteson",
+ "category": "sans-serif",
+ "weights": [
+ 300,
+ 400,
+ 500,
+ 600,
+ 700,
+ 800
+ ],
+ "styles": [
+ "italic",
+ "normal"
+ ],
+ "subsets": [
+ "cyrillic",
+ "cyrillic-ext",
+ "greek",
+ "greek-ext",
+ "hebrew",
+ "latin",
+ "latin-ext",
+ "math",
+ "symbols",
+ "vietnamese"
+ ],
+ "license": "OFL-1.1",
+ "version": "v44",
+ "source": "https://github.com/google/fonts",
+ "license_url": "https://github.com/google/fonts/tree/main/ofl/opensans",
+ "files": {
+ "300-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-italic.woff2",
+ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",
+ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",
+ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
+ "500-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-italic.woff2",
+ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",
+ "600-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-italic.woff2",
+ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",
+ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",
+ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",
+ "800-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-italic.woff2",
+ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2"
+ },
+ "sri": "sha384-y5c6efoQ3OOtWXIDzjW2R3FJVtHOFICtnJqM5+8NHiPlG95BTMDNxiS862cIaIyx"
+ },
+ {
+ "id": "oswald",
+ "family": "Oswald",
+ "designer": "Vernon Adams, Kalapi Gajjar, Cyreal",
+ "category": "sans-serif",
+ "weights": [
+ 200,
+ 300,
+ 400,
+ 500,
+ 600,
+ 700
+ ],
+ "styles": [
+ "normal"
+ ],
+ "subsets": [
+ "cyrillic",
+ "cyrillic-ext",
+ "latin",
+ "latin-ext",
+ "vietnamese"
+ ],
+ "license": "OFL-1.1",
+ "version": "v57",
+ "source": "https://github.com/google/fonts",
+ "license_url": "https://github.com/google/fonts/tree/main/ofl/oswald",
+ "files": {
+ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",
+ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",
+ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
+ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",
+ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",
+ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2"
+ },
+ "sri": "sha384-pAZFASbk49K48F9Ak4QWKRMPCS+PUVOTyZ8lbOmavPcxXfSm2MXJz7RYx/sj79m4"
+ },
+ {
+ "id": "outfit",
+ "family": "Outfit",
+ "designer": "Smartsheet Inc, Rodrigo Fuenzalida",
+ "category": "sans-serif",
+ "weights": [
+ 100,
+ 200,
+ 300,
+ 400,
+ 500,
+ 600,
+ 700,
+ 800,
+ 900
+ ],
+ "styles": [
+ "normal"
+ ],
+ "subsets": [
+ "latin",
+ "latin-ext"
+ ],
+ "license": "OFL-1.1",
+ "version": "v15",
+ "source": "https://github.com/google/fonts",
+ "license_url": "https://github.com/google/fonts/tree/main/ofl/outfit",
+ "files": {
+ "100-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-normal.woff2",
+ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",
+ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",
+ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
+ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",
+ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",
+ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",
+ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2",
+ "900-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-normal.woff2"
+ },
+ "sri": "sha384-LAIKRxjNJSUYh5JU9qx1to4Cg5fiTZmEHpe+giOnsw5m/9nofEsQw+b65hqvaQio"
+ },
+ {
+ "id": "permanent-marker",
+ "family": "Permanent Marker",
+ "designer": "Font Diner",
+ "category": "handwriting",
+ "weights": [
+ 400
+ ],
+ "styles": [
+ "normal"
+ ],
+ "subsets": [
+ "latin"
+ ],
+ "license": "Apache-2.0",
+ "version": "v16",
+ "source": "https://github.com/google/fonts",
+ "license_url": "https://github.com/google/fonts/tree/main/apache/permanentmarker",
+ "files": {
+ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2"
+ },
+ "sri": "sha384-vT5xTr25YOIF7ASHObdDkQnLzyunaZ1r9FRHtYCen88HCgzujW8tWPo6rzU2vt77"
+ },
+ {
+ "id": "playfair-display",
+ "family": "Playfair Display",
+ "designer": "Claus Eggers Sørensen",
+ "category": "serif",
+ "weights": [
+ 400,
+ 500,
+ 600,
+ 700,
+ 800,
+ 900
+ ],
+ "styles": [
+ "italic",
+ "normal"
+ ],
+ "subsets": [
+ "cyrillic",
+ "latin",
+ "latin-ext",
+ "vietnamese"
+ ],
+ "license": "OFL-1.1",
+ "version": "v40",
+ "source": "https://github.com/google/fonts",
+ "license_url": "https://github.com/google/fonts/tree/main/ofl/playfairdisplay",
+ "files": {
+ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",
+ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
+ "500-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-italic.woff2",
+ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",
+ "600-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-italic.woff2",
+ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",
+ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",
+ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",
+ "800-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-italic.woff2",
+ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2",
+ "900-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-italic.woff2",
+ "900-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-normal.woff2"
+ },
+ "sri": "sha384-eIw2aTe54daHavGiBUeDMN9SiosN3q5uI4UASs2J59iv0Rje8ZTBs6dq/jw8WKEI"
+ },
+ {
+ "id": "poppins",
+ "family": "Poppins",
+ "designer": "Indian Type Foundry, Jonny Pinhorn, Ninad Kale",
+ "category": "sans-serif",
+ "weights": [
+ 100,
+ 200,
+ 300,
+ 400,
+ 500,
+ 600,
+ 700,
+ 800,
+ 900
+ ],
+ "styles": [
+ "italic",
+ "normal"
+ ],
+ "subsets": [
+ "devanagari",
+ "latin",
+ "latin-ext"
+ ],
+ "license": "OFL-1.1",
+ "version": "v24",
+ "source": "https://github.com/google/fonts",
+ "license_url": "https://github.com/google/fonts/tree/main/ofl/poppins",
+ "files": {
+ "100-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-italic.woff2",
+ "100-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-normal.woff2",
+ "200-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-italic.woff2",
+ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",
+ "300-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-italic.woff2",
+ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",
+ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",
+ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
+ "500-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-italic.woff2",
+ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",
+ "600-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-italic.woff2",
+ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",
+ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",
+ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",
+ "800-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-italic.woff2",
+ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2",
+ "900-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-italic.woff2",
+ "900-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-normal.woff2"
+ },
+ "sri": "sha384-W4IwjlabTeW68fblL1nwDvUYgUVRfBlSg7TwKfQGYAuYTkO+zEQFxqxcc6hCAyp4"
+ },
+ {
+ "id": "raleway",
+ "family": "Raleway",
+ "designer": "Matt McInerney, Pablo Impallari, Rodrigo Fuenzalida",
+ "category": "sans-serif",
+ "weights": [
+ 100,
+ 200,
+ 300,
+ 400,
+ 500,
+ 600,
+ 700,
+ 800,
+ 900
+ ],
+ "styles": [
+ "italic",
+ "normal"
+ ],
+ "subsets": [
+ "cyrillic",
+ "cyrillic-ext",
+ "latin",
+ "latin-ext",
+ "vietnamese"
+ ],
+ "license": "OFL-1.1",
+ "version": "v37",
+ "source": "https://github.com/google/fonts",
+ "license_url": "https://github.com/google/fonts/tree/main/ofl/raleway",
+ "files": {
+ "100-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-italic.woff2",
+ "100-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-normal.woff2",
+ "200-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-italic.woff2",
+ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",
+ "300-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-italic.woff2",
+ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",
+ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",
+ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
+ "500-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-italic.woff2",
+ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",
+ "600-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-italic.woff2",
+ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",
+ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",
+ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",
+ "800-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-italic.woff2",
+ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2",
+ "900-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-italic.woff2",
+ "900-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-normal.woff2"
+ },
+ "sri": "sha384-3TJ5i8gimHsS/euAR5EX6GeSQlDDV1iYtoHAzCtoza0M5y4Jy7FELGLUQiFFbgbd"
+ },
+ {
+ "id": "roboto",
+ "family": "Roboto",
+ "designer": "Christian Robertson, ParaType, Font Bureau",
+ "category": "sans-serif",
+ "weights": [
+ 100,
+ 200,
+ 300,
+ 400,
+ 500,
+ 600,
+ 700,
+ 800,
+ 900
+ ],
+ "styles": [
+ "italic",
+ "normal"
+ ],
+ "subsets": [
+ "cyrillic",
+ "cyrillic-ext",
+ "greek",
+ "greek-ext",
+ "latin",
+ "latin-ext",
+ "math",
+ "symbols",
+ "vietnamese"
+ ],
+ "license": "OFL-1.1",
+ "version": "v51",
+ "source": "https://github.com/google/fonts",
+ "license_url": "https://github.com/google/fonts/tree/main/ofl/roboto",
+ "files": {
+ "100-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-italic.woff2",
+ "100-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-normal.woff2",
+ "200-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-italic.woff2",
+ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",
+ "300-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-italic.woff2",
+ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",
+ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",
+ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
+ "500-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-italic.woff2",
+ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",
+ "600-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-italic.woff2",
+ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",
+ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",
+ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",
+ "800-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-italic.woff2",
+ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2",
+ "900-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-italic.woff2",
+ "900-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-normal.woff2"
+ },
+ "sri": "sha384-cL8xRHWIL6DOapsGZvH0PmgY8FHJfx1/oZFwZsJdZtGrbgWNFiUSs6P2RQldUJuZ"
+ },
+ {
+ "id": "roboto-slab",
+ "family": "Roboto Slab",
+ "designer": "Christian Robertson",
+ "category": "serif",
+ "weights": [
+ 100,
+ 200,
+ 300,
+ 400,
+ 500,
+ 600,
+ 700,
+ 800,
+ 900
+ ],
+ "styles": [
+ "normal"
+ ],
+ "subsets": [
+ "cyrillic",
+ "cyrillic-ext",
+ "greek",
+ "greek-ext",
+ "latin",
+ "latin-ext",
+ "vietnamese"
+ ],
+ "license": "Apache-2.0",
+ "version": "v36",
+ "source": "https://github.com/google/fonts",
+ "license_url": "https://github.com/google/fonts/tree/main/apache/robotoslab",
+ "files": {
+ "100-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-normal.woff2",
+ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",
+ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",
+ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
+ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",
+ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",
+ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",
+ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2",
+ "900-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-normal.woff2"
+ },
+ "sri": "sha384-8jbBGEUvybflDyOzrH9bExjVKMnrgI5Pno4lP5PVxiesK+IBAmy3bUnJqitpZlBu"
+ },
+ {
+ "id": "rubik",
+ "family": "Rubik",
+ "designer": "Hubert and Fischer, Meir Sadan, Cyreal, Daniel Grumer, Omaima Dajani",
+ "category": "sans-serif",
+ "weights": [
+ 300,
+ 400,
+ 500,
+ 600,
+ 700,
+ 800,
+ 900
+ ],
+ "styles": [
+ "italic",
+ "normal"
+ ],
+ "subsets": [
+ "arabic",
+ "cyrillic",
+ "cyrillic-ext",
+ "hebrew",
+ "latin",
+ "latin-ext"
+ ],
+ "license": "OFL-1.1",
+ "version": "v31",
+ "source": "https://github.com/google/fonts",
+ "license_url": "https://github.com/google/fonts/tree/main/ofl/rubik",
+ "files": {
+ "300-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-italic.woff2",
+ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",
+ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",
+ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
+ "500-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-italic.woff2",
+ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",
+ "600-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-italic.woff2",
+ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",
+ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",
+ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",
+ "800-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-italic.woff2",
+ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2",
+ "900-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-italic.woff2",
+ "900-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-normal.woff2"
+ },
+ "sri": "sha384-h6cxownIAO3b8G3yOMfioFISos4smxsXsfM2SuVeBOt+EWzON5L8IrtaLvpmwcvk"
+ },
+ {
+ "id": "source-sans-3",
+ "family": "Source Sans 3",
+ "designer": "Paul D. Hunt",
+ "category": "sans-serif",
+ "weights": [
+ 200,
+ 300,
+ 400,
+ 500,
+ 600,
+ 700,
+ 800,
+ 900
+ ],
+ "styles": [
+ "italic",
+ "normal"
+ ],
+ "subsets": [
+ "cyrillic",
+ "cyrillic-ext",
+ "greek",
+ "greek-ext",
+ "latin",
+ "latin-ext",
+ "vietnamese"
+ ],
+ "license": "OFL-1.1",
+ "version": "v20",
+ "source": "https://github.com/google/fonts",
+ "license_url": "https://github.com/google/fonts/tree/main/ofl/sourcesans3",
+ "files": {
+ "200-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-italic.woff2",
+ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",
+ "300-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-italic.woff2",
+ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",
+ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",
+ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
+ "500-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-italic.woff2",
+ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",
+ "600-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-italic.woff2",
+ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",
+ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",
+ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",
+ "800-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-italic.woff2",
+ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2",
+ "900-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-italic.woff2",
+ "900-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-normal.woff2"
+ },
+ "sri": "sha384-YbPa4/38CWQVCsfXlRcOJywm27n3RERUyzIuomA7HNoa7hcXwEy5uL9nHMyXj8Hb"
+ },
+ {
+ "id": "source-serif-4",
+ "family": "Source Serif 4",
+ "designer": "Frank Grießhammer",
+ "category": "serif",
+ "weights": [
+ 200,
+ 300,
+ 400,
+ 500,
+ 600,
+ 700,
+ 800,
+ 900
+ ],
+ "styles": [
+ "italic",
+ "normal"
+ ],
+ "subsets": [
+ "cyrillic",
+ "cyrillic-ext",
+ "greek",
+ "latin",
+ "latin-ext",
+ "vietnamese"
+ ],
+ "license": "OFL-1.1",
+ "version": "v15",
+ "source": "https://github.com/google/fonts",
+ "license_url": "https://github.com/google/fonts/tree/main/ofl/sourceserif4",
+ "files": {
+ "200-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-italic.woff2",
+ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",
+ "300-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-italic.woff2",
+ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",
+ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",
+ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
+ "500-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-italic.woff2",
+ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",
+ "600-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-italic.woff2",
+ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",
+ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",
+ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",
+ "800-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-italic.woff2",
+ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2",
+ "900-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-italic.woff2",
+ "900-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-normal.woff2"
+ },
+ "sri": "sha384-QH8kQ1dp35FXXdgaYtl6lRIMtjT62fMRls72L4GH5Apv6brOezk4QZWWj/Sam95h"
+ },
+ {
+ "id": "space-grotesk",
+ "family": "Space Grotesk",
+ "designer": "Florian Karsten",
+ "category": "sans-serif",
+ "weights": [
+ 300,
+ 400,
+ 500,
+ 600,
+ 700
+ ],
+ "styles": [
+ "normal"
+ ],
+ "subsets": [
+ "latin",
+ "latin-ext",
+ "vietnamese"
+ ],
+ "license": "OFL-1.1",
+ "version": "v22",
+ "source": "https://github.com/google/fonts",
+ "license_url": "https://github.com/google/fonts/tree/main/ofl/spacegrotesk",
+ "files": {
+ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",
+ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
+ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",
+ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",
+ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2"
+ },
+ "sri": "sha384-LN9HSpXtfJY+J/HQtn1ux4LTp3GzWE5bXPvrP3x2ydU9Fj4Ie3RJ8RUqZX8c834d"
+ },
+ {
+ "id": "ubuntu",
+ "family": "Ubuntu",
+ "designer": "Dalton Maag",
+ "category": "sans-serif",
+ "weights": [
+ 300,
+ 400,
+ 500,
+ 700
+ ],
+ "styles": [
+ "italic",
+ "normal"
+ ],
+ "subsets": [
+ "cyrillic",
+ "cyrillic-ext",
+ "greek",
+ "greek-ext",
+ "latin",
+ "latin-ext"
+ ],
+ "license": "UFL-1.0",
+ "version": "v21",
+ "source": "https://github.com/google/fonts",
+ "license_url": "https://github.com/google/fonts/tree/main/ufl/ubuntu",
+ "files": {
+ "300-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-italic.woff2",
+ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",
+ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",
+ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
+ "500-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-italic.woff2",
+ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",
+ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",
+ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2"
+ },
+ "sri": "sha384-jGsKWVoTkCuJGW0HOJgSCbQQtkTaHgnKbzKERY4HBWzy6WWCGvTYfvkugA578CAT"
+ },
+ {
+ "id": "work-sans",
+ "family": "Work Sans",
+ "designer": "Wei Huang",
+ "category": "sans-serif",
+ "weights": [
+ 100,
+ 200,
+ 300,
+ 400,
+ 500,
+ 600,
+ 700,
+ 800,
+ 900
+ ],
+ "styles": [
+ "italic",
+ "normal"
+ ],
+ "subsets": [
+ "latin",
+ "latin-ext",
+ "vietnamese"
+ ],
+ "license": "OFL-1.1",
+ "version": "v24",
+ "source": "https://github.com/google/fonts",
+ "license_url": "https://github.com/google/fonts/tree/main/ofl/worksans",
+ "files": {
+ "100-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-italic.woff2",
+ "100-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-normal.woff2",
+ "200-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-italic.woff2",
+ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",
+ "300-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-italic.woff2",
+ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",
+ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",
+ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
+ "500-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-italic.woff2",
+ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",
+ "600-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-italic.woff2",
+ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",
+ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",
+ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",
+ "800-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-italic.woff2",
+ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2",
+ "900-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-italic.woff2",
+ "900-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-normal.woff2"
+ },
+ "sri": "sha384-x6cgTi/NG/0nnQyCVkB8w/JUcS7wSz8cG5AEGjk2qktSQfVtBz259hNqnzRKhRnV"
+ }
+]
@@ -40,6 +40,7 @@ function publicPages(themes = []) {
@@ -40,6 +40,7 @@ function publicPages(themes = []) {
{ path: '/native', changefreq: 'monthly', priority: 0.6, title: 'Apps for Android and iOS', text: 'How an OpenVibe page becomes a real Android or iOS screen: one set of tokens, components, patterns and icons, three size classes, what each platform does its own way, and the road from the web to apps.' },
{ path: '/native', changefreq: 'monthly', priority: 0.6, title: 'Apps for Android and iOS', text: 'How an OpenVibe page becomes a real Android or iOS screen: one set of tokens, components, patterns and icons, three size classes, what each platform does its own way, and the road from the web to apps.' },
{ path: '/motion', changefreq: 'monthly', priority: 0.6, title: 'Motion', text: 'How OpenVibe moves: the mark\'s motion and one shared kit of effects (glint, glow, lift, press, ripple, tilt, reveal, count, confetti), the timing every site shares, the rules and the code (openvibe-shared/ov-fx).' },
{ path: '/motion', changefreq: 'monthly', priority: 0.6, title: 'Motion', text: 'How OpenVibe moves: the mark\'s motion and one shared kit of effects (glint, glow, lift, press, ripple, tilt, reveal, count, confetti), the timing every site shares, the rules and the code (openvibe-shared/ov-fx).' },
{ path: '/icons', changefreq: 'weekly', priority: 0.7, title: 'The OpenVibe glyphs', text: 'Every OpenVibe icon in one hand, the mark\'s: search them, copy or download their SVG, use them in place of Font Awesome, and draw new ones with OpenVibe.AI.' },
{ path: '/icons', changefreq: 'weekly', priority: 0.7, title: 'The OpenVibe glyphs', text: 'Every OpenVibe icon in one hand, the mark\'s: search them, copy or download their SVG, use them in place of Font Awesome, and draw new ones with OpenVibe.AI.' },
+ { path: '/fonts', changefreq: 'weekly', priority: 0.7, title: 'Open font library', text: 'Browse open licence font families by category, script and weight; preview every style and find its source and licence.' },
{ path: '/icons/font-awesome-alternative', changefreq: 'monthly', priority: 0.6, title: 'A free Font Awesome alternative', text: 'Keep your Font Awesome markup and show the OpenVibe glyphs: one script, the same classes, animations and transforms, plus free web and desktop fonts.' },
{ path: '/icons/font-awesome-alternative', changefreq: 'monthly', priority: 0.6, title: 'A free Font Awesome alternative', text: 'Keep your Font Awesome markup and show the OpenVibe glyphs: one script, the same classes, animations and transforms, plus free web and desktop fonts.' },
{ path: '/icons/migrate', changefreq: 'monthly', priority: 0.5, title: 'Switch from Font Awesome, Lucide or Material', text: 'Paste your HTML or code and see every Font Awesome, Lucide, Material or MUI icon it uses with its free OpenVibe twin.' },
{ path: '/icons/migrate', changefreq: 'monthly', priority: 0.5, title: 'Switch from Font Awesome, Lucide or Material', text: 'Paste your HTML or code and see every Font Awesome, Lucide, Material or MUI icon it uses with its free OpenVibe twin.' },
{ path: '/arena', changefreq: 'daily', priority: 0.9, title: 'The Design Arena', text: 'One prompt, three AI designers, blind votes and an Elo leaderboard per category, with cost and time per generation.' },
{ path: '/arena', changefreq: 'daily', priority: 0.9, title: 'The Design Arena', text: 'One prompt, three AI designers, blind votes and an Elo leaderboard per category, with cost and time per generation.' },
@@ -90,6 +91,7 @@ function createDiscoveryRoutes(ctx) {
@@ -90,6 +91,7 @@ function createDiscoveryRoutes(ctx) {
{ title: 'The studio', url: abs('/studio'), note: 'try, edit, use, submit and roll back a theme' },
{ title: 'The studio', url: abs('/studio'), note: 'try, edit, use, submit and roll back a theme' },
{ title: 'Tokens', url: abs('/tokens'), note: 'any theme as CSS, Tailwind, Compose (Android), SwiftUI (iOS), Flutter, Figma or DTCG JSON; the API is GET /api/v1/tokens/:theme?format=' },
{ title: 'Tokens', url: abs('/tokens'), note: 'any theme as CSS, Tailwind, Compose (Android), SwiftUI (iOS), Flutter, Figma or DTCG JSON; the API is GET /api/v1/tokens/:theme?format=' },
{ title: 'The OpenVibe glyphs', url: abs('/icons'), note: 'every icon in one hand, each with its own page (/icons/<name>): SVG, PNG at any size and colour (/icons/<name>.png?size=&color=&stroke=&pad=&ground=&bg=), the WOFF2/TTF/OTF fonts and the sprite (/icons/pack/<file>); style=auto|line|solid (auto: the solid drawing at 18 px and under); /icons/<name>.mjs (ES module), .xml (Android VectorDrawable), /icons/<name>/favicon.zip; the <ov-icon name=\"…\"> web component at /ov-icon.js; the registry as data at GET /api/v1/glyphs (?names=a,fa-b,lucide:c,material:d for just those); POST /api/v1/glyphs/migrate {text} lists every Font Awesome, Lucide, Material or MUI icon in a page or code with its OpenVibe twin (/icons/migrate is the page). All open, no sign-in, CORS *', html: true },
{ title: 'The OpenVibe glyphs', url: abs('/icons'), note: 'every icon in one hand, each with its own page (/icons/<name>): SVG, PNG at any size and colour (/icons/<name>.png?size=&color=&stroke=&pad=&ground=&bg=), the WOFF2/TTF/OTF fonts and the sprite (/icons/pack/<file>); style=auto|line|solid (auto: the solid drawing at 18 px and under); /icons/<name>.mjs (ES module), .xml (Android VectorDrawable), /icons/<name>/favicon.zip; the <ov-icon name=\"…\"> web component at /ov-icon.js; the registry as data at GET /api/v1/glyphs (?names=a,fa-b,lucide:c,material:d for just those); POST /api/v1/glyphs/migrate {text} lists every Font Awesome, Lucide, Material or MUI icon in a page or code with its OpenVibe twin (/icons/migrate is the page). All open, no sign-in, CORS *', html: true },
+ { title: 'Open font library', url: abs('/fonts'), note: 'Open licence typefaces with live previews, every style, licence, source, CSS and download. Search and filters at /fonts; JSON at GET /api/v1/fonts and /api/v1/fonts/<family>; MCP tools list and get fonts.' },
{ title: 'A free Font Awesome alternative', url: abs('/icons/font-awesome-alternative'), note: 'one script keeps Font Awesome markup, classes, animations and data-fa-transform, and shows the OpenVibe glyphs', html: true },
{ title: 'A free Font Awesome alternative', url: abs('/icons/font-awesome-alternative'), note: 'one script keeps Font Awesome markup, classes, animations and data-fa-transform, and shows the OpenVibe glyphs', html: true },
{ title: 'Loading', url: abs('/loading'), note: 'how OpenVibe waits: placeholders shaped like the content (kit.skeleton: card, row, tile, chip, line, chat, stat), the mark at work instead of a spinner (OpenVibeLoading), the navbar while a page moves (OpenVibeNavbar.busy), and the rules' },
{ title: 'Loading', url: abs('/loading'), note: 'how OpenVibe waits: placeholders shaped like the content (kit.skeleton: card, row, tile, chip, line, chat, stat), the mark at work instead of a spinner (OpenVibeLoading), the navbar while a page moves (OpenVibeNavbar.busy), and the rules' },
{ title: 'Forms', url: abs('/forms'), note: 'OpenVibe’s own select (a menu on a computer, a sheet on a phone; options with state, badge and a setup link), switches, fields and choice cards: ov-forms.js and ov-forms.css' },
{ title: 'Forms', url: abs('/forms'), note: 'OpenVibe’s own select (a menu on a computer, a sheet on a phone; options with state, badge and a setup link), switches, fields and choice cards: ov-forms.js and ov-forms.css' },
@@ -101,6 +103,7 @@ function createDiscoveryRoutes(ctx) {
@@ -101,6 +103,7 @@ function createDiscoveryRoutes(ctx) {
// The data an agent came for is a main section, not something to skip.
// The data an agent came for is a main section, not something to skip.
{ title: 'Machine-readable', links: [
{ title: 'Machine-readable', links: [
{ title: 'The theme catalogue (JSON)', url: abs('/api/themes'), note: 'public, cached for 5 minutes' },
{ title: 'The theme catalogue (JSON)', url: abs('/api/themes'), note: 'public, cached for 5 minutes' },
+ { title: 'Font catalogue (JSON)', url: abs('/api/v1/fonts'), note: 'open licence families, cached for 5 minutes' },
{ title: 'The Arena dataset (JSONL)', url: abs('/api/v1/arena/dataset.jsonl'), note: 'prompts, outputs and votes, no voters' },
{ title: 'The Arena dataset (JSONL)', url: abs('/api/v1/arena/dataset.jsonl'), note: 'prompts, outputs and votes, no voters' },
{ title: 'Agent manifest', url: abs('/.well-known/agent.json') },
{ title: 'Agent manifest', url: abs('/.well-known/agent.json') },
] },
] },
new file mode 100644
new file mode 100644
@@ -0,0 +1,29 @@
@@ -0,0 +1,29 @@
+'use strict';
+
+// GET /api/v1/fonts?q=&category=&script=&weight= — open licence font families.
+// GET /api/v1/fonts/:family — metadata, CDN file URLs and three rule based pairings.
+const { asyncRouter } = require('./router');
+const views = require('../views/fonts');
+
+function createFontRoutes({ fonts }) {
+ const r = asyncRouter();
+ const filters = req => ({ q: String(req.query.q || '').slice(0, 100), category: String(req.query.category || '').slice(0, 40),
+ script: String(req.query.script || '').slice(0, 40), weight: String(req.query.weight || '').slice(0, 4) });
+ r.get('/api/v1/fonts', async (req, res) => {
+ const list = await fonts.list(filters(req));
+ res.set('Access-Control-Allow-Origin', '*').set('Cache-Control', 'public, max-age=300').json({ fonts: list, count: list.length });
+ });
+ r.get('/api/v1/fonts/:family', async (req, res) => {
+ const font = await fonts.get(req.params.family);
+ res.set('Access-Control-Allow-Origin', '*').set('Cache-Control', 'public, max-age=300');
+ return font ? res.json(font) : res.status(404).json({ error: 'Font family not found' });
+ });
+ r.get('/fonts', async (req, res) => res.set('Cache-Control', 'public, max-age=120').send(views.index(await fonts.list(), filters(req))));
+ r.get('/fonts/:family', async (req, res) => {
+ const font = await fonts.get(req.params.family);
+ return font ? res.set('Cache-Control', 'public, max-age=120').send(views.detail(font)) : res.status(404).send('Font family not found');
+ });
+ return r;
+}
+
+module.exports = { createFontRoutes };
@@ -53,6 +53,13 @@ const TOOLS = [
@@ -53,6 +53,13 @@ const TOOLS = [
{ name: 'openvibe_design_icon_migrate', description: 'Read HTML or code and list every Font Awesome, Lucide, Material or MUI icon it uses, with its OpenVibe twin and how often it appears. Read only.', inputSchema: schema(['text'], {
{ name: 'openvibe_design_icon_migrate', description: 'Read HTML or code and list every Font Awesome, Lucide, Material or MUI icon it uses, with its OpenVibe twin and how often it appears. Read only.', inputSchema: schema(['text'], {
text: { type: 'string', minLength: 1, maxLength: 400000 },
text: { type: 'string', minLength: 1, maxLength: 400000 },
}) },
}) },
+ { name: 'openvibe_design_list_fonts', description: 'List open licence font families with category, scripts, weights, licence and source.', inputSchema: schema([], {
+ query: { type: 'string', maxLength: 100 }, category: { type: 'string', maxLength: 40 },
+ script: { type: 'string', maxLength: 40 }, weight: { type: 'integer', minimum: 100, maximum: 900 },
+ }) },
+ { name: 'openvibe_design_get_font', description: 'Get one font family with every style, CSS files, licence and three suggested pairings.', inputSchema: schema(['family'], {
+ family: { type: 'string', minLength: 1, maxLength: 100 },
+ }) },
].map(tool => ({ ...tool, annotations: { readOnlyHint: true, openWorldHint: false } }));
].map(tool => ({ ...tool, annotations: { readOnlyHint: true, openWorldHint: false } }));
const rpc = (id, result) => ({ jsonrpc: '2.0', id, result });
const rpc = (id, result) => ({ jsonrpc: '2.0', id, result });
@@ -66,7 +73,7 @@ function toolResult(value) {
@@ -66,7 +73,7 @@ function toolResult(value) {
};
};
}
}
-async function callTool(name, args, { catalog }) {
+async function callTool(name, args, { catalog, fonts }) {
if (!object(args)) return toolError('Arguments must be an object.');
if (!object(args)) return toolError('Arguments must be an object.');
switch (name) {
switch (name) {
case 'openvibe_design_list_themes': {
case 'openvibe_design_list_themes': {
@@ -130,6 +137,17 @@ async function callTool(name, args, { catalog }) {
@@ -130,6 +137,17 @@ async function callTool(name, args, { catalog }) {
case 'openvibe_design_icon_migrate':
case 'openvibe_design_icon_migrate':
if (typeof args.text !== 'string' || !args.text || args.text.length > 400000) return toolError('text must be a nonempty string of at most 400,000 characters.');
if (typeof args.text !== 'string' || !args.text || args.text.length > 400000) return toolError('text must be a nonempty string of at most 400,000 characters.');
return toolResult({ icons: aliases.scan(args.text), page: `${BASE}/icons/migrate` });
return toolResult({ icons: aliases.scan(args.text), page: `${BASE}/icons/migrate` });
+ case 'openvibe_design_list_fonts': {
+ if (typeof args.query !== 'undefined' && (typeof args.query !== 'string' || args.query.length > 100)) return toolError('query must be at most 100 characters.');
+ if (args.weight !== undefined && (!Number.isInteger(args.weight) || args.weight < 100 || args.weight > 900)) return toolError('weight must be 100 to 900.');
+ const list = await fonts.list({ q: args.query, category: args.category, script: args.script, weight: args.weight });
+ return toolResult({ fonts: list, count: list.length });
+ }
+ case 'openvibe_design_get_font': {
+ if (typeof args.family !== 'string' || !/^[a-z0-9-]{1,100}$/.test(args.family)) return toolError('family must be a font slug.');
+ const font = await fonts.get(args.family);
+ return font ? toolResult(font) : toolError('No font family by that slug.');
+ }
default:
default:
return null;
return null;
}
}
@@ -22,6 +22,7 @@ function createLifecycle({ server, ctx, exit, signals } = {}) {
@@ -22,6 +22,7 @@ function createLifecycle({ server, ctx, exit, signals } = {}) {
deadlineMs: 10_000,
deadlineMs: 10_000,
close: [
close: [
() => ctx.jobs.stop(),
() => ctx.jobs.stop(),
+ () => ctx.fonts.stop(),
() => ctx.searchIndex.stop(),
() => ctx.searchIndex.stop(),
() => ctx.keys.client.stop(),
() => ctx.keys.client.stop(),
() => ctx.s.close(),
() => ctx.s.close(),
@@ -41,6 +42,7 @@ async function start() {
@@ -41,6 +42,7 @@ async function start() {
ctx.keys.ensure().catch(() => {});
ctx.keys.ensure().catch(() => {});
// Generate queued Arena rounds. DESIGN_DRILL=1 keeps the loop off (a test or a drill drives tick()).
// Generate queued Arena rounds. DESIGN_DRILL=1 keeps the loop off (a test or a drill drives tick()).
ctx.jobs.start();
ctx.jobs.start();
+ if (!config.drill) ctx.fonts.start();
// The icon and theme pages in OpenVibe.Search: a sweep a minute after boot and every six hours.
// The icon and theme pages in OpenVibe.Search: a sweep a minute after boot and every six hours.
if (!config.drill) ctx.searchIndex.start();
if (!config.drill) ctx.searchIndex.start();
ctx.designs.resume().catch((err) => console.warn('[OpenVibe.Design] resume designs:', err.message));
ctx.designs.resume().catch((err) => console.warn('[OpenVibe.Design] resume designs:', err.message));
@@ -33,6 +33,7 @@ const NAV = [
@@ -33,6 +33,7 @@ const NAV = [
{ label: 'Loading', href: '/loading', icon: 'fa-hourglass-half', more: true },
{ label: 'Loading', href: '/loading', icon: 'fa-hourglass-half', more: true },
{ label: 'Forms', href: '/forms', icon: 'fa-list-check', more: true },
{ label: 'Forms', href: '/forms', icon: 'fa-list-check', more: true },
{ label: 'Icons', href: '/icons', icon: 'fa-shapes', more: true },
{ label: 'Icons', href: '/icons', icon: 'fa-shapes', more: true },
+ { label: 'Fonts', href: '/fonts', icon: 'fa-font', more: true },
{ label: 'Patterns', href: '/patterns', icon: 'fa-shapes', more: true },
{ label: 'Patterns', href: '/patterns', icon: 'fa-shapes', more: true },
{ label: 'Kit', href: '/kit', icon: 'fa-cubes', more: true },
{ label: 'Kit', href: '/kit', icon: 'fa-cubes', more: true },
{ label: 'Components', href: '/components', icon: 'fa-puzzle-piece', more: true },
{ label: 'Components', href: '/components', icon: 'fa-puzzle-piece', more: true },
@@ -49,7 +50,7 @@ const baseUrl = () => BASE;
@@ -49,7 +50,7 @@ const baseUrl = () => BASE;
const canonical = (p) => `${BASE}${p || '/'}`;
const canonical = (p) => `${BASE}${p || '/'}`;
/** The page around a body: the shared shell with the room's stylesheets and behaviour. */
/** The page around a body: the shared shell with the room's stylesheets and behaviour. */
-function layout({ title, description, canonical, path, robots = 'index, follow', body, active = '/', scripts = [], styles = [], jsonLd = [], inlineStyles = [], image, imageAlt, shared = [], sharedStyles = [] } = {}) {
+function layout({ title, description, canonical, path, robots = 'index, follow', body, active = '/', scripts = [], styles = [], jsonLd = [], inlineStyles = [], preloads = [], image, imageAlt, shared = [], sharedStyles = [] } = {}) {
const pathNow = path || active || '/';
const pathNow = path || active || '/';
canonical = canonical || (path ? `${BASE}${path}` : undefined);
canonical = canonical || (path ? `${BASE}${path}` : undefined);
const navbar = {
const navbar = {
@@ -87,6 +88,7 @@ function layout({ title, description, canonical, path, robots = 'index, follow',
@@ -87,6 +88,7 @@ function layout({ title, description, canonical, path, robots = 'index, follow',
${(sharedStyles || []).map((file) => `<link rel="stylesheet" href="${esc(ovServe.url(file))}">`).join('\n')}
${(sharedStyles || []).map((file) => `<link rel="stylesheet" href="${esc(ovServe.url(file))}">`).join('\n')}
${(styles || []).map((rel) => `<link rel="stylesheet" href="${esc(asset(rel))}">`).join('\n')}
${(styles || []).map((rel) => `<link rel="stylesheet" href="${esc(asset(rel))}">`).join('\n')}
${(inlineStyles || []).map((css) => `<style>${css}</style>`).join('\n')}
${(inlineStyles || []).map((css) => `<style>${css}</style>`).join('\n')}
+${preloads.map(p => `<link rel="preload" as="font" type="font/woff2" href="${esc(p.url)}" crossorigin integrity="${esc(p.sri)}">`).join('\n')}
<script src="${esc(ovServe.url('kit-web.js'))}" defer></script>
<script src="${esc(ovServe.url('kit-web.js'))}" defer></script>
<script src="${esc(ovServe.url('motion.js'))}" defer></script>
<script src="${esc(ovServe.url('motion.js'))}" defer></script>
${(shared || []).map((file) => `<script src="${esc(ovServe.url(file))}" defer></script>`).join('\n')}
${(shared || []).map((file) => `<script src="${esc(ovServe.url(file))}" defer></script>`).join('\n')}
new file mode 100644
new file mode 100644
@@ -0,0 +1,60 @@
@@ -0,0 +1,60 @@
+'use strict';
+
+const { layout, esc } = require('../render/layout');
+
+const previewFile = f => f.files['400-normal'] || Object.values(f.files)[0];
+const face = (f, weight = 400, style = 'normal') => {
+ const url = f.files[`${weight}-${style}`];
+ return url ? `@font-face{font-family:${JSON.stringify(f.family)};src:url(${JSON.stringify(url)}) format("woff2");font-weight:${weight};font-style:${style};font-display:swap}` : '';
+};
+
+function index(fonts, filters) {
+ const categories = ['sans-serif', 'serif', 'display', 'handwriting', 'monospace'];
+ const scripts = [...new Set(fonts.flatMap(f => f.subsets))].sort();
+ const shown = fonts.filter(f => (!filters.q || `${f.family} ${f.designer}`.toLowerCase().includes(filters.q.toLowerCase()))
+ && (!filters.category || f.category === filters.category)
+ && (!filters.script || f.subsets.includes(filters.script))
+ && (!filters.weight || f.weights.includes(Number(filters.weight))));
+ const option = (values, selected, label) => `<label>${label}<select name="${label.toLowerCase()}"><option value="">All ${label.toLowerCase()}s</option>${values.map(v => `<option value="${esc(v)}"${v === selected ? ' selected' : ''}>${esc(String(v))}</option>`).join('')}</select></label>`;
+ const cards = shown.map(f => `<article class="fo-card"><a class="fo-card-link" href="/fonts/${encodeURIComponent(f.id)}">
+<h2 style="font-family:${esc(JSON.stringify(f.family))}">${esc(f.family)}</h2>
+<p class="fo-sample" style="font-family:${esc(JSON.stringify(f.family))}">${esc('The quick brown fox jumps over the lazy dog.')}</p>
+<p class="fo-meta"><span>${esc(f.category)}</span> · ${f.weights.length} ${f.weights.length === 1 ? 'weight' : 'weights'} · ${esc(f.license)}</p></a></article>`).join('');
+ return layout({ title: 'Open font library', description: 'Browse open licence typefaces by category, script and weight.',
+ path: '/fonts', active: '/fonts', styles: ['css/fonts.css'], scripts: ['js/fonts.js'],
+ inlineStyles: [shown.map(f => face(f, f.files['400-normal'] ? 400 : f.weights[0], 'normal')).join('\n')],
+ body: `<div class="fo"><header class="fo-hero"><p class="fo-eyebrow">The font library</p><h1>Find a face for your idea.</h1><p>Open licence typefaces with their source and licence beside them.</p></header>
+<nav class="fo-chips" aria-label="Category"><a href="/fonts${filters.q ? `?q=${encodeURIComponent(filters.q)}` : ''}"${!filters.category ? ' aria-current="page"' : ''}>All <span>${fonts.length}</span></a>${categories.filter(c => fonts.some(f => f.category === c)).map(c => `<a href="/fonts?category=${encodeURIComponent(c)}${filters.q ? `&q=${encodeURIComponent(filters.q)}` : ''}"${filters.category === c ? ' aria-current="page"' : ''}>${esc(c)} <span>${fonts.filter(f => f.category === c).length}</span></a>`).join('')}</nav>
+<form class="fo-filters" method="get" action="/fonts">${filters.category ? `<input type="hidden" name="category" value="${esc(filters.category)}">` : ''}<label class="fo-q">Search<input type="search" name="q" value="${esc(filters.q || '')}" placeholder="Family or designer"></label>
+${option(scripts, filters.script, 'Script')}${option([100,200,300,400,500,600,700,800,900], Number(filters.weight) || '', 'Weight')}
+<button type="submit">Filter</button></form>
+<label class="fo-try">Try your own words<input type="text" data-font-sample maxlength="140" placeholder="The quick brown fox jumps over the lazy dog."></label>
+<p class="fo-count">${shown.length} of ${fonts.length} families</p><div class="fo-grid">${cards || '<p>No families match these filters.</p>'}</div></div>` });
+}
+
+function detail(f) {
+ const style = ([key, url]) => {
+ const [weight, kind] = key.split('-');
+ return `<div class="fo-style"><div><b>${esc(weight)}${kind === 'italic' ? ' italic' : ''}</b><a href="${esc(url)}" download aria-label="Download ${esc(f.family)} ${esc(weight)} ${esc(kind)} (WOFF2)">WOFF2</a></div><p style="font-family:${esc(JSON.stringify(f.family))};font-weight:${weight};font-style:${kind}">The quick brown fox jumps over the lazy dog.</p></div>`;
+ };
+ // Upright first, lightest to heaviest, then the italics: the order a person reads a family in.
+ const entries = Object.entries(f.files).sort(([a], [b]) => { const [wa, sa] = a.split('-'), [wb, sb] = b.split('-'); return (sa === sb ? 0 : sa === 'normal' ? -1 : 1) || Number(wa) - Number(wb); });
+ const firstStyles = entries.slice(0, 6), moreStyles = entries.slice(6);
+ const styles = `${firstStyles.map(style).join('')}`;
+ const stylesMore = moreStyles.length ? `<details class="fo-more"><summary>More styles <span>(${moreStyles.length})</span></summary><div class="fo-styles">${moreStyles.map(style).join('')}</div></details>` : '';
+ const url = previewFile(f);
+ const css = `@font-face {\n font-family: ${JSON.stringify(f.family)};\n src: url("${url}") format("woff2");\n font-weight: 400;\n font-style: normal;\n font-display: swap;\n}`;
+ return layout({ title: f.family, description: `${f.family} by ${f.designer}: ${f.license} font, styles, CSS and download.`,
+ path: `/fonts/${f.id}`, active: '/fonts', styles: ['css/fonts.css'], scripts: ['js/fonts.js'],
+ preloads: f.sri ? [{ url, sri: f.sri }] : [],
+ inlineStyles: [Object.keys(f.files).map(key => { const [w, s] = key.split('-'); return face(f, Number(w), s); }).join('\n')
+ + f.pairings.filter(p => p.file).map(p => `@font-face{font-family:${JSON.stringify(p.family)};src:url(${JSON.stringify(p.file)}) format("woff2");font-weight:${Number(p.weight) || 700};font-style:normal;font-display:swap}`).join('\n')],
+ body: `<div class="fo fo-detail"><a class="fo-back" href="/fonts">← All fonts</a><header class="fo-hero"><p class="fo-eyebrow">${esc(f.category)} · ${esc(f.version || '')}</p><h1 style="font-family:${esc(JSON.stringify(f.family))}">${esc(f.family)}</h1><p>Designed by ${esc(f.designer)}</p></header>
+<label class="fo-try">Try your own words<input type="text" data-font-sample maxlength="140" placeholder="The quick brown fox jumps over the lazy dog."></label>
+<section><h2>Every style <span class="fo-n">${entries.length}</span></h2><div class="fo-styles">${styles}</div>${stylesMore}</section>
+<section class="fo-details"><div><h2>Use this font</h2><pre><code>${esc(css)}</code></pre><p><a href="${esc(url)}" download>Download the regular WOFF2</a>${f.sri ? `<span class="fo-integrity">SHA-384 integrity: <code>${esc(f.sri)}</code></span>` : ''}</p></div>
+<div><h2>Licence and source</h2><p>${esc(f.license)} · <a href="${esc(f.license_url)}" rel="noopener">Licence and source files</a></p><p><a href="${esc(f.source)}" rel="noopener">${esc(f.source)}</a></p><p>Scripts: ${f.subsets.map(esc).join(', ')}</p><p>Weights: ${f.weights.join(', ')} · Styles: ${f.styles.map(esc).join(', ')}</p></div></section>
+<section><h2>Suggested pairings</h2><p class="fo-note">A heading in the other face over ${esc(f.family)} for the text, chosen by contrast of form, x-height and era. Suggestions, not rules.</p><div class="fo-pairings">${f.pairings.map(p => `<article><p class="fo-pair-h" style="font-family:${esc(JSON.stringify(p.family))};font-weight:${Number(p.weight) || 700}">A heading in ${esc(p.family)}</p><p class="fo-pair-b" style="font-family:${esc(JSON.stringify(f.family))}">And the text under it in ${esc(f.family)}, the way a reader meets them together on a page.</p><p class="fo-pair-why"><a href="/fonts/${encodeURIComponent(p.id)}">${esc(p.family)}</a> · ${esc(p.reason)}</p></article>`).join('')}</div></section></div>` });
+}
+
+module.exports = { index, detail };
new file mode 100644
new file mode 100644
@@ -0,0 +1,87 @@
@@ -0,0 +1,87 @@
+'use strict';
+
+const assert = require('node:assert/strict');
+const { boot, check, done } = require('./helpers/boot');
+const { testDb } = require('./helpers/db');
+const { createFontCatalog, validFont, due, WEEK } = require('../server/fonts/catalog');
+const seed = require('../server/fonts/seed.json');
+
+(async () => {
+ const t = await boot();
+ try {
+ await check('the seed contains only licensed families with source and downloadable files', () => {
+ assert.equal(seed.length, 38);
+ assert.ok(seed.every(validFont));
+ assert.ok(seed.every(f => f.designer && f.version && f.source && f.sri));
+ assert.deepEqual(new Set(seed.map(f => f.license)), new Set(['OFL-1.1', 'Apache-2.0', 'UFL-1.0']));
+ });
+ await check('font pages show live samples, every style, CSS, licence, download and pairings', async () => {
+ const index = await t.get('/fonts');
+ assert.equal(index.status, 200);
+ assert.match(index.text, /Find a face for your idea/);
+ assert.match(index.text, /data-font-sample/);
+ assert.match(index.text, /fonts\/inter/);
+ const detail = await t.get('/fonts/inter');
+ assert.equal(detail.status, 200);
+ for (const text of ['Every style', 'OFL-1.1', 'Download the regular WOFF2', 'aria-label="Download Inter 400 normal (WOFF2)"', 'More styles <span>(', 'A heading in ', '@font-face', 'Suggested pairings', 'SHA-384 integrity', 'integrity="sha384-']) assert.ok(detail.text.includes(text), text);
+ assert.equal((await t.get('/fonts/no-such-family')).status, 404);
+ });
+ await check('the API filters by category, script and weight and rejects missing families', async () => {
+ const all = await t.get('/api/v1/fonts');
+ assert.equal(all.status, 200);
+ assert.equal(all.json().count, 38);
+ assert.ok(all.json().fonts.every(f => ['OFL-1.1', 'Apache-2.0', 'UFL-1.0'].includes(f.license)));
+ const arabic = (await t.get('/api/v1/fonts?script=arabic&weight=400')).json().fonts;
+ assert.ok(arabic.length && arabic.every(f => f.subsets.includes('arabic') && f.weights.includes(400)));
+ const serif = (await t.get('/api/v1/fonts?category=serif')).json().fonts;
+ assert.ok(serif.length && serif.every(f => f.category === 'serif'));
+ const inter = (await t.get('/api/v1/fonts/inter')).json();
+ assert.equal(inter.pairings.length, 3);
+ assert.ok(inter.pairings.every(p => p.reason && p.id !== 'inter'));
+ assert.equal((await t.get('/api/v1/fonts/no-such-family')).status, 404);
+ });
+ await check('MCP and llms.txt expose the catalogue', async () => {
+ const mcp = await t.get('/mcp', { json: { jsonrpc: '2.0', id: 1, method: 'tools/list' } });
+ assert.ok(mcp.json().result.tools.some(x => x.name === 'openvibe_design_get_font'));
+ assert.ok((await t.get('/llms.txt')).text.includes('/api/v1/fonts'));
+ });
+ } finally { await t.close(); }
+
+ await check('weekly refresh is database claimed and filters revoked licences', async () => {
+ const test = await testDb();
+ try {
+ let time = Date.UTC(2026, 10, 8);
+ let blocked = 'inter';
+ const fakeFetch = async url => {
+ if (url.startsWith('https://api.fontsource.org/')) {
+ const f = seed.find(x => url.endsWith(`/${x.id}`));
+ const variants = {};
+ for (const [key, link] of Object.entries(f.files)) {
+ const [weight, style] = key.split('-');
+ variants[weight] ||= {};
+ variants[weight][style] = { latin: { url: { woff2: link } } };
+ }
+ return { ok: true, json: async () => ({ ...f, license: f.id === blocked ? 'CC0-1.0' : f.license,
+ defSubset: 'latin', variants }) };
+ }
+ if (url.endsWith('/METADATA.pb')) return { ok: true, text: async () => 'designer: "Test Designer"\n' };
+ return { ok: true, arrayBuffer: async () => Buffer.from('font bytes') };
+ };
+ const options = { db: test.db, fetchImpl: fakeFetch, now: () => time };
+ const a = createFontCatalog(options), b = createFontCatalog(options);
+ await a.seedMissing();
+ assert.equal(due({ last_success_at: null, claimed_until: null }, time), true);
+ assert.equal((await Promise.all([a.tick(), b.tick()])).filter(Boolean).length, 1);
+ assert.equal((await a.list()).length, 37);
+ assert.equal(await a.get(blocked), null);
+ assert.equal(await a.tick(), false);
+ await a.seedMissing();
+ assert.equal(await a.get(blocked), null, 'restart seeding cannot restore a revoked licence');
+ time += WEEK + 1;
+ blocked = null;
+ assert.equal(await b.tick(), true);
+ assert.equal((await b.list()).length, 38);
+ } finally { await test.close(); }
+ });
+ done();
+})().catch(e => { console.error(e); process.exit(1); });
@@ -23,6 +23,7 @@ const components = require('openvibe-shared/components');
@@ -23,6 +23,7 @@ const components = require('openvibe-shared/components');
'openvibe_design_list_components', 'openvibe_design_get_component',
'openvibe_design_list_components', 'openvibe_design_get_component',
'openvibe_design_validate_kit_document',
'openvibe_design_validate_kit_document',
'openvibe_design_icon_search', 'openvibe_design_icon_svg', 'openvibe_design_icon_code', 'openvibe_design_icon_migrate',
'openvibe_design_icon_search', 'openvibe_design_icon_svg', 'openvibe_design_icon_code', 'openvibe_design_icon_migrate',
+ 'openvibe_design_list_fonts', 'openvibe_design_get_font',
]);
]);
assert.ok(listed.every(x => x.inputSchema && x.annotations.readOnlyHint));
assert.ok(listed.every(x => x.inputSchema && x.annotations.readOnlyHint));
assert.strictEqual((await t.get('/mcp', { method: 'OPTIONS' })).status, 204);
assert.strictEqual((await t.get('/mcp', { method: 'OPTIONS' })).status, 204);