From c51cfdf40ec76ae77af2edbbb3ae818f5321d1d6 Mon Sep 17 00:00:00 2001 From: AnandSundar Date: Sat, 20 Jun 2026 22:20:24 -0600 Subject: [PATCH 1/5] feat(scraper): add typography types and extractTypography function Implements U1 and U2 from the typography extraction plan: - Add FontSource, FontAsset, HeadingStyle, BodyStyle, TypographyAsset types (src/types.ts) - Make typography non-optional on BrandExtractionResult - Re-export extractTypography and new types from src/index.ts - Implement extractTypography($, html, baseUrl) in src/scraper.ts: * Collects CSS from inline + + `; + const $ = cheerio.load(html); + const result = await extractTypography($, html, "https://example.com"); + const families = result.fonts.map((f) => f.family.toLowerCase()); + // Only Inter and Roboto should appear; sans-serif/serif should not + expect(families).toContain("inter"); + expect(families).toContain("roboto"); + expect(families).not.toContain("sans-serif"); + expect(families).not.toContain("serif"); + expect(families).not.toContain("monospace"); + }); +}); + +describe("extractTypography — case-insensitive family dedup", () => { + test("merges 'Inter' and 'inter' into a single entry, accumulating weights", async () => { + const html = ` + + + + `; + const $ = cheerio.load(html); + const result = await extractTypography($, html, "https://example.com"); + expect(result.fonts.length).toBe(1); + expect(result.fonts[0].weights).toEqual([400, 700]); + }); +}); + +describe("extractTypography — Google Fonts URL classification", () => { + test("@font-face with fonts.gstatic.com src is classified as google-fonts", async () => { + const html = ` + + + + `; + const $ = cheerio.load(html); + const result = await extractTypography($, html, "https://example.com"); + expect(result.fonts.length).toBe(1); + expect(result.fonts[0].source).toBe("google-fonts"); + expect(result.fonts[0].family).toBe("Roboto"); + }); + + test("@font-face with use.typekit.net src is classified as typekit", async () => { + const html = ` + + + + `; + const $ = cheerio.load(html); + const result = await extractTypography($, html, "https://example.com"); + expect(result.fonts.length).toBe(1); + expect(result.fonts[0].source).toBe("typekit"); + }); + + test("@font-face with arbitrary self-hosted URL is classified as self-hosted", async () => { + const html = ` + + + + `; + const $ = cheerio.load(html); + const result = await extractTypography($, html, "https://example.com"); + expect(result.fonts.length).toBe(1); + expect(result.fonts[0].source).toBe("self-hosted"); + }); + + test("@font-face with no src url is classified as system", async () => { + const html = ` + + + + `; + const $ = cheerio.load(html); + const result = await extractTypography($, html, "https://example.com"); + expect(result.fonts.length).toBe(1); + expect(result.fonts[0].source).toBe("system"); + }); +}); + +describe("extractTypography — heading and body style extraction", () => { + test("extracts h1 font-family, font-size, font-weight from CSS rule", async () => { + const html = ` + + +

Title

+ `; + const $ = cheerio.load(html); + const result = await extractTypography($, html, "https://example.com"); + expect(result.styles.h1).toBeDefined(); + expect(result.styles.h1!.fontFamily).toBe("Inter"); + expect(result.styles.h1!.fontSize).toBe("48px"); + expect(result.styles.h1!.fontWeight).toBe(700); + }); + + test("extracts body font-family from a body rule", async () => { + const html = ` + + + + `; + const $ = cheerio.load(html); + const result = await extractTypography($, html, "https://example.com"); + expect(result.styles.body).toBeDefined(); + expect(result.styles.body!.fontFamily).toBe("Lato"); + expect(result.styles.body!.fontSize).toBe("16px"); + }); + + test("inline style attribute on h1 overrides the CSS rule", async () => { + const html = ` + + +

Title

+ `; + const $ = cheerio.load(html); + const result = await extractTypography($, html, "https://example.com"); + expect(result.styles.h1!.fontFamily).toBe("Overridden"); + expect(result.styles.h1!.fontSize).toBe("24px"); + expect(result.styles.h1!.fontWeight).toBe(400); + }); + + test("returns empty styles when no matching rules exist", async () => { + const html = `

Title

`; + const $ = cheerio.load(html); + const result = await extractTypography($, html, "https://example.com"); + expect(result.styles.h1).toBeUndefined(); + expect(result.styles.h2).toBeUndefined(); + expect(result.styles.body).toBeUndefined(); + expect(result.fonts).toEqual([]); + }); +}); + +describe("extractTypography — comments and braces handled correctly", () => { + test("ignores @font-face rules inside CSS comments", async () => { + const html = ` + + + + `; + const $ = cheerio.load(html); + const result = await extractTypography($, html, "https://example.com"); + expect(result.fonts.length).toBe(1); + expect(result.fonts[0].family).toBe("RealFont"); + }); +}); diff --git a/src/scraper.ts b/src/scraper.ts index 21a0b4e..41838d8 100644 --- a/src/scraper.ts +++ b/src/scraper.ts @@ -829,15 +829,15 @@ function extractRegularRules(css: string): CssRule[] { function splitTopLevelBlocks(css: string): string[] { const blocks: string[] = []; let depth = 0; - let start = 0; + let blockStart = 0; // Start of the current top-level construct (selector + body) for (let i = 0; i < css.length; i++) { if (css[i] === "{") { - if (depth === 0) start = i; depth++; } else if (css[i] === "}") { depth--; if (depth === 0) { - blocks.push(css.substring(start, i + 1)); + blocks.push(css.substring(blockStart, i + 1)); + blockStart = i + 1; } } } From 7eaf5fc854aa88ad028a292e3174bdb1eb4ea823 Mon Sep 17 00:00:00 2001 From: AnandSundar Date: Sat, 20 Jun 2026 22:27:16 -0600 Subject: [PATCH 5/5] docs(mcp+skill): add typography to tool description and skill docs Implements U6 from the typography extraction plan: - Update MCP tool description for extract_brand_assets to mention typography - Update SKILL.md frontmatter description, npm example, asset-sources table, and response-format JSON sample to include typography - Add CHANGELOG.md entry for 0.3.0 documenting the new typography field, scope of CSS sources, generic-name filter, weights-merge dedup, and 5-second fetch timeout --- CHANGELOG.md | 17 +++++++++++++++++ SKILL.md | 15 +++++++++++++-- mcp/src/index.ts | 2 +- 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 62bd0ac..789dfa1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## [0.3.0] - 2026-06-20 + +### Added + +- **Typography extraction:** `extractTypography()` now extracts font families and heading/body styles from any website +- `BrandExtractionResult.typography` field is now part of the standard response shape (non-optional; defaults to `{ fonts: [], styles: {} }` for legacy cache entries) +- Each font asset includes `family`, `source` (`google-fonts` | `typekit` | `self-hosted` | `system`), optional `weights`, and optional `url` (omitted when multiple URLs are present) +- New `TypographyDisplay` component in the web UI renders fonts and live heading/body samples +- New `mcp` tool description mentions typography as part of the brand asset extraction surface + +### Scope + +- CSS sources: inline `