diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..154e335 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,44 @@ +## Description + + + +## Screenshots + + + + +| Before | After | +| ------ | ----- | +| | | + +- [ ] N/A — no visual changes + +## AI Models Used + + + +| Model | How it was used | +| ----- | --------------- | +| | | + + + +- [ ] No AI tools were used for this PR + +## Testing + + + +- [ ] Ran `bun lint` +- [ ] Ran `bun test integration_test/` +- [ ] Manually tested in the browser + +## Checklist + +- [ ] My code follows the existing style of this project +- [ ] I've included screenshots (or marked N/A) +- [ ] I've disclosed AI model usage (or marked N/A) +- [ ] I've tested my changes diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..257d4c9 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,110 @@ +# Contributing to OpenBrand + +Thanks for your interest in contributing to OpenBrand! Whether you're fixing a bug, adding a feature, or improving docs — we appreciate it. + +## Getting Started + +1. **Fork and clone** the repository: + + ```bash + git clone https://github.com//openbrand.git + cd openbrand + ``` + +2. **Install dependencies** (we use [Bun](https://bun.sh)): + + ```bash + bun install + ``` + +3. **Set up environment variables** — copy `.env.example` or create `.env.local`: + + ``` + NEXT_PUBLIC_SUPABASE_URL= + NEXT_PUBLIC_SUPABASE_ANON_KEY= + SUPABASE_SERVICE_ROLE_KEY= + ``` + +4. **Start the dev server**: + + ```bash + bun dev + ``` + +## Project Structure + +| Directory | What's in there | +| -------------- | -------------------------------------------- | +| `src/` | Core extraction library (published to npm) | +| `app/` | Next.js app routes and API endpoints | +| `components/` | React UI components | +| `mcp/` | MCP server for AI assistant integration | +| `integration_test/` | Integration tests | +| `lib/` | Shared utilities (auth, Supabase clients) | + +## Development Workflow + +1. **Create a branch** from `main`: + + ```bash + git checkout -b feat/my-feature + ``` + +2. **Make your changes** — follow the existing code style (TypeScript, Tailwind CSS). + +3. **Lint and test**: + + ```bash + bun lint + bun test integration_test/ + ``` + +4. **Open a pull request** against `main`. + +## Pull Request Requirements + +Every PR must include the following. Our PR template will remind you, but here's what we expect: + +### UI Screenshots + +If your change affects the UI in any way, include screenshots: + +- **Before & after** screenshots for changes to existing UI +- **Screenshots of new UI** for new features or components +- If your change is purely backend/library code with no visual impact, note that in the PR + +Visual changes without screenshots will not be merged. This helps reviewers understand the impact of your work at a glance. + +### AI Model Disclosure + +We believe in transparency about how code is created. If you used AI tools during development, please disclose: + +- **Which model(s)** you used (e.g., Claude Opus 4.6, GPT-4, Gemini, Copilot) +- **How you used them** (e.g., code generation, debugging, code review, writing tests) + +This isn't about gatekeeping — AI tools are welcome and encouraged! We track this to understand how our codebase evolves and to give proper attribution. + +If you didn't use any AI tools, just check the "No AI tools were used" box in the PR template. + +## Code Style + +- **TypeScript** everywhere — avoid `any` types +- **ESLint** rules are enforced — run `bun lint` before pushing +- Follow existing patterns in the codebase rather than introducing new ones +- Keep changes focused — one feature or fix per PR + +## Testing + +- Integration tests live in `integration_test/` +- If you're modifying the core extraction logic in `src/`, make sure existing tests still pass +- Adding tests for new functionality is appreciated + +## Where to Contribute + +- Check [open issues](https://github.com/ethanjyx/openbrand/issues) for things to work on +- Issues labeled `good first issue` are great starting points +- Have an idea? Open an issue first to discuss it before writing code + +## Questions? + +Open an issue or start a discussion — we're happy to help you get started. diff --git a/app/api/extract/route.ts b/app/api/extract/route.ts index d454c36..ce09709 100644 --- a/app/api/extract/route.ts +++ b/app/api/extract/route.ts @@ -117,6 +117,7 @@ export async function GET(request: NextRequest) { logos: extracted.data.logos || [], colors: extracted.data.colors || [], backdrops: extracted.data.backdrop_images || [], + fonts: extracted.data.fonts || [], }; console.log(JSON.stringify({ @@ -128,6 +129,7 @@ export async function GET(request: NextRequest) { logoCount: result.logos.length, colorCount: result.colors.length, backdropCount: result.backdrops.length, + fontCount: result.fonts.length, })); // Insert into brand_cache, then log diff --git a/components/brand-results.tsx b/components/brand-results.tsx index 55dd823..bfa5f6a 100644 --- a/components/brand-results.tsx +++ b/components/brand-results.tsx @@ -5,6 +5,7 @@ import type { BrandExtractionResult } from "@/src/types"; import { ColorPalette } from "./color-palette"; import { LogoDisplay } from "./logo-display"; import { BackdropGallery } from "./backdrop-gallery"; +import { FontDisplay } from "./font-display"; import { JsonView } from "./json-view"; export function BrandResults({ data }: { data: BrandExtractionResult }) { @@ -48,6 +49,7 @@ export function BrandResults({ data }: { data: BrandExtractionResult }) { <> + ) : ( diff --git a/components/font-display.tsx b/components/font-display.tsx new file mode 100644 index 0000000..f786c1d --- /dev/null +++ b/components/font-display.tsx @@ -0,0 +1,45 @@ +"use client"; + +import type { FontAsset } from "@/src/types"; +import { useState } from "react"; + +export function FontDisplay({ fonts }: { fonts: FontAsset[] }) { + const [copied, setCopied] = useState(null); + + if (fonts.length === 0) return null; + + const copy = (text: string) => { + navigator.clipboard.writeText(text); + setCopied(text); + setTimeout(() => setCopied(null), 1500); + }; + + return ( +
+

+ Fonts +

+
+ {fonts.map((font, i) => ( + + ))} +
+
+ ); +} diff --git a/integration_test/asset-shapes.test.ts b/integration_test/asset-shapes.test.ts index 3647fd5..2542f73 100644 --- a/integration_test/asset-shapes.test.ts +++ b/integration_test/asset-shapes.test.ts @@ -3,6 +3,8 @@ import { extractBrandAssets } from "../src"; const VALID_LOGO_TYPES = ["img", "svg", "favicon", "apple-touch-icon", "icon", "logo"]; const VALID_COLOR_USAGES = ["primary", "secondary", "accent", "background", "text"]; +const VALID_FONT_ROLES = ["heading", "body"]; +const VALID_FONT_SOURCES = ["google", "system", "custom"]; describe("asset shape validation", () => { // Extract once and share across tests @@ -71,4 +73,35 @@ describe("asset shape validation", () => { } } }); + + test("FontAsset shape — family is string, role and source are known values", () => { + // fonts array must exist (may be empty for some sites) + expect(Array.isArray(data.fonts)).toBe(true); + + for (const font of data.fonts) { + expect(font.family).toBeString(); + expect(font.family.length).toBeGreaterThan(0); + + expect(VALID_FONT_ROLES).toContain(font.role); + expect(VALID_FONT_SOURCES).toContain(font.source); + + expect(Array.isArray(font.weights)).toBe(true); + expect(font.weights.length).toBeGreaterThanOrEqual(1); + for (const w of font.weights) { + expect(typeof w).toBe("number"); + expect(w).toBeGreaterThanOrEqual(100); + expect(w).toBeLessThanOrEqual(900); + } + + expect(Array.isArray(font.fallbacks)).toBe(true); + for (const f of font.fallbacks) { + expect(typeof f).toBe("string"); + } + + if (font.source === "google") { + expect(font.googleFontsUrl).toBeDefined(); + expect(font.googleFontsUrl).toContain("fonts.googleapis.com"); + } + } + }); }); diff --git a/src/index.ts b/src/index.ts index b47a8e4..851d19a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,4 +4,5 @@ export type { LogoAsset, ColorAsset, BackdropAsset, + FontAsset, } from "./types"; diff --git a/src/scraper.ts b/src/scraper.ts index c019232..4cb06dd 100644 --- a/src/scraper.ts +++ b/src/scraper.ts @@ -1,7 +1,7 @@ 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 } 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"; @@ -82,6 +82,7 @@ async function fetchPage(url: string): Promise<{ html: string; ok: boolean; stat "Accept-Language": "en-US,en;q=0.5", }, redirect: "follow", + signal: AbortSignal.timeout(15_000), }); return { html: await res.text(), ok: res.ok, status: res.status }; @@ -94,6 +95,7 @@ async function fetchViaJina(url: string): Promise { Accept: "text/html", "X-Return-Format": "html", }, + signal: AbortSignal.timeout(15_000), }); if (!res.ok) return null; return res.text(); @@ -143,6 +145,7 @@ async function parseHtml( logos: LogoAsset[]; colors: ColorAsset[]; backdrop_images: BackdropAsset[]; + fonts: FontAsset[]; brand_name: string; }> { const $ = cheerio.load(html); @@ -151,11 +154,13 @@ async function parseHtml( const { logos, backdrops: imgBackdrops } = await extractImages($, baseUrl, domainName); const colors = await extractColors($, baseUrl, logos); const cssBackdrops = extractCssBackdrops($, html, baseUrl); + const fonts = await extractFonts($, html, baseUrl); return { logos, colors, backdrop_images: [...cssBackdrops, ...imgBackdrops], + fonts, brand_name: extractBrandName($, domainName), }; } @@ -595,6 +600,254 @@ function extractCssBackdrops( return backdrops; } +// ── Fonts ───────────────────────────────────────────────────────────── + +const SYSTEM_FONTS = new Set([ + "arial", "helvetica", "helvetica neue", "times new roman", "times", + "georgia", "verdana", "tahoma", "trebuchet ms", "courier new", "courier", + "system-ui", "-apple-system", "blinkmacsystemfont", "segoe ui", + "sans-serif", "serif", "monospace", "cursive", "fantasy", "ui-sans-serif", + "ui-serif", "ui-monospace", "ui-rounded", +]); + +const HEADING_SELECTORS = /\bh[1-6]\b/i; +const BODY_SELECTORS = /\b(body|html|p|main|article|\*)\b/i; + +interface FontInfo { + family: string; + weights: Set; + isGoogle: boolean; + googleUrl: string | null; + fallbacks: string[]; + appliedTo: Set; +} + +/** Parse a Google Fonts URL (css or css2 API) into family names and weights */ +function parseGoogleFontsUrl(url: string): Array<{ family: string; weights: number[] }> { + const results: Array<{ family: string; weights: number[] }> = []; + try { + const u = new URL(url); + const families = u.searchParams.getAll("family"); + for (const raw of families) { + // css2: "Inter:wght@400;700" or "Inter:wght@400..700" + // css: "Inter:400,700" or just "Inter" + const colonIdx = raw.indexOf(":"); + const name = colonIdx === -1 ? raw : raw.slice(0, colonIdx); + const spec = colonIdx === -1 ? "" : raw.slice(colonIdx + 1); + + const weights: number[] = []; + // Match numeric weight values + const weightMatches = spec.match(/\d{3}/g); + if (weightMatches) { + for (const w of weightMatches) weights.push(parseInt(w, 10)); + } + if (weights.length === 0) weights.push(400); + + results.push({ family: name.replace(/\+/g, " "), weights }); + } + } catch { + // Malformed URL — skip + } + return results; +} + +/** Parse a font-family CSS value into [primary, ...fallbacks] */ +function parseFontStack(value: string): string[] { + return value + .replace(/\s*!important\s*/gi, "") + .split(",") + .map((f) => f.trim().replace(/^["']|["']$/g, "")) + .filter(Boolean); +} + +function getOrCreateFont(map: Map, family: string): FontInfo { + const key = family.toLowerCase(); + let info = map.get(key); + if (!info) { + info = { family, weights: new Set(), isGoogle: false, googleUrl: null, fallbacks: [], appliedTo: new Set() }; + map.set(key, info); + } + return info; +} + +async function extractFonts($: cheerio.CheerioAPI, html: string, baseUrl: string): Promise { + const fonts = new Map(); + + // ── Phase A: Google Fonts via tags ── + $('link[href*="fonts.googleapis.com"]').each((_, el) => { + const href = $(el).attr("href"); + if (!href) return; + for (const { family, weights } of parseGoogleFontsUrl(href)) { + const info = getOrCreateFont(fonts, family); + info.isGoogle = true; + info.googleUrl = href; + for (const w of weights) info.weights.add(w); + } + }); + + // ── Phase B: Google Fonts via @import in