{
  "$schema": "https://blode.co/ui/schema/registry-item.json",
  "name": "google-fonts",
  "title": "Google Fonts",
  "author": "Matthew Blode",
  "description": "Fetch and preview Google Fonts, with stylesheet injection and cleanup.",
  "files": [
    {
      "path": "lib/google-fonts.ts",
      "content": "const WEBFONTS_ENDPOINT = \"https://www.googleapis.com/webfonts/v1/webfonts\";\nconst STYLESHEET_ENDPOINT = \"https://fonts.googleapis.com/css2\";\nconst PREVIEW_ATTRIBUTE = \"data-google-font\";\n\n/** The five families Google groups its catalogue into. */\ntype GoogleFontCategory = \"display\" | \"handwriting\" | \"monospace\" | \"sans-serif\" | \"serif\";\n\n/** Order the API returns families in. */\ntype GoogleFontSort = \"alpha\" | \"popularity\" | \"style\" | \"trending\";\n\n/** One family, narrowed to the fields a picker actually needs. */\ninterface GoogleFont {\n  category: GoogleFontCategory;\n  family: string;\n  subsets: string[];\n  variants: string[];\n}\n\n/** Why a fetch failed, in terms a UI can act on. */\ntype GoogleFontsErrorCode = \"missing-key\" | \"network\" | \"rate-limited\" | \"request-failed\";\n\n/** Thrown by `fetchGoogleFonts`. `code` is what to branch a UI state on. */\nclass GoogleFontsError extends Error {\n  readonly code: GoogleFontsErrorCode;\n  readonly status?: number;\n\n  constructor(message: string, code: GoogleFontsErrorCode, status?: number) {\n    super(message);\n    this.code = code;\n    this.name = \"GoogleFontsError\";\n    this.status = status;\n  }\n}\n\ninterface FetchGoogleFontsOptions {\n  /** A Google Fonts Developer API key. Ignored when `endpoint` is set. */\n  apiKey?: string;\n  /** Keeps only these categories. Defaults to every category. */\n  categories?: GoogleFontCategory[];\n  /**\n   * A URL that proxies the Google Fonts Developer API. Takes precedence over\n   * `apiKey`, and no key is sent — the proxy holds it. `sort` is forwarded as a\n   * query parameter. See the module comment for the response shape.\n   */\n  endpoint?: string;\n  /** Caps how many families come back, applied after filtering. */\n  limit?: number;\n  /** Aborts the request. */\n  signal?: AbortSignal;\n  /** Order to request. Defaults to `\"popularity\"`. */\n  sort?: GoogleFontSort;\n  /** Keeps only families covering every one of these subsets. */\n  subsets?: string[];\n  /** Keeps only families offering every one of these variants. */\n  variants?: string[];\n}\n\ninterface RawGoogleFont {\n  category?: string;\n  family?: string;\n  subsets?: string[];\n  variants?: string[];\n}\n\nconst CATEGORIES = new Set<string>([\"display\", \"handwriting\", \"monospace\", \"sans-serif\", \"serif\"]);\n\nconst coversAll = (available: string[], required?: string[]) =>\n  !required?.length || required.every((entry) => available.includes(entry));\n\n/** Narrows one raw API row to a `GoogleFont`, or null if it fails the filters. */\nconst narrowFont = (\n  item: RawGoogleFont,\n  categories: Set<string> | null,\n  subsets?: string[],\n  variants?: string[],\n): GoogleFont | null => {\n  const category = item.category ?? \"\";\n  if (!item.family || !CATEGORIES.has(category)) {\n    return null;\n  }\n  if (categories && !categories.has(category)) {\n    return null;\n  }\n  const availableSubsets = item.subsets ?? [];\n  const availableVariants = item.variants ?? [];\n  if (!coversAll(availableSubsets, subsets) || !coversAll(availableVariants, variants)) {\n    return null;\n  }\n  return {\n    category: category as GoogleFontCategory,\n    family: item.family,\n    subsets: availableSubsets,\n    variants: availableVariants,\n  };\n};\n\n/**\n * The URL to ask for the catalogue. A proxy carries no key — that is the whole\n * point of it — so the key is only ever appended to Google's own endpoint.\n * Throws `missing-key` when there is nothing to call.\n */\nconst catalogueUrl = (endpoint: string | undefined, apiKey: string | undefined, sort: string) => {\n  if (endpoint) {\n    // A relative `endpoint` is the common case for a same-origin proxy, and\n    // `new URL` needs a base to resolve one against. There is no such base off\n    // the browser, so say that rather than letting `new URL` throw a bare\n    // TypeError that callers cannot branch on.\n    const base = typeof document === \"undefined\" ? undefined : document.baseURI;\n    if (!(base || URL.canParse(endpoint))) {\n      throw new GoogleFontsError(\n        `A relative endpoint (\"${endpoint}\") cannot be resolved outside a browser. Pass an absolute URL when calling from the server.`,\n        \"request-failed\",\n      );\n    }\n    const proxied = new URL(endpoint, base);\n    proxied.searchParams.set(\"sort\", sort);\n    return proxied;\n  }\n\n  if (!apiKey) {\n    throw new GoogleFontsError(\n      \"A Google Fonts API key or a proxy endpoint is required.\",\n      \"missing-key\",\n    );\n  }\n\n  const direct = new URL(WEBFONTS_ENDPOINT);\n  direct.searchParams.set(\"key\", apiKey);\n  direct.searchParams.set(\"sort\", sort);\n  return direct;\n};\n\n/**\n * Fetches the catalogue and narrows it. Rejects with a `GoogleFontsError` for\n * everything except an abort, which rejects with the original `AbortError` so\n * callers can ignore it the usual way.\n */\nconst fetchGoogleFonts = async ({\n  apiKey,\n  categories,\n  endpoint,\n  limit,\n  signal,\n  sort = \"popularity\",\n  subsets,\n  variants,\n}: FetchGoogleFontsOptions): Promise<GoogleFont[]> => {\n  const url = catalogueUrl(endpoint, apiKey, sort);\n\n  let response: Response;\n  try {\n    response = await fetch(url, { signal });\n  } catch (error) {\n    if (error instanceof DOMException && error.name === \"AbortError\") {\n      throw error;\n    }\n    throw new GoogleFontsError(\"Could not reach the Google Fonts API.\", \"network\");\n  }\n\n  if (!response.ok) {\n    throw new GoogleFontsError(\n      response.status === 429\n        ? \"The Google Fonts API rate limit has been reached.\"\n        : `The Google Fonts API returned ${response.status}.`,\n      response.status === 429 ? \"rate-limited\" : \"request-failed\",\n      response.status,\n    );\n  }\n\n  const payload = (await response.json()) as { items?: RawGoogleFont[] };\n  const allowed = categories?.length ? new Set<string>(categories) : null;\n\n  const fonts: GoogleFont[] = [];\n  for (const item of payload.items ?? []) {\n    const font = narrowFont(item, allowed, subsets, variants);\n    if (!font) {\n      continue;\n    }\n    fonts.push(font);\n    if (limit !== undefined && fonts.length >= limit) {\n      break;\n    }\n  }\n\n  return fonts;\n};\n\n/** Slug for a family, stable enough to key a stylesheet element by. */\nconst googleFontId = (family: string) => family.trim().toLowerCase().replaceAll(/\\s+/gu, \"-\");\n\n/** The `css2` stylesheet URL for one family, at its default weight. */\nconst googleFontStylesheetHref = (family: string) =>\n  `${STYLESHEET_ENDPOINT}?family=${encodeURIComponent(family).replaceAll(\"%20\", \"+\")}&display=swap`;\n\n/**\n * Reference counted so two pickers previewing the same family share one\n * `<link>`, and so releasing one of them does not pull the stylesheet out from\n * under the other.\n */\nconst previews = new Map<string, { count: number; link: HTMLLinkElement }>();\n\nconst noop = () => {\n  // Nothing was injected, so nothing needs releasing.\n};\n\n/**\n * Adds a preview stylesheet for `family` to `<head>`, and returns the function\n * that releases it. Call the returned function on unmount — an unreleased\n * preview stays in `<head>` for the life of the page.\n */\nconst loadGoogleFontPreview = (family: string): (() => void) => {\n  if (typeof document === \"undefined\") {\n    return noop;\n  }\n\n  const id = googleFontId(family);\n  const existing = previews.get(id);\n  if (existing) {\n    existing.count += 1;\n  } else {\n    const link = document.createElement(\"link\");\n    link.href = googleFontStylesheetHref(family);\n    link.rel = \"stylesheet\";\n    link.setAttribute(PREVIEW_ATTRIBUTE, id);\n    document.head.append(link);\n    previews.set(id, { count: 1, link });\n  }\n\n  let released = false;\n  return () => {\n    if (released) {\n      return;\n    }\n    released = true;\n    const entry = previews.get(id);\n    if (!entry) {\n      return;\n    }\n    entry.count -= 1;\n    if (entry.count <= 0) {\n      entry.link.remove();\n      previews.delete(id);\n    }\n  };\n};\n\nexport {\n  fetchGoogleFonts,\n  googleFontId,\n  googleFontStylesheetHref,\n  GoogleFontsError,\n  loadGoogleFontPreview,\n};\nexport type {\n  FetchGoogleFontsOptions,\n  GoogleFont,\n  GoogleFontCategory,\n  GoogleFontsErrorCode,\n  GoogleFontSort,\n};\n",
      "type": "registry:lib",
      "target": ""
    }
  ],
  "type": "registry:lib"
}
