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 ` + + `; + 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/mcp/src/index.ts b/mcp/src/index.ts index 33d32c0..34a91fd 100644 --- a/mcp/src/index.ts +++ b/mcp/src/index.ts @@ -15,7 +15,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ { name: "extract_brand_assets", description: - "Extract brand assets (logos, colors, backdrop images, brand name) from a website URL. Returns structured data with logo URLs, hex colors with usage hints, backdrop/OG images, and the detected brand name.", + "Extract brand assets (logos, colors, backdrop images, brand name, typography) from a website URL. Returns structured data with logo URLs, hex colors with usage hints, backdrop/OG images, the detected brand name, and typography (font families with sources + heading/body styles).", inputSchema: { type: "object" as const, properties: { diff --git a/src/index.ts b/src/index.ts index b47a8e4..bad4d19 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,13 @@ -export { extractBrandAssets } from "./scraper"; +export { extractBrandAssets, extractTypography } from "./scraper"; export type { ExtractionResult, ExtractionError } from "./scraper"; export type { LogoAsset, ColorAsset, BackdropAsset, + FontAsset, + FontSource, + HeadingStyle, + BodyStyle, + TypographyAsset, + BrandExtractionResult, } from "./types"; diff --git a/src/scraper.ts b/src/scraper.ts index bbaa4ba..41838d8 100644 --- a/src/scraper.ts +++ b/src/scraper.ts @@ -1,7 +1,16 @@ import * as cheerio from "cheerio"; import probe from "probe-image-size"; import sharp from "sharp"; -import type { LogoAsset, ColorAsset, BackdropAsset } from "./types"; +import type { + LogoAsset, + ColorAsset, + BackdropAsset, + FontAsset, + FontSource, + TypographyAsset, + HeadingStyle, + BodyStyle, +} from "./types"; const USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"; @@ -146,6 +155,7 @@ async function parseHtml( colors: ColorAsset[]; backdrop_images: BackdropAsset[]; brand_name: string; + typography: TypographyAsset; }> { const $ = cheerio.load(html); const domainName = getDomainName(baseUrl); @@ -153,12 +163,14 @@ async function parseHtml( const { logos, backdrops: imgBackdrops } = await extractImages($, baseUrl, domainName); const colors = await extractColors($, baseUrl, logos); const cssBackdrops = extractCssBackdrops($, html, baseUrl); + const typography = await extractTypography($, html, baseUrl); return { logos, colors, backdrop_images: [...cssBackdrops, ...imgBackdrops], brand_name: extractBrandName($, domainName), + typography, }; } @@ -649,3 +661,371 @@ function extractBrandName( return ""; } + +// ── Typography extraction ──────────────────────────────────────────── + +// Generic CSS family names (CSS Fonts Module Level 4). These appear in +// font-family stacks as fallbacks; we filter them so we only surface +// custom font families the site actually loaded. +const GENERIC_FONT_FAMILIES = new Set([ + "sans-serif", + "serif", + "monospace", + "system-ui", + "cursive", + "fantasy", + "ui-serif", + "ui-sans-serif", + "ui-monospace", + "ui-rounded", + "emoji", + "math", + "fangsong", + "inherit", + "initial", + "unset", + "revert", + "none", + "revert-layer", +]); + +const GOOGLE_FONTS_HOSTS = ["fonts.googleapis.com", "fonts.gstatic.com"]; +const TYPEKIT_HOSTS = ["use.typekit.net"]; + +export async function extractTypography( + $: cheerio.CheerioAPI, + html: string, + baseUrl: string +): Promise { + // 1. Collect CSS sources: inline