Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
217 changes: 217 additions & 0 deletions apps/mesh/migrations/066-brand-context-structured.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
/**
* Migration 065: Restructure brand_context colors and fonts
*
* Transforms legacy JSON shapes into structured semantic objects:
* - colors: [{label:"primary",value:"#fff"},...] → {"primary":"#fff",...}
* - fonts: [{name:"Inter",role:"heading"},...] → {"heading":"Inter",...}
*
* Idempotent — skips rows that are already in the new format.
*/
import type { Kysely } from "kysely";
import { sql } from "kysely";

const COLOR_ROLES = new Set([
"primary",
"secondary",
"accent",
"background",
"foreground",
]);

const FONT_ROLE_MAP: Record<string, string> = {
heading: "heading",
headings: "heading",
head: "heading",
title: "heading",
body: "body",
primary: "body",
text: "body",
code: "code",
monospace: "code",
mono: "code",
};

function transformColors(raw: unknown): {
structured: string | null;
extra: Record<string, string>;
} {
const extra: Record<string, string> = {};
if (!raw) return { structured: null, extra };

// Already structured object with known keys
if (!Array.isArray(raw) && typeof raw === "object") {
const obj = raw as Record<string, unknown>;
if (Object.keys(obj).some((k) => COLOR_ROLES.has(k))) {
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(obj)) {
if (typeof value !== "string") continue;
if (COLOR_ROLES.has(key)) {
result[key] = value;
} else {
extra[key] = value;
}
}
return {
structured:
Object.keys(result).length > 0 ? JSON.stringify(result) : null,
extra,
};
}
// Legacy Record<string,string>
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(obj)) {
if (typeof value !== "string") continue;
if (COLOR_ROLES.has(key.toLowerCase())) {
result[key.toLowerCase()] = value;
} else {
extra[key] = value;
}
}
return {
structured:
Object.keys(result).length > 0 ? JSON.stringify(result) : null,
extra,
};
}

// Legacy array
if (Array.isArray(raw)) {
const result: Record<string, string> = {};
for (const item of raw) {
const entry = item as Record<string, unknown>;
const label = (entry.label as string)?.toLowerCase?.();
const value = entry.value as string;
if (!label || !value) continue;
if (COLOR_ROLES.has(label)) {
result[label] = value;
} else {
extra[label] = value;
}
}
return {
structured:
Object.keys(result).length > 0 ? JSON.stringify(result) : null,
extra,
};
}

return { structured: null, extra };
}

function transformFonts(raw: unknown): string | null {
if (!raw) return null;

// Already structured
if (!Array.isArray(raw) && typeof raw === "object") {
const obj = raw as Record<string, unknown>;
if (
typeof obj.heading === "string" ||
typeof obj.body === "string" ||
typeof obj.code === "string"
) {
return JSON.stringify(obj);
}
return null;
}

// Legacy array — two-pass: first assign explicitly mapped roles,
// then fill remaining slots with unmapped entries
if (Array.isArray(raw)) {
const result: Record<string, string> = {};
const unmapped: string[] = [];
for (const item of raw) {
const entry = item as Record<string, unknown>;
const name =
(entry.name as string) ?? (entry.family as string) ?? undefined;
const role = (entry.role as string)?.toLowerCase?.() ?? "";
if (!name) continue;
const mapped = FONT_ROLE_MAP[role];
if (mapped && !result[mapped]) {
result[mapped] = name;
} else {
unmapped.push(name);
}
}
// Fill body slot with first unmapped font if not already assigned
if (!result.body && unmapped[0]) {
result.body = unmapped[0];
}
return Object.keys(result).length > 0 ? JSON.stringify(result) : null;
}

return null;
}

export async function up(db: Kysely<unknown>): Promise<void> {
const rows = await sql<{
id: string;
colors: string | null;
fonts: string | null;
metadata: string | null;
}>`SELECT id, colors, fonts, metadata FROM brand_context`.execute(db);

for (const row of rows.rows) {
let colorsChanged = false;
let fontsChanged = false;
let metadataChanged = false;
let newColors: string | null = row.colors;
let newFonts: string | null = row.fonts;
let metadata: Record<string, unknown> = {};

try {
metadata = row.metadata ? JSON.parse(row.metadata) : {};
} catch {
// skip unparseable metadata
}

if (row.colors) {
try {
const parsed = JSON.parse(row.colors);
// Only transform if it's an array (legacy format)
if (Array.isArray(parsed)) {
const result = transformColors(parsed);
newColors = result.structured;
colorsChanged = true;
// Preserve unmapped colors in metadata so no data is lost
if (Object.keys(result.extra).length > 0) {
metadata.extraColors = result.extra;
metadataChanged = true;
}
}
} catch {
// skip unparseable
}
}

if (row.fonts) {
try {
const parsed = JSON.parse(row.fonts);
if (Array.isArray(parsed)) {
newFonts = transformFonts(parsed);
fontsChanged = true;
}
} catch {
// skip unparseable
}
}

if (colorsChanged || fontsChanged || metadataChanged) {
const newMetadata = metadataChanged
? JSON.stringify(metadata)
: row.metadata;
await sql`
UPDATE brand_context
SET
colors = ${newColors},
fonts = ${newFonts},
metadata = ${newMetadata},
updated_at = NOW()
WHERE id = ${row.id}
`.execute(db);
}
}
}

