import type { NostrEvent } from '@nostrify/nostrify'; import type { CoreThemeColors, ThemeConfig, ThemeFont, ThemeBackground } from '@/themes'; import { hslStringToHex, hexToHslString } from '@/lib/colorUtils'; // ─── Kind Constants ─────────────────────────────────────────────────── /** Addressable event: a shareable, named theme definition. Multiple per user. */ export const THEME_DEFINITION_KIND = 36767; /** Replaceable event: the user's currently active profile theme. One per user. */ export const ACTIVE_THEME_KIND = 16767; // ─── Color Tag Helpers ──────────────────────────────────────────────── /** Color role markers used in `c` tags. */ type ColorRole = 'primary' | 'text' | 'background'; /** Build `c` tags from CoreThemeColors (HSL → hex conversion). */ function buildColorTags(colors: CoreThemeColors): string[][] { const roles: ColorRole[] = ['background', 'text', 'primary']; return roles.map((role) => ['c', hslStringToHex(colors[role]), role]); } /** * Parse `c` tags into CoreThemeColors. * Returns null if any of the 3 required roles are missing. */ function parseColorTags(tags: string[][]): CoreThemeColors | null { const colorMap = new Map(); for (const tag of tags) { if (tag[0] === 'c' && tag[1] && tag[2]) { colorMap.set(tag[2], tag[1]); } } const bgHex = colorMap.get('background'); const textHex = colorMap.get('text'); const primaryHex = colorMap.get('primary'); if (!bgHex || !textHex || !primaryHex) return null; return { background: hexToHslString(bgHex), text: hexToHslString(textHex), primary: hexToHslString(primaryHex), }; } // ─── Font Tag Helpers ───────────────────────────────────────────────── /** Build `f` tags from body and title fonts. Body tag is always ordered before title tag. */ function buildFontTags(font: ThemeFont | undefined, titleFont: ThemeFont | undefined): string[][] { const tags: string[][] = []; if (font?.family) { const tag = ['f', font.family]; if (font.url) tag.push(font.url); else tag.push(''); tag.push('body'); tags.push(tag); } if (titleFont?.family) { const tag = ['f', titleFont.family]; if (titleFont.url) tag.push(titleFont.url); else tag.push(''); tag.push('title'); tags.push(tag); } return tags; } /** Parse `f` tags into body and title ThemeFonts. Legacy tags without a role are treated as body. */ function parseFontTags(tags: string[][]): { font?: ThemeFont; titleFont?: ThemeFont } { let font: ThemeFont | undefined; let titleFont: ThemeFont | undefined; for (const tag of tags) { if (tag[0] !== 'f' || !tag[1]) continue; const role = tag[3]; // 4th element: "body", "title", or absent (legacy) const parsed: ThemeFont = { family: tag[1] }; if (tag[2]) parsed.url = tag[2]; if (role === 'title') { if (!titleFont) titleFont = parsed; } else { // "body" or absent (legacy) — treat as body font if (!font) font = parsed; } } return { font, titleFont }; } // ─── Background Tag Helpers ─────────────────────────────────────────── /** Build a `bg` tag from ThemeBackground (imeta-style variadic). */ function buildBackgroundTag(bg: ThemeBackground | undefined): string[][] { if (!bg?.url) return []; const entries: string[] = ['bg', `url ${bg.url}`]; if (bg.mode) entries.push(`mode ${bg.mode}`); if (bg.mimeType) entries.push(`m ${bg.mimeType}`); if (bg.dimensions) entries.push(`dim ${bg.dimensions}`); if (bg.blurhash) entries.push(`blurhash ${bg.blurhash}`); return [entries]; } /** Parse a `bg` tag into ThemeBackground. Returns undefined if no bg tag. */ function parseBackgroundTag(tags: string[][]): ThemeBackground | undefined { const bgTag = tags.find(([n]) => n === 'bg'); if (!bgTag) return undefined; const kv = new Map(); for (let i = 1; i < bgTag.length; i++) { const entry = bgTag[i]; const spaceIdx = entry.indexOf(' '); if (spaceIdx === -1) continue; kv.set(entry.slice(0, spaceIdx), entry.slice(spaceIdx + 1)); } const url = kv.get('url'); if (!url) return undefined; const bg: ThemeBackground = { url }; const mode = kv.get('mode'); if (mode === 'cover' || mode === 'tile') bg.mode = mode; bg.mimeType = kv.get('m'); bg.dimensions = kv.get('dim'); bg.blurhash = kv.get('blurhash'); return bg; } // ─── Theme Definition (Kind 36767) ──────────────────────────────────── export interface ThemeDefinition { /** The d-tag identifier (slug) */ identifier: string; /** Theme title */ title: string; /** Optional description */ description?: string; /** The 3 core theme colors */ colors: CoreThemeColors; /** Optional custom body font */ font?: ThemeFont; /** Optional title/header font (profile display name) */ titleFont?: ThemeFont; /** Optional background */ background?: ThemeBackground; /** The original Nostr event */ event: NostrEvent; } /** Parse and validate a kind 36767 theme definition event. Returns null if invalid. */ export function parseThemeDefinition(event: NostrEvent): ThemeDefinition | null { if (event.kind !== THEME_DEFINITION_KIND) return null; const identifier = event.tags.find(([n]) => n === 'd')?.[1]; if (!identifier) return null; const title = event.tags.find(([n]) => n === 'title')?.[1]; if (!title) return null; const description = event.tags.find(([n]) => n === 'description')?.[1]; // Try new format: colors in `c` tags, content is empty let colors = parseColorTags(event.tags); // Fall back to legacy format: colors as JSON in content if (!colors && event.content) { try { const parsed = JSON.parse(event.content); colors = normalizeLegacyColors(parsed); } catch { // Invalid JSON } } if (!colors) return null; const { font, titleFont } = parseFontTags(event.tags); const background = parseBackgroundTag(event.tags); return { identifier, title, description, colors, font, titleFont, background, event }; } /** Create tags for a kind 36767 theme definition event. */ export function buildThemeDefinitionTags( identifier: string, title: string, themeConfig: ThemeConfig, description?: string, ): string[][] { const tags: string[][] = [ ['d', identifier], ...buildColorTags(themeConfig.colors), ...buildFontTags(themeConfig.font, themeConfig.titleFont), ...buildBackgroundTag(themeConfig.background), ['title', title], ['alt', `Custom theme: ${title}`], ['t', 'theme'], ]; if (description) { tags.push(['description', description]); } return tags; } /** Generate a URL-safe slug from a title. */ export function titleToSlug(title: string): string { return title .toLowerCase() .trim() .replace(/[^a-z0-9\s-]/g, '') .replace(/\s+/g, '-') .replace(/-+/g, '-') .slice(0, 64); } // ─── Active Profile Theme (Kind 16767) ──────────────────────────────── export interface ActiveProfileTheme { /** The 3 core theme colors */ colors: CoreThemeColors; /** Optional custom body font */ font?: ThemeFont; /** Optional title/header font (profile display name) */ titleFont?: ThemeFont; /** Optional background */ background?: ThemeBackground; /** naddr-style reference to the source theme definition, if any */ sourceRef?: string; /** The original Nostr event */ event: NostrEvent; } /** Parse and validate a kind 16767 active profile theme event. Returns null if invalid. */ export function parseActiveProfileTheme(event: NostrEvent): ActiveProfileTheme | null { if (event.kind !== ACTIVE_THEME_KIND) return null; // Try new format: colors in `c` tags let colors = parseColorTags(event.tags); // Fall back to legacy format: colors as JSON in content if (!colors && event.content) { try { const parsed = JSON.parse(event.content); colors = normalizeLegacyColors(parsed); } catch { // Invalid JSON } } if (!colors) return null; const { font, titleFont } = parseFontTags(event.tags); const background = parseBackgroundTag(event.tags); const sourceRef = event.tags.find(([n]) => n === 'a')?.[1]; return { colors, font, titleFont, background, sourceRef, event }; } /** Create tags for a kind 16767 active profile theme event. */ export function buildActiveThemeTags( themeConfig: ThemeConfig, sourceAuthor?: string, sourceIdentifier?: string, description?: string, ): string[][] { const tags: string[][] = [ ...buildColorTags(themeConfig.colors), ...buildFontTags(themeConfig.font, themeConfig.titleFont), ...buildBackgroundTag(themeConfig.background), ['alt', 'Active profile theme'], ]; if (themeConfig.title) { tags.push(['title', themeConfig.title]); } if (description) { tags.push(['description', description]); } if (sourceAuthor && sourceIdentifier) { tags.push(['a', `${THEME_DEFINITION_KIND}:${sourceAuthor}:${sourceIdentifier}`]); } return tags; } // ─── Backward Compatibility ─────────────────────────────────────────── /** * Normalize a parsed JSON object to CoreThemeColors (legacy format). * Handles: * - Current format: { background, text, primary } * - Old 4-color format: { background, text, primary, secondary } (secondary dropped) * - Legacy 19-token format: { background, foreground, primary, ... } */ function normalizeLegacyColors(parsed: Record): CoreThemeColors | null { // Current or old 4-color format (both have background + text + primary) if (parsed.background && parsed.text && parsed.primary) { return { background: String(parsed.background), text: String(parsed.text), primary: String(parsed.primary), }; } // Legacy 19-token format (background, foreground, primary, ...) if (parsed.background && parsed.foreground && parsed.primary) { return { background: String(parsed.background), text: String(parsed.foreground), primary: String(parsed.primary), }; } return null; }