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\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);
}
}