export async function down(_db: Kysely<unknown>): Promise<void> {
// Data transformation is not reversible — old format data is lost
}
2 changes: 2 additions & 0 deletions apps/mesh/migrations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ import * as migration062privateregistry from "./062-private-registry.ts";
import * as migration063eventsubscriptionsenabledboolean from "./063-event-subscriptions-enabled-boolean.ts";
import * as migration064brandcontext from "./064-brand-context.ts";
import * as migration065organizationdomains from "./065-organization-domains.ts";
import * as migration066brandcontextstructured from "./066-brand-context-structured.ts";

/**
* Core migrations for the Mesh application.
Expand Down Expand Up @@ -142,6 +143,7 @@ const migrations: Record<string, Migration> = {
migration063eventsubscriptionsenabledboolean,
"064-brand-context": migration064brandcontext,
"065-organization-domains": migration065organizationdomains,
"066-brand-context-structured": migration066brandcontextstructured,
};

export default migrations;
80 changes: 57 additions & 23 deletions apps/mesh/src/auth/extract-brand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,18 @@ export interface ExtractedBrand {
logo: string | null;
favicon: string | null;
ogImage: string | null;
fonts: { name: string; role: string }[] | null;
colors: { label: string; value: string }[] | null;
fonts: {
heading?: string;
body?: string;
code?: string;
} | null;
colors: {
primary?: string;
secondary?: string;
accent?: string;
background?: string;
foreground?: string;
} | null;
images: null;
metadata: Record<string, unknown> | null;
}
Expand Down Expand Up @@ -83,8 +93,8 @@ export async function extractBrandFromDomain(
logo: mapped.logo,
favicon: mapped.favicon,
ogImage: mapped.ogImage,
fonts: mapped.fonts.length > 0 ? mapped.fonts : null,
colors: mapped.colors.length > 0 ? mapped.colors : null,
fonts: mapped.fonts,
colors: mapped.colors,
images: null,
metadata: Object.keys(mapped.metadata).length > 0 ? mapped.metadata : null,
};
Expand All @@ -94,53 +104,73 @@ export async function extractBrandFromDomain(
// Internal helpers
// ============================================================================

const COLOR_ROLES = new Set([
"primary",
"secondary",
"accent",
"background",
"foreground",
]);

const FONT_ROLE_MAP: Record<string, string> = {
heading: "heading",
headings: "heading",
head: "heading",
title: "heading",
body: "body",
primary: "body",
text: "body",
code: "code",
monospace: "code",
mono: "code",
};

function mapFirecrawlBranding(
branding: Record<string, unknown>,
metadata: Record<string, unknown>,
): {
logo: string | null;
favicon: string | null;
ogImage: string | null;
fonts: { name: string; role: string }[];
colors: { label: string; value: string }[];
fonts: ExtractedBrand["fonts"];
colors: ExtractedBrand["colors"];
metadata: Record<string, unknown>;
} {
const images = (branding.images ?? {}) as Record<string, unknown>;

// Colors: pick known semantic roles from branding.colors
const rawColors = (branding.colors ?? {}) as Record<string, unknown>;
const colors: { label: string; value: string }[] = [];
for (const [label, value] of Object.entries(rawColors)) {
if (typeof value === "string" && value) {
colors.push({ label, value });
const colors: Record<string, string> = {};
for (const [key, value] of Object.entries(rawColors)) {
if (typeof value === "string" && value && COLOR_ROLES.has(key)) {
colors[key] = value;
}
}

const fonts: { name: string; role: string }[] = [];
// Fonts: map fontFamilies roles to semantic roles
const fonts: Record<string, string> = {};
const typography = (branding.typography ?? {}) as Record<string, unknown>;
const fontFamilies = (typography.fontFamilies ?? {}) as Record<
string,
unknown
>;

const seenFamilies = new Set<string>();
for (const [role, family] of Object.entries(fontFamilies)) {
if (typeof family === "string" && family) {
fonts.push({ name: family, role });
seenFamilies.add(family.toLowerCase());
const mapped = FONT_ROLE_MAP[role.toLowerCase()];
if (mapped && !fonts[mapped]) {
fonts[mapped] = family;
}
}
}

// Fallback: additional fonts from the fonts array
const rawFonts = branding.fonts;
if (Array.isArray(rawFonts)) {
for (const f of rawFonts) {
const family = (f as Record<string, unknown>).family;
if (
typeof family === "string" &&
family &&
!seenFamilies.has(family.toLowerCase())
) {
fonts.push({ name: family, role: "" });
seenFamilies.add(family.toLowerCase());
if (typeof family === "string" && family && !fonts.body) {
fonts.body = family;
}
}
}
Expand All @@ -166,8 +196,12 @@ function mapFirecrawlBranding(
logo: (images.logo as string) ?? null,
favicon: (images.favicon as string) ?? null,
ogImage: (images.ogImage as string) ?? (metadata.ogImage as string) ?? null,
fonts,
colors,
fonts:
Object.keys(fonts).length > 0 ? (fonts as ExtractedBrand["fonts"]) : null,
colors:
Object.keys(colors).length > 0
? (colors as ExtractedBrand["colors"])
: null,
metadata: richMetadata,
};
}
Loading
Loading