File size: 6,107 Bytes
d9e058a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 | /* =========================================================================
* DSSL: fonts.js (0.12.0)
*
* fonts {
* Fraunces: 500 700 800 // from Google Fonts, these weights
* "Hanken Grotesk": 400 700 italic // with the italics too
* Inter: 100..900 display optional // a variable range, and how it swaps in
* Brand: from "/fonts/brand.woff2" 400 // your own file: becomes @font-face
* }
*
* Fonts used to need a raw { } block of plain CSS. A font is a stylesheet link
* (Google) or an @font-face (your file), and neither belongs in a rule, so this
* is its own block. At run time the engine adds the <link> to <head>; `dssl build`
* writes an @import at the top of the CSS.
*
* Google's servers see every visitor who loads a Google font. If that matters
* to you (it does under some privacy laws), self-host with `from`.
* ========================================================================= */
export const FONT_WORDS = {
weights: 'a weight (400), several (400 700), or a variable range (100..900)',
italic: 'also load the italic of each weight',
display: 'display <swap|block|fallback|optional>: how the text behaves while the font loads (default swap)',
from: 'from "/path/to/file.woff2": use your own file instead of Google Fonts',
};
const DISPLAYS = ['swap', 'block', 'fallback', 'optional', 'auto'];
const FORMATS = { woff2: 'woff2', woff: 'woff', ttf: 'truetype', otf: 'opentype' };
const words = (s) => {
const out = []; let i = 0;
while (i < s.length) {
if (/\s/.test(s[i])) { i++; continue; }
if (s[i] === '"' || s[i] === "'") { const q = s[i]; let j = i + 1; while (j < s.length && s[j] !== q) j++; out.push(s.slice(i, j + 1)); i = j + 1; continue; }
let j = i; while (j < s.length && !/\s/.test(s[j])) j++; out.push(s.slice(i, j)); i = j;
}
return out;
};
const unq = (w) => (/^(["']).*\1$/.test(w) ? w.slice(1, -1) : w);
/**
* Parse the text inside `fonts { ... }`.
* hooks.error(line, message, hint) reports a problem, hooks.suggest(word, list) gives a did-you-mean.
* Returns { links: [{ url, line }], faces: [css], families: [name] }.
*/
export function parseFonts(body, firstLine, hooks) {
const google = new Map(); // family -> { weights:Set<string>, italic:bool }
let display = 'swap';
const faces = [], families = [];
const lines = String(body).replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' ')).split('\n');
lines.forEach((raw, idx) => {
const line = firstLine + idx;
let text = raw.replace(/(^|\s)\/\/.*$/, '').trim().replace(/;$/, '');
if (!text) return;
// the family may be quoted, or bare words ("Hanken Grotesk"): everything before the first colon outside quotes
let colon = -1, q = null;
for (let i = 0; i < text.length; i++) { const c = text[i]; if (q) { if (c === q) q = null; continue; } if (c === '"' || c === "'") { q = c; continue; } if (c === ':') { colon = i; break; } }
if (colon === -1) { hooks.error(line, `expected "Family: weights" in "${text.slice(0, 40)}"`, 'e.g. Fraunces: 500 700 800'); return; }
const family = unq(text.slice(0, colon).trim()).replace(/\s+/g, ' ');
if (!family) { hooks.error(line, 'a font line needs a family name before the colon'); return; }
const w = words(text.slice(colon + 1));
const weights = []; let italic = false, from = null, disp = null;
for (let i = 0; i < w.length; i++) {
const x = w[i];
if (/^\d{1,4}$/.test(x) || /^\d{1,4}\.\.\d{1,4}$/.test(x)) {
const [a, b] = x.split('..').map(Number);
if (a < 1 || a > 1000 || (b !== undefined && (b < 1 || b > 1000 || b <= a))) { hooks.error(line, `"${x}" is not a font weight`, FONT_WORDS.weights); return; }
weights.push(x);
} else if (x === 'italic') italic = true;
else if (x === 'display') {
if (!DISPLAYS.includes(w[i + 1])) { hooks.error(line, `display needs one of: ${DISPLAYS.join(', ')}`, FONT_WORDS.display); return; }
disp = w[++i];
} else if (x === 'from') {
if (!w[i + 1]) { hooks.error(line, 'from needs a file, e.g. from "/fonts/brand.woff2"', FONT_WORDS.from); return; }
from = unq(w[++i]);
} else {
hooks.error(line, `unknown font setting "${x}"`, (hooks.suggest && hooks.suggest(x, ['italic', 'display', 'from'])) || 'a line is: Family: weights italic display swap, or Family: from "file" weight');
return;
}
}
if (!families.includes(family)) families.push(family);
if (from) {
if (weights.length > 1) { hooks.error(line, 'one font file holds one weight (or one variable range), so give from a single weight', 'add one line per file'); return; }
const ext = (from.split('?')[0].match(/\.([a-z0-9]+)$/i) || [])[1];
const fmt = ext && FORMATS[ext.toLowerCase()];
const weight = weights[0] ? weights[0].replace('..', ' ') : '400';
faces.push(`@font-face {\n font-family: "${family}";\n src: url("${from}")${fmt ? ` format("${fmt}")` : ''};\n font-weight: ${weight};\n font-style: ${italic ? 'italic' : 'normal'};\n font-display: ${disp || 'swap'};\n}`);
return;
}
if (disp) display = disp;
const g = google.get(family) || { weights: new Set(), italic: false };
for (const x of weights) g.weights.add(x);
if (italic) g.italic = true;
google.set(family, g);
});
const links = [];
if (google.size) {
const parts = [];
for (const [fam, g] of google) {
const name = fam.replace(/ /g, '+');
const ws = [...g.weights].sort((a, b) => parseInt(a) - parseInt(b));
if (!ws.length) { parts.push(g.italic ? `${name}:ital@0;1` : name); continue; }
if (g.italic) {
const axis = [];
for (const it of [0, 1]) for (const x of ws) axis.push(`${it},${x}`);
parts.push(`${name}:ital,wght@${axis.join(';')}`);
} else parts.push(`${name}:wght@${ws.join(';')}`);
}
links.push({ url: `https://fonts.googleapis.com/css2?family=${parts.join('&family=')}&display=${display}`, line: firstLine });
}
return { links, faces, families };
}
|