Files
eranos/src/lib/themeEvent.ts
T

288 lines
9.3 KiB
TypeScript

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<string, string>();
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 an `f` tag from a ThemeFont. */
function buildFontTag(font: ThemeFont | undefined): string[][] {
if (!font?.family) return [];
const tag = ['f', font.family];
if (font.url) tag.push(font.url);
return [tag];
}
/** Parse the first `f` tag into a ThemeFont. Returns undefined if no f tag. */
function parseFontTag(tags: string[][]): ThemeFont | undefined {
for (const tag of tags) {
if (tag[0] !== 'f' || !tag[1]) continue;
const font: ThemeFont = { family: tag[1] };
if (tag[2]) font.url = tag[2];
return font;
}
return undefined;
}
// ─── 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<string, string>();
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 font */
font?: 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 = parseFontTag(event.tags);
const background = parseBackgroundTag(event.tags);
return { identifier, title, description, colors, font, 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),
...buildFontTag(themeConfig.font),
...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 font */
font?: 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 = parseFontTag(event.tags);
const background = parseBackgroundTag(event.tags);
const sourceRef = event.tags.find(([n]) => n === 'a')?.[1];
return { colors, font, background, sourceRef, event };
}
/** Create tags for a kind 16767 active profile theme event. */
export function buildActiveThemeTags(
themeConfig: ThemeConfig,
sourceAuthor?: string,
sourceIdentifier?: string,
): string[][] {
const tags: string[][] = [
...buildColorTags(themeConfig.colors),
...buildFontTag(themeConfig.font),
...buildBackgroundTag(themeConfig.background),
['alt', 'Active profile theme'],
];
if (themeConfig.title) {
tags.push(['title', themeConfig.title]);
}
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<string, unknown>): 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;
}