import { mkdir, readFile, writeFile, rm } from "node:fs/promises";
import path from "node:path";
type Image = { url: string; etag: string };
type Brand = {
id: string;
name: string;
logo: Image | null;
brandmark: Image | null;
social_banners: Image[];
colors: Color[];
};
type Color = { hex: string; oklch: { l: number; c: number; h: number } };
type Theme = { accent: string; onAccent: string };
// variant records the requested size and theme, so a new size replaces old files.
type StoredImage = { etag: string; variant: string; path: string };
type BrandKit = {
brandId: string;
name: string;
logo: StoredImage | null;
brandmark: StoredImage | null;
banners: StoredImage[];
colors: string[];
theme: Theme;
};
const ROOT = path.join(process.cwd(), "public", "brand-kits");
const DEFAULT_THEME: Theme = { accent: "#18181b", onAccent: "#ffffff" };
// Colors come most dominant first. Take the first one with enough chroma to
// work as an accent. Tune 0.08 to your design; neutrals are close to 0.
function pickAccent(colors: Color[]): Theme {
const accent = colors.find((color) => color.oklch.c >= 0.08);
if (!accent) {
return DEFAULT_THEME;
}
// Light accents, such as yellow or bright green, need dark text.
return {
accent: accent.hex,
onAccent: accent.oklch.l > 0.7 ? "#18181b" : "#ffffff",
};
}
async function readKit(domain: string): Promise<BrandKit | null> {
try {
return JSON.parse(await readFile(path.join(ROOT, domain, "kit.json"), "utf8"));
} catch {
return null;
}
}
// Download an image only when its etag or the requested variant changed
// since the last import. The etag does not change with the size or theme.
// Download parameters: https://www.logo.dev/docs/brand/introduction#images
async function storeImage(
domain: string,
slot: string,
image: Image,
previous: StoredImage | null | undefined,
size: number,
theme?: "light" | "dark"
): Promise<StoredImage> {
const variant = `${size}-${theme ?? "default"}`;
if (previous?.etag === image.etag && previous.variant === variant) {
return previous;
}
// The signature in the URL is the credential, so send no key.
const url = new URL(image.url);
url.searchParams.set("format", "png");
// Without size, the download is a small default. An editor needs larger files.
url.searchParams.set("size", String(size));
if (theme) {
url.searchParams.set("theme", theme);
}
const res = await fetch(url);
if (!res.ok) {
throw new Error(`Download failed: ${res.status}`);
}
const file = `${slot}-${variant}-${image.etag.slice(0, 16)}.png`;
await writeFile(path.join(ROOT, domain, file), Buffer.from(await res.arrayBuffer()));
return { etag: image.etag, variant, path: `/brand-kits/${domain}/${file}` };
}
export async function GET(request: Request) {
const domain = new URL(request.url).searchParams.get("domain")?.trim().toLowerCase();
if (!domain || !/^[a-z0-9.-]+\.[a-z]{2,}$/.test(domain)) {
return Response.json({ status: "error" }, { status: 400 });
}
const res = await fetch(
`https://api.logo.dev/v2/brands?domain=${encodeURIComponent(domain)}`,
{ headers: { Authorization: `Bearer ${process.env.LOGO_DEV_SECRET_KEY}` } }
);
// Not indexed yet: https://www.logo.dev/docs/brand/introduction#handle-each-status
if (res.status === 202) {
// Retry timing and the retry window:
// https://www.logo.dev/docs/platform/errors#not-found-vs-still-indexing-202
const body = await res.json().catch(() => null);
const pending = body?.metadata?.pending;
const retryAfter = Number(res.headers.get("Retry-After")) || pending?.retry_after_seconds || 30;
const expiresIn = Number(pending?.expires_in_seconds) || 0;
return Response.json({ status: "pending", retryAfter, expiresIn }, { status: 202 });
}
if (res.status === 404) {
return Response.json({ status: "not_found" });
}
if (!res.ok) {
return Response.json({ status: "error" }, { status: 502 });
}
const { data }: { data: Brand } = await res.json();
const previous = await readKit(domain);
await mkdir(path.join(ROOT, domain), { recursive: true });
const kit: BrandKit = {
brandId: data.id,
name: data.name,
logo: data.logo && (await storeImage(domain, "logo", data.logo, previous?.logo, 512)),
// The tray shows brandmarks on white, so request the light-background version.
brandmark:
data.brandmark &&
(await storeImage(domain, "brandmark", data.brandmark, previous?.brandmark, 800, "light")),
banners: await Promise.all(
data.social_banners
.slice(0, 1)
.map((banner, i) => storeImage(domain, `banner-${i}`, banner, previous?.banners[i], 800))
),
colors: data.colors.map((color) => color.hex),
theme: pickAccent(data.colors),
};
await writeFile(path.join(ROOT, domain, "kit.json"), JSON.stringify(kit));
// Delete files the new kit no longer uses, such as an image that changed.
const kept = new Set(
[kit.logo, kit.brandmark, ...kit.banners].flatMap((image) => (image ? [image.path] : []))
);
const old = previous ? [previous.logo, previous.brandmark, ...previous.banners] : [];
for (const image of old) {
if (image && !kept.has(image.path)) {
await rm(path.join(ROOT, domain, path.basename(image.path)), { force: true });
}
}
return Response.json({ status: "ready", kit });
}