Fix Google Fonts css2 family parsing (#349)

This commit is contained in:
Dustin Persek
2026-07-07 17:16:11 -07:00
committed by GitHub
parent 60d32e1e58
commit 9f49cb85cc
7 changed files with 137 additions and 24 deletions
+33 -8
View File
@@ -628,6 +628,36 @@ function colorToHex(c) {
return '#' + [c.r, c.g, c.b].map(v => v.toString(16).padStart(2, '0')).join('');
}
// --- cli/engine/shared/fonts.mjs ---
const GOOGLE_FONTS_URL_RE = /fonts\.googleapis\.com\/css2?\?[^"'\s)<>]*/gi;
function normalizeGoogleFontFamilyParam(value) {
return String(value || '')
.split('|')
.map(part => part.split(':')[0].trim().toLowerCase())
.filter(Boolean);
}
function extractGoogleFontFamilies(text) {
const families = [];
if (!text) return families;
GOOGLE_FONTS_URL_RE.lastIndex = 0;
let urlMatch;
while ((urlMatch = GOOGLE_FONTS_URL_RE.exec(text)) !== null) {
const url = urlMatch[0];
const queryStart = url.indexOf('?');
if (queryStart === -1) continue;
const params = new URLSearchParams(url.slice(queryStart + 1).replace(/&amp;/g, '&'));
for (const value of params.getAll('family')) {
families.push(...normalizeGoogleFontFamilyParam(value));
}
}
return families;
}
// --- cli/engine/rules/checks.mjs ---
const DETECTOR_IS_BROWSER = typeof window !== 'undefined';
@@ -2682,14 +2712,9 @@ function checkPageTypography(doc, win) {
// Check Google Fonts links in HTML
const html = doc.documentElement?.outerHTML || '';
const gfRe = /fonts\.googleapis\.com\/css2?\?family=([^&"'\s]+)/gi;
let m;
while ((m = gfRe.exec(html)) !== null) {
const families = m[1].split('|').map(f => f.split(':')[0].replace(/\+/g, ' ').toLowerCase());
for (const f of families) {
fonts.add(f);
if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
}
for (const f of extractGoogleFontFamilies(html)) {
fonts.add(f);
if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
}
// Also parse raw HTML/style content for font-family (jsdom may not expose all via CSSOM)
+13 -8
View File
@@ -1,5 +1,6 @@
import { GENERIC_FONTS } from '../../shared/constants.mjs';
import { GENERIC_FONTS, OVERUSED_FONTS } from '../../shared/constants.mjs';
import { isNeutralColor } from '../../shared/color.mjs';
import { extractGoogleFontFamilies } from '../../shared/fonts.mjs';
import { checkSourceDesignSystem } from '../../design-system.mjs';
import { isFullPage } from '../../shared/page.mjs';
import { applyInlineIgnores } from '../../shared/inline-ignores.mjs';
@@ -38,6 +39,10 @@ function shouldRunPageAnalyzers(content, filePath) {
return !ext || PAGE_ANALYZER_EXTS.has(ext);
}
function firstOverusedGoogleFont(text) {
return extractGoogleFontFamilies(text).find(f => OVERUSED_FONTS.has(f)) || '';
}
function isNeutralBorderColor(str) {
const m = str.match(/solid\s+((?:rgba?|hsla?|oklch|oklab|lab|lch|hwb|color)\([^)]*\)|#[0-9a-f]{3,8}\b|[a-z]+)/i);
if (!m) return false;
@@ -88,9 +93,12 @@ const REGEX_MATCHERS = [
{ id: 'overused-font', regex: /font-family\s*:\s*['"]?(Inter|Roboto|Open Sans|Lato|Montserrat|Arial|Helvetica|Fraunces|Geist Sans|Geist Mono|Geist|Mona Sans|Plus Jakarta Sans|Space Grotesk|Recoleta|Instrument Sans|Instrument Serif)\b/gi,
test: () => true,
fmt: (m) => m[0] },
{ id: 'overused-font', regex: /fonts\.googleapis\.com\/css2?\?family=(Inter|Roboto|Open\+Sans|Lato|Montserrat|Fraunces|Plus\+Jakarta\+Sans|Space\+Grotesk|Instrument\+Sans|Instrument\+Serif|Mona\+Sans|Geist)\b/gi,
test: () => true,
fmt: (m) => `Google Fonts: ${m[1].replace(/\+/g, ' ')}` },
{ id: 'overused-font', regex: /fonts\.googleapis\.com\/css2?\?[^"'\s)<>]*/gi,
test: (m) => {
m.overusedGoogleFont = firstOverusedGoogleFont(m[0]);
return Boolean(m.overusedGoogleFont);
},
fmt: (m) => `Google Fonts: ${m.overusedGoogleFont || firstOverusedGoogleFont(m[0])}` },
// --- Gradient text ---
{ id: 'gradient-text', regex: /background-clip\s*:\s*text|-webkit-background-clip\s*:\s*text/gi,
test: (m, line) => /gradient/i.test(line),
@@ -170,10 +178,7 @@ const REGEX_ANALYZERS = [
if (f && !GENERIC_FONTS.has(f)) fonts.add(f);
}
}
const gfRe = /fonts\.googleapis\.com\/css2?\?family=([^&"'\s]+)/gi;
while ((m = gfRe.exec(content)) !== null) {
for (const f of m[1].split('|').map(f => f.split(':')[0].replace(/\+/g, ' ').toLowerCase())) fonts.add(f);
}
for (const f of extractGoogleFontFamilies(content)) fonts.add(f);
if (fonts.size !== 1 || content.split('\n').length < 20) return [];
const name = [...fonts][0];
const lines = content.split('\n');
+4 -8
View File
@@ -18,6 +18,7 @@ import {
parseRgb,
relativeLuminance,
} from '../shared/color.mjs';
import { extractGoogleFontFamilies } from '../shared/fonts.mjs';
const DETECTOR_IS_BROWSER = typeof window !== 'undefined';
@@ -2072,14 +2073,9 @@ function checkPageTypography(doc, win) {
// Check Google Fonts links in HTML
const html = doc.documentElement?.outerHTML || '';
const gfRe = /fonts\.googleapis\.com\/css2?\?family=([^&"'\s]+)/gi;
let m;
while ((m = gfRe.exec(html)) !== null) {
const families = m[1].split('|').map(f => f.split(':')[0].replace(/\+/g, ' ').toLowerCase());
for (const f of families) {
fonts.add(f);
if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
}
for (const f of extractGoogleFontFamilies(html)) {
fonts.add(f);
if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
}
// Also parse raw HTML/style content for font-family (jsdom may not expose all via CSSOM)
+30
View File
@@ -0,0 +1,30 @@
const GOOGLE_FONTS_URL_RE = /fonts\.googleapis\.com\/css2?\?[^"'\s)<>]*/gi;
function normalizeGoogleFontFamilyParam(value) {
return String(value || '')
.split('|')
.map(part => part.split(':')[0].trim().toLowerCase())
.filter(Boolean);
}
function extractGoogleFontFamilies(text) {
const families = [];
if (!text) return families;
GOOGLE_FONTS_URL_RE.lastIndex = 0;
let urlMatch;
while ((urlMatch = GOOGLE_FONTS_URL_RE.exec(text)) !== null) {
const url = urlMatch[0];
const queryStart = url.indexOf('?');
if (queryStart === -1) continue;
const params = new URLSearchParams(url.slice(queryStart + 1).replace(/&amp;/g, '&'));
for (const value of params.getAll('family')) {
families.push(...normalizeGoogleFontFamilyParam(value));
}
}
return families;
}
export { extractGoogleFontFamilies };
+2
View File
@@ -18,6 +18,7 @@ const MODULES = [
'cli/engine/shared/constants.mjs',
'cli/engine/registry/antipatterns.mjs',
'cli/engine/shared/color.mjs',
'cli/engine/shared/fonts.mjs',
'cli/engine/rules/checks.mjs',
'cli/engine/browser/injected/index.mjs',
];
@@ -32,6 +33,7 @@ function browserSafeModule(relPath) {
code = match[0];
}
code = code.replace(/^import[\s\S]*?;\n/gm, '');
code = code.replace(/^export\s+\{[^}]*\};\n?/gm, '');
code = code.replace(/^export\s+\{[\s\S]*?^};\n?/gm, '');
return `// --- ${relPath} ---\n${code.trim()}\n`;
}
+2
View File
@@ -28,6 +28,7 @@ const BROWSER_MODULES = [
'cli/engine/shared/constants.mjs',
'cli/engine/registry/antipatterns.mjs',
'cli/engine/shared/color.mjs',
'cli/engine/shared/fonts.mjs',
'cli/engine/rules/checks.mjs',
'cli/engine/browser/injected/index.mjs',
];
@@ -42,6 +43,7 @@ function browserSafeModule(relPath) {
code = match[0];
}
code = code.replace(/^import[\s\S]*?;\n/gm, '');
code = code.replace(/^export\s+\{[^}]*\};\n?/gm, '');
code = code.replace(/^export\s+\{[\s\S]*?^};\n?/gm, '');
return `// --- ${relPath} ---\n${code.trim()}\n`;
}
+53
View File
@@ -12,6 +12,7 @@ import {
} from '../cli/engine/detect-antipatterns.mjs';
import {
checkElementTextOverflowDOM,
checkPageTypography,
isScreenReaderOnlyTextStyle,
} from '../cli/engine/rules/checks.mjs';
@@ -48,6 +49,34 @@ function findingIds(findings) {
return findings.map(f => f.antipattern);
}
function pageWithGoogleFonts(href) {
return [
'<!DOCTYPE html><html><head>',
`<link href="${href}" rel="stylesheet">`,
'</head><body>',
...Array.from({ length: 22 }, (_, i) => `<p>Sample content row ${i + 1}</p>`),
'</body></html>',
].join('\n');
}
function pageTypographyForGoogleFonts(href) {
const html = pageWithGoogleFonts(href);
const doc = {
styleSheets: [],
documentElement: { outerHTML: html },
querySelectorAll(selector) {
if (selector === '*') return Array.from({ length: 24 }, () => ({}));
return [];
},
};
const win = {
getComputedStyle() {
return { fontSize: '16px' };
},
};
return checkPageTypography(doc, win);
}
// ---------------------------------------------------------------------------
// Core: checkElementBorders (computed style simulation)
@@ -199,6 +228,30 @@ describe('detectText — overused fonts', () => {
const f = detectText("body { font-family: 'Karla', sans-serif; }", 'test.css');
expect(f.filter(r => r.antipattern === 'overused-font')).toHaveLength(0);
});
test('detects overused Google Fonts css2 family after first family param', () => {
const page = pageWithGoogleFonts('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300&family=Inter:wght@400;500;600&display=swap');
const f = detectText(page, 'index.html');
expect(f.some(r => r.antipattern === 'overused-font' && /Inter/i.test(r.snippet))).toBe(true);
});
test('does not flag single-font for combined Google Fonts css2 families', () => {
const page = pageWithGoogleFonts('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300&family=Jost:wght@300;400;500&display=swap');
const f = detectText(page, 'index.html');
expect(f.filter(r => r.antipattern === 'single-font')).toHaveLength(0);
});
test('keeps legacy Google Fonts css pipe-separated families multi-font', () => {
const page = pageWithGoogleFonts('https://fonts.googleapis.com/css?family=Cormorant+Garamond|Jost&display=swap');
const f = detectText(page, 'index.html');
expect(f.filter(r => r.antipattern === 'single-font')).toHaveLength(0);
});
test('page typography parses repeated Google Fonts css2 family params', () => {
const f = pageTypographyForGoogleFonts('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300&family=Inter:wght@400;500;600&display=swap');
expect(f.some(r => r.id === 'overused-font' && /inter/i.test(r.snippet))).toBe(true);
expect(f.filter(r => r.id === 'single-font')).toHaveLength(0);
});
});
describe('detectText — flat type hierarchy', () => {