diff --git a/public/index.css b/public/index.css index 5a98685..93e6f15 100644 --- a/public/index.css +++ b/public/index.css @@ -59,7 +59,7 @@ body { .catalog { display: grid; grid-template-columns: repeat(auto-fill, minmax(100%, 1fr)); - gap: 2rem; + gap: 1rem; } .font-card { @@ -79,6 +79,55 @@ body { margin-bottom: 0.5rem; } +.font-card__controls { + display: inline-flex; + align-items: center; + gap: 0.75rem; +} + +.font-card__select { + height: 2.25rem; + border: 1px solid var(--border-color); + background: transparent; + color: var(--fg-color); + padding: 0 0.75rem; + font-size: 0.85rem; + font-family: inherit; + cursor: pointer; +} + +.font-card__range { + width: 160px; + accent-color: var(--accent-color); + cursor: pointer; + height: 4px; +} + +.font-card__range::-webkit-slider-runnable-track { + height: 4px; +} + +.font-card__range::-webkit-slider-thumb { + margin-top: -6px; + transform: scale(0.75); +} + +.font-card__range::-moz-range-track { + height: 4px; +} + +.font-card__range::-moz-range-thumb { + margin-top: -6px; + transform: scale(0.75); +} + +.font-card__value { + min-width: 2.5rem; + text-align: right; + font-size: 0.85rem; + font-weight: 700; +} + .font-card__title { font-size: 1.25rem; font-weight: bold; diff --git a/public/index.html b/public/index.html index 0f40bc0..f5c3c4c 100644 --- a/public/index.html +++ b/public/index.html @@ -63,16 +63,113 @@ } }; + const parseWeights = (value) => { + if (!value) { + return []; + } + return value + .split(",") + .map((entry) => Number.parseInt(entry, 10)) + .filter((weight) => Number.isFinite(weight)) + .sort((a, b) => a - b); + }; + fontCards.forEach((card) => { const importUrl = card.getAttribute("data-import-url"); const importSnippet = buildImportSnippet(importUrl); const importOutput = card.querySelector("[data-import]"); const copyButton = card.querySelector("[data-copy]"); + const weightSelect = card.querySelector("[data-weight-select]"); + const weightRange = card.querySelector("[data-weight-range]"); + const demoText = card.querySelector("[data-demo]"); + const weightValue = card.querySelector("[data-weight-value]"); + const weights = parseWeights(card.getAttribute("data-weights")); + const defaultWeight = Number.parseInt( + card.getAttribute("data-default-weight") ?? "", + 10, + ); + const weightMin = Number.parseInt( + card.getAttribute("data-weight-min") ?? "", + 10, + ); + const weightMax = Number.parseInt( + card.getAttribute("data-weight-max") ?? "", + 10, + ); + const weightStep = Number.parseInt( + card.getAttribute("data-weight-step") ?? "10", + 10, + ); if (importOutput) { importOutput.textContent = importSnippet; } + if (!weightRange && !weightSelect) { + if (demoText && Number.isFinite(defaultWeight)) { + demoText.style.fontWeight = String(defaultWeight); + } + } else if (weightRange && Number.isFinite(weightMin) && Number.isFinite(weightMax)) { + const step = Number.isFinite(weightStep) && weightStep > 0 ? weightStep : 10; + const midpoint = Math.round((weightMin + weightMax) / 2); + const initialWeight = Number.isFinite(defaultWeight) + ? defaultWeight + : midpoint; + const normalizedWeight = Math.min( + weightMax, + Math.max( + weightMin, + Math.round((initialWeight - weightMin) / step) * step + weightMin, + ), + ); + weightRange.min = String(weightMin); + weightRange.max = String(weightMax); + weightRange.step = String(step); + weightRange.value = String(normalizedWeight); + if (weightValue) { + weightValue.textContent = String(normalizedWeight); + } + if (demoText) { + demoText.style.fontWeight = String(normalizedWeight); + } + weightRange.addEventListener("input", (event) => { + const value = Number.parseInt(event.target.value, 10); + if (!Number.isFinite(value)) { + return; + } + if (weightValue) { + weightValue.textContent = String(value); + } + if (demoText) { + demoText.style.fontWeight = String(value); + } + }); + } else if (weightSelect && weights.length > 0) { + weightSelect.innerHTML = ""; + weights.forEach((weight) => { + const option = document.createElement("option"); + option.value = String(weight); + option.textContent = String(weight); + weightSelect.appendChild(option); + }); + const initialWeight = Number.isFinite(defaultWeight) + ? defaultWeight + : weights[Math.floor((weights.length - 1) / 2)]; + weightSelect.value = String(initialWeight); + if (demoText) { + demoText.style.fontWeight = String(initialWeight); + } + weightSelect.addEventListener("change", (event) => { + const value = Number.parseInt(event.target.value, 10); + if (!Number.isFinite(value)) { + return; + } + if (demoText) { + demoText.style.fontWeight = String(value); + } + }); + } + if (copyButton) { copyButton.addEventListener("click", async () => { if (!importSnippet) { diff --git a/src/server.ts b/src/server.ts index d140dd8..1ae8fc7 100644 --- a/src/server.ts +++ b/src/server.ts @@ -26,20 +26,72 @@ const normalizeFamily = (value: string) => const slugify = (value: string) => value.toLowerCase().replace(/\s+/g, " ").trim(); -const parseFontFamilies = (css: string) => { +const parseWeightValues = (value: string) => { + const weights: number[] = []; + const numbers = value.match(/\d+/g)?.map((entry) => Number(entry)) ?? []; + if (numbers.length === 1) { + weights.push(numbers[0]); + return { weights }; + } + if (numbers.length >= 2) { + const min = Math.min(numbers[0], numbers[1]); + const max = Math.max(numbers[0], numbers[1]); + return { weights, range: { min, max } }; + } + const normalized = value.trim().toLowerCase(); + if (normalized === "bold") { + weights.push(700); + } else if (normalized === "normal") { + weights.push(400); + } + return { weights }; +}; + +const parseFontData = (css: string) => { const families = new Set(); + const weightsByFamily = new Map>(); + const rangesByFamily = new Map(); const blocks = css.match(/@font-face\s*{[^}]*}/gms) ?? []; for (const block of blocks) { - const match = block.match(/font-family\s*:\s*([^;]+);/i); - if (!match) { + const familyMatch = block.match(/font-family\s*:\s*([^;]+);/i); + if (!familyMatch) { continue; } - const family = normalizeFamily(match[1]); - if (family) { - families.add(family); + const family = normalizeFamily(familyMatch[1]); + if (!family) { + continue; + } + families.add(family); + const weightMatch = block.match(/font-weight\s*:\s*([^;]+);/i); + if (!weightMatch) { + continue; + } + const parsed = parseWeightValues(weightMatch[1]); + const weightSet = weightsByFamily.get(family) ?? new Set(); + for (const weight of parsed.weights) { + if (Number.isFinite(weight)) { + weightSet.add(weight); + } + } + if (parsed.range) { + const existingRange = rangesByFamily.get(family); + const min = existingRange + ? Math.min(existingRange.min, parsed.range.min) + : parsed.range.min; + const max = existingRange + ? Math.max(existingRange.max, parsed.range.max) + : parsed.range.max; + rangesByFamily.set(family, { min, max }); + } + if (weightSet.size > 0) { + weightsByFamily.set(family, weightSet); } } - return [...families]; + return { + families: [...families], + weightsByFamily, + rangesByFamily, + }; }; const buildFontCatalog = async () => { @@ -51,13 +103,14 @@ const buildFontCatalog = async () => { const cards: string[] = []; const importUrls: string[] = []; + let cardIndex = 0; for (const fileName of cssFiles) { const baseName = parse(fileName).name; const route = `/${baseName}`; const filePath = join(cssDir, fileName); const css = await Bun.file(filePath).text(); - const families = parseFontFamilies(css); + const { families, weightsByFamily, rangesByFamily } = parseFontData(css); if (families.length === 0) { continue; @@ -66,16 +119,41 @@ const buildFontCatalog = async () => { importUrls.push(route); for (const family of families) { + cardIndex += 1; const displayName = family || baseName; const dataName = slugify(displayName); const fontFamily = family || displayName; + const weightList = [...(weightsByFamily.get(family) ?? [])] + .filter((weight) => Number.isFinite(weight)) + .sort((a, b) => a - b); + const weightRange = rangesByFamily.get(family); + const weights = weightList.length > 0 ? weightList : [400]; + const isSingleWeight = !weightRange && weights.length <= 1; + const defaultWeight = weightRange + ? Math.round((weightRange.min + weightRange.max) / 2) + : weights[Math.floor((weights.length - 1) / 2)]; + const weightType = weightRange ? "variable" : "static"; const card = `\n
\n
\n

${escapeHtml( + )}" data-import-url="${escapeAttr(route)}" data-weights="${escapeAttr( + weights.join(","), + )}" data-default-weight="${escapeAttr( + String(defaultWeight), + )}" data-weight-type="${escapeAttr(weightType)}" data-weight-min="${escapeAttr( + String(weightRange?.min ?? ""), + )}" data-weight-max="${escapeAttr( + String(weightRange?.max ?? ""), + )}" data-weight-step="10">\n
\n

${escapeHtml( displayName, - )}

\n
\n\n
\n

\n \n ${weightRange ? `\n ` : ``}\n

`}\n

\n\n
\n

\n The quick brown fox jumps over the lazy dog.\n

\n
\n\n \n
`; + )}', serif; font-weight: ${escapeAttr(String(defaultWeight))};">\n The quick brown fox jumps over the lazy dog.\n

\n \n\n \n `; cards.push(card); } }