svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function AlertDescription({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export { Alert, AlertTitle, AlertDescription, AlertAction }
diff --git a/components/ui/button.tsx b/components/ui/button.tsx
new file mode 100644
index 0000000..c88ffd6
--- /dev/null
+++ b/components/ui/button.tsx
@@ -0,0 +1,67 @@
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+import { Slot } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+
+const buttonVariants = cva(
+ "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
+ outline:
+ "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
+ secondary:
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
+ ghost:
+ "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
+ destructive:
+ "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ size: {
+ default:
+ "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
+ xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
+ sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
+ lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
+ icon: "size-8",
+ "icon-xs":
+ "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
+ "icon-sm":
+ "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
+ "icon-lg": "size-9",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+)
+
+function Button({
+ className,
+ variant = "default",
+ size = "default",
+ asChild = false,
+ ...props
+}: React.ComponentProps<"button"> &
+ VariantProps
& {
+ asChild?: boolean
+ }) {
+ const Comp = asChild ? Slot.Root : "button"
+
+ return (
+
+ )
+}
+
+export { Button, buttonVariants }
diff --git a/components/ui/dropdown-menu.tsx b/components/ui/dropdown-menu.tsx
new file mode 100644
index 0000000..a4695ec
--- /dev/null
+++ b/components/ui/dropdown-menu.tsx
@@ -0,0 +1,267 @@
+import * as React from "react"
+import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+import { CheckIcon, ChevronRightIcon } from "lucide-react"
+
+function DropdownMenu({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DropdownMenuPortal({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuTrigger({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuContent({
+ className,
+ align = "start",
+ sideOffset = 4,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+function DropdownMenuGroup({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuItem({
+ className,
+ inset,
+ variant = "default",
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean
+ variant?: "default" | "destructive"
+}) {
+ return (
+
+ )
+}
+
+function DropdownMenuCheckboxItem({
+ className,
+ children,
+ checked,
+ inset,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean
+}) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function DropdownMenuRadioGroup({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuRadioItem({
+ className,
+ children,
+ inset,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean
+}) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function DropdownMenuLabel({
+ className,
+ inset,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean
+}) {
+ return (
+
+ )
+}
+
+function DropdownMenuSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuShortcut({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+
+ )
+}
+
+function DropdownMenuSub({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DropdownMenuSubTrigger({
+ className,
+ inset,
+ children,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean
+}) {
+ return (
+
+ {children}
+
+
+ )
+}
+
+function DropdownMenuSubContent({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export {
+ DropdownMenu,
+ DropdownMenuPortal,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuLabel,
+ DropdownMenuItem,
+ DropdownMenuCheckboxItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubTrigger,
+ DropdownMenuSubContent,
+}
diff --git a/components/ui/input.tsx b/components/ui/input.tsx
new file mode 100644
index 0000000..d763cd9
--- /dev/null
+++ b/components/ui/input.tsx
@@ -0,0 +1,19 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Input({ className, type, ...props }: React.ComponentProps<"input">) {
+ return (
+
+ )
+}
+
+export { Input }
diff --git a/components/ui/tabs.tsx b/components/ui/tabs.tsx
new file mode 100644
index 0000000..2cb6924
--- /dev/null
+++ b/components/ui/tabs.tsx
@@ -0,0 +1,88 @@
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+import { Tabs as TabsPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+
+function Tabs({
+ className,
+ orientation = "horizontal",
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+const tabsListVariants = cva(
+ "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
+ {
+ variants: {
+ variant: {
+ default: "bg-muted",
+ line: "gap-1 bg-transparent",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+function TabsList({
+ className,
+ variant = "default",
+ ...props
+}: React.ComponentProps &
+ VariantProps) {
+ return (
+
+ )
+}
+
+function TabsTrigger({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function TabsContent({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
diff --git a/lib/utils.ts b/lib/utils.ts
new file mode 100644
index 0000000..bd0c391
--- /dev/null
+++ b/lib/utils.ts
@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from "clsx"
+import { twMerge } from "tailwind-merge"
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs))
+}
diff --git a/package.json b/package.json
index a98c245..40a3b92 100644
--- a/package.json
+++ b/package.json
@@ -42,8 +42,15 @@
"@supabase/supabase-js": "^2.99.1",
"@types/probe-image-size": "^7.2.5",
"cheerio": "^1.2.0",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "lucide-react": "^0.577.0",
"probe-image-size": "^7.2.3",
- "sharp": "^0.34.5"
+ "radix-ui": "^1.4.3",
+ "shadcn": "^4.0.7",
+ "sharp": "^0.34.5",
+ "tailwind-merge": "^3.5.0",
+ "tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
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..890ee35 100644
--- a/src/scraper.ts
+++ b/src/scraper.ts
@@ -1,7 +1,14 @@
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";
+
+/** Internal shape during extraction; we output only FontAsset (family + url?) */
+type InternalFont = {
+ family: string;
+ sourceUrl?: string;
+ source: "google_fonts" | "fontshare" | "private" | "unknown";
+};
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";
@@ -37,8 +44,8 @@ export async function extractBrandAssets(url: string): Promise
}
const data = await parseHtml(html, url);
- if (data.logos.length === 0 && data.colors.length === 0 && data.backdrop_images.length === 0) {
- return { ok: false, error: { code: "EMPTY_CONTENT", message: "The page loaded but no brand assets (logos, colors, or images) were found." } };
+ if (data.logos.length === 0 && data.colors.length === 0 && data.backdrop_images.length === 0 && data.fonts.length === 0) {
+ return { ok: false, error: { code: "EMPTY_CONTENT", message: "The page loaded but no brand assets (logos, colors, images, or fonts) were found." } };
}
return { ok: true, data };
@@ -143,6 +150,7 @@ async function parseHtml(
logos: LogoAsset[];
colors: ColorAsset[];
backdrop_images: BackdropAsset[];
+ fonts: FontAsset[];
brand_name: string;
}> {
const $ = cheerio.load(html);
@@ -151,11 +159,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 +605,335 @@ function extractCssBackdrops(
return backdrops;
}
+// ── Fonts ───────────────────────────────────────────────────────────
+
+const FONT_FACE_RE = /@font-face\s*\{([^}]*)\}/gi;
+const FONT_FAMILY_RE = /font-family\s*:\s*["']?([^"';}]+)["']?/i;
+const FONT_SRC_RE = /src\s*:\s*([^;]+);/i;
+const FONT_WEIGHT_RE = /font-weight\s*:\s*([^;]+);/i;
+const URL_RE = /url\s*\(\s*["']?([^"')]+)["']?\s*\)/g;
+/** Match font-family: "Name", sans-serif or font-family: Name, sans-serif (capture first name) */
+const FONT_FAMILY_DECL_RE = /font-family\s*:\s*(?:["']([^"']+)["']|([^,"';}\s][^,"';}]*))/gi;
+
+/** Generic font families – skip when extracting from CSS declarations (CSS keywords + common system stack names) */
+const GENERIC_FAMILIES = new Set(
+ [
+ "inherit", "initial", "unset",
+ "serif", "sans-serif", "monospace", "cursive", "fantasy",
+ "system-ui", "ui-serif", "ui-sans-serif", "ui-monospace", "ui-rounded",
+ "emoji", "math", "fangsong",
+ ].map((s) => s.toLowerCase())
+);
+
+const MAX_STYLESHEETS_TO_FETCH = 10;
+const STYLESHEET_FETCH_TIMEOUT_MS = 4000;
+
+/** Normalize font family: trim, strip quotes, take first in stack, reject generics. */
+function normalizeFamily(raw: string): string | null {
+ const trimmed = raw.trim().replace(/^["']|["']$/g, "").split(",")[0].trim();
+ if (!trimmed || trimmed.length < 2) return null;
+ if (GENERIC_FAMILIES.has(trimmed.toLowerCase())) return null;
+ return trimmed;
+}
+
+/**
+ * Clean build-time/hashed font names for display:
+ * __satoshi_e99f3e → Satoshi, __Instrument_Serif_315a98 → Instrument Serif,
+ * __Instrument_Serif_Fallback_315a98 → Instrument Serif
+ */
+function cleanFontFamilyDisplay(raw: string): string {
+ let s = raw.trim();
+ s = s.replace(/^__+/, "");
+ s = s.replace(/_Fallback(?:_[a-f0-9]+)?$/i, "");
+ s = s.replace(/_[a-f0-9]{5,}$/i, "");
+ s = s.replace(/_/g, " ").replace(/\s+/g, " ").trim();
+ if (!s) return raw;
+ return s.split(" ").map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()).join(" ");
+}
+
+/** Classify font source from URL */
+function classifyFontSource(url: string, baseUrl: string): InternalFont["source"] {
+ const lower = url.toLowerCase();
+ if (lower.includes("fonts.gstatic.com") || lower.includes("fonts.googleapis.com")) return "google_fonts";
+ if (lower.includes("fontshare.com") || lower.includes("api.fontshare.com")) return "fontshare";
+ if (lower.includes("dafont.com") || lower.includes("fonts.cdnfonts.com")) return "unknown"; // treat as "find on web"
+ try {
+ const fontUrl = new URL(url, baseUrl);
+ const base = new URL(baseUrl);
+ if (fontUrl.origin === base.origin) return "private";
+ } catch {}
+ return "private";
+}
+
+/** Extract family names from Google Fonts stylesheet URL (css: family=A|B, css2: family=A&family=B) */
+function parseGoogleFontFamilies(href: string): string[] {
+ const families: string[] = [];
+ try {
+ const u = new URL(href, "https://fonts.googleapis.com");
+ const params = u.searchParams.getAll("family");
+ if (params.length === 0) {
+ const single = u.searchParams.get("family");
+ if (single) params.push(single);
+ }
+ for (const familyParam of params) {
+ const parts = familyParam.split("|");
+ for (const part of parts) {
+ const name = part.split(":")[0].trim().replace(/\+/g, " ");
+ const norm = normalizeFamily(name);
+ if (norm && !families.includes(norm)) families.push(norm);
+ }
+ }
+ } catch {}
+ return families;
+}
+
+/** Extract font family from Fontshare URL or default to null */
+function parseFontshareFamily(href: string): string | null {
+ try {
+ const u = new URL(href, "https://api.fontshare.com");
+ const path = u.pathname.replace(/^\//, "").split("/")[0];
+ if (path) return path.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
+ } catch {}
+ return null;
+}
+
+/** Fetch external stylesheet content (for parsing @font-face and font-family). */
+async function fetchStylesheet(url: string): Promise {
+ try {
+ const res = await fetch(url, {
+ headers: { "User-Agent": USER_AGENT, Accept: "text/css,*/*;q=0.1" },
+ signal: AbortSignal.timeout(STYLESHEET_FETCH_TIMEOUT_MS),
+ });
+ if (!res.ok) return null;
+ return res.text();
+ } catch {
+ return null;
+ }
+}
+
+/** Parse CSS string for @font-face blocks; returns array of InternalFont with cleaned family. */
+function parseFontFaceFromCss(css: string, baseUrl: string): InternalFont[] {
+ const out: InternalFont[] = [];
+ FONT_FACE_RE.lastIndex = 0;
+ let block: RegExpExecArray | null;
+ while ((block = FONT_FACE_RE.exec(css)) !== null) {
+ const decl = block[1];
+ const familyMatch = decl.match(FONT_FAMILY_RE);
+ const rawFamily = familyMatch
+ ? normalizeFamily(familyMatch[1].trim().replace(/^["']|["']$/g, "").split(",")[0].trim())
+ : null;
+ if (!rawFamily) continue;
+ const family = cleanFontFamilyDisplay(rawFamily);
+
+ const srcMatch = decl.match(FONT_SRC_RE);
+ let source: InternalFont["source"] = "private";
+ let sourceUrl: string | undefined;
+ if (srcMatch) {
+ URL_RE.lastIndex = 0;
+ let urlMatch: RegExpExecArray | null;
+ while ((urlMatch = URL_RE.exec(srcMatch[1])) !== null) {
+ const url = urlMatch[1].trim();
+ if (url.startsWith("data:")) continue;
+ const resolved = resolveUrl(url, baseUrl);
+ if (resolved) {
+ sourceUrl = resolved;
+ source = classifyFontSource(resolved, baseUrl);
+ break;
+ }
+ }
+ }
+ out.push({ family, sourceUrl, source });
+ }
+ return out;
+}
+
+/** Parse CSS string for font-family declarations (first name in stack only). Returns all occurrences for usage counting. */
+function parseFontFamilyDeclarationsFromCss(css: string): string[] {
+ const families: string[] = [];
+ FONT_FAMILY_DECL_RE.lastIndex = 0;
+ let m: RegExpExecArray | null;
+ while ((m = FONT_FAMILY_DECL_RE.exec(css)) !== null) {
+ const name = (m[1] ?? m[2] ?? "").trim();
+ const norm = normalizeFamily(name);
+ if (norm) families.push(norm);
+ }
+ return families;
+}
+
+/** Prefer higher-confidence source when merging (google_fonts > fontshare > private > unknown). */
+function sourcePriority(s: InternalFont["source"]): number {
+ switch (s) {
+ case "google_fonts": return 3;
+ case "fontshare": return 2;
+ case "private": return 1;
+ default: return 0;
+ }
+}
+
+/** Google Fonts specimen URL for a family name */
+function googleFontsSpecimenUrl(family: string): string {
+ const slug = encodeURIComponent(family).replace(/%20/g, "+");
+ return `https://fonts.google.com/specimen/${slug}`;
+}
+
+/** Try to resolve font from Google Fonts; returns specimen URL if the font exists. */
+async function resolveFontUrlFromGoogle(family: string): Promise {
+ try {
+ const encoded = encodeURIComponent(family).replace(/%20/g, "+");
+ const res = await fetch(
+ `https://fonts.googleapis.com/css2?family=${encoded}&display=swap`,
+ { headers: { "User-Agent": USER_AGENT }, signal: AbortSignal.timeout(3000) }
+ );
+ if (!res.ok) return null;
+ const css = await res.text();
+ if (!css.includes("@font-face")) return null;
+ return googleFontsSpecimenUrl(family);
+ } catch {
+ return null;
+ }
+}
+
+async function extractFonts(
+ $: cheerio.CheerioAPI,
+ html: string,
+ baseUrl: string
+): Promise {
+ const byFamily = new Map();
+ const countByKey = new Map();
+
+ function ensureFont(asset: InternalFont) {
+ const key = asset.family.toLowerCase();
+ const existing = byFamily.get(key);
+ if (!existing) {
+ byFamily.set(key, asset);
+ return;
+ }
+ const ep = sourcePriority(existing.source);
+ const np = sourcePriority(asset.source);
+ if (np > ep) {
+ byFamily.set(key, asset);
+ return;
+ }
+ if (np < ep) return;
+ // Same priority: never overwrite when we'd lose sourceUrl (website font file URL)
+ if (existing.sourceUrl && !asset.sourceUrl) return;
+ byFamily.set(key, asset);
+ }
+
+ function countFont(family: string) {
+ const key = family.toLowerCase();
+ countByKey.set(key, (countByKey.get(key) ?? 0) + 1);
+ }
+
+ // ── 1. Google Fonts & Fontshare from ──
+ $('link[rel="stylesheet"]').each((_, el) => {
+ const href = $(el).attr("href");
+ if (!href) return;
+ const resolved = resolveUrl(href, baseUrl);
+ if (!resolved) return;
+ const lower = resolved.toLowerCase();
+ if (lower.includes("fonts.googleapis.com")) {
+ const families = parseGoogleFontFamilies(resolved);
+ for (const name of families) {
+ const family = cleanFontFamilyDisplay(name);
+ if (family) {
+ ensureFont({ family, sourceUrl: resolved, source: "google_fonts" });
+ countFont(family);
+ }
+ }
+ }
+ if (lower.includes("fontshare.com") || lower.includes("api.fontshare.com")) {
+ const name = parseFontshareFamily(resolved) || "Fontshare font";
+ const family = name ? cleanFontFamilyDisplay(name) : null;
+ if (family) {
+ ensureFont({ family, sourceUrl: resolved, source: "fontshare" });
+ countFont(family);
+ }
+ }
+ });
+
+ // ── 2. Inline