/** * Normalization utilities for converting between pub.leaflet and site.standard lexicon formats. * * The standard format (site.standard.*) is used as the canonical representation for * reading data from the database, while both formats are accepted for storage. * * ## Site Field Format * * The `site` field in site.standard.document supports two URI formats: * - AT-URIs (at://did/collection/rkey) - Used when document belongs to an AT Protocol publication * - HTTPS URLs (https://example.com) - Used for standalone documents or external sites * * Both formats are valid and should be handled by consumers. */ import type * as PubLeafletDocument from "../api/types/pub/leaflet/document"; import * as PubLeafletPublication from "../api/types/pub/leaflet/publication"; import type * as PubLeafletContent from "../api/types/pub/leaflet/content"; import type * as SiteStandardDocument from "../api/types/site/standard/document"; import type * as SiteStandardPublication from "../api/types/site/standard/publication"; import type * as SiteStandardThemeBasic from "../api/types/site/standard/theme/basic"; import type * as PubLeafletThemeColor from "../api/types/pub/leaflet/theme/color"; import type { $Typed } from "../api/util"; import { AtUri } from "@atproto/syntax"; // Normalized document type - uses the generated site.standard.document type // with an additional optional theme field for backwards compatibility export type NormalizedDocument = SiteStandardDocument.Record & { // Keep the original theme for components that need leaflet-specific styling theme?: PubLeafletPublication.Theme; preferences?: SiteStandardPublication.Preferences; }; // Normalized publication type - uses the generated site.standard.publication type // with the theme narrowed to only the valid pub.leaflet.publication#theme type // (isTheme validates that $type is present, so we use $Typed) // Note: We explicitly list fields rather than using Omit because the generated Record type // has an index signature [k: string]: unknown that interferes with property typing export type NormalizedPublication = { $type: "site.standard.publication"; name: string; url: string; description?: string; icon?: SiteStandardPublication.Record["icon"]; basicTheme?: SiteStandardThemeBasic.Main; theme?: $Typed; preferences?: SiteStandardPublication.Preferences; }; /** * Checks if the record is a pub.leaflet.document */ export function isLeafletDocument( record: unknown, ): record is PubLeafletDocument.Record { if (!record || typeof record !== "object") return false; const r = record as Record; return ( r.$type === "pub.leaflet.document" || // Legacy records without $type but with pages array (Array.isArray(r.pages) && typeof r.author === "string") ); } /** * Checks if the record is a site.standard.document */ export function isStandardDocument( record: unknown, ): record is SiteStandardDocument.Record { if (!record || typeof record !== "object") return false; const r = record as Record; return r.$type === "site.standard.document"; } /** * Checks if the record is a pub.leaflet.publication */ export function isLeafletPublication( record: unknown, ): record is PubLeafletPublication.Record { if (!record || typeof record !== "object") return false; const r = record as Record; return ( r.$type === "pub.leaflet.publication" || // Legacy records without $type but with name and no url (typeof r.name === "string" && !("url" in r)) ); } /** * Checks if the record is a site.standard.publication */ export function isStandardPublication( record: unknown, ): record is SiteStandardPublication.Record { if (!record || typeof record !== "object") return false; const r = record as Record; return r.$type === "site.standard.publication"; } /** * Extracts RGB values from a color union type */ function extractRgb( color: | $Typed | $Typed | { $type: string } | undefined, ): { r: number; g: number; b: number } | undefined { if (!color || typeof color !== "object") return undefined; const c = color as Record; if ( typeof c.r === "number" && typeof c.g === "number" && typeof c.b === "number" ) { return { r: c.r, g: c.g, b: c.b }; } return undefined; } /** * Converts a pub.leaflet theme to a site.standard.theme.basic format */ export function leafletThemeToBasicTheme( theme: PubLeafletPublication.Theme | undefined, ): SiteStandardThemeBasic.Main | undefined { if (!theme) return undefined; const background = extractRgb(theme.backgroundColor); const accent = extractRgb(theme.accentBackground) || extractRgb(theme.primary); const accentForeground = extractRgb(theme.accentText); // If we don't have the required colors, return undefined if (!background || !accent) return undefined; // Default foreground to dark if not specified const foreground = { r: 0, g: 0, b: 0 }; // Default accent foreground to white if not specified const finalAccentForeground = accentForeground || { r: 255, g: 255, b: 255 }; return { $type: "site.standard.theme.basic", background: { $type: "site.standard.theme.color#rgb", ...background }, foreground: { $type: "site.standard.theme.color#rgb", ...foreground }, accent: { $type: "site.standard.theme.color#rgb", ...accent }, accentForeground: { $type: "site.standard.theme.color#rgb", ...finalAccentForeground, }, }; } /** * Normalizes a document record from either format to the standard format. * * @param record - The document record from the database (either pub.leaflet or site.standard) * @param uri - Optional document URI, used to extract the rkey for the path field when normalizing pub.leaflet records * @returns A normalized document in site.standard format, or null if invalid/unrecognized */ export function normalizeDocument( record: unknown, uri?: string, ): NormalizedDocument | null { if (!record || typeof record !== "object") return null; // Pass through site.standard records directly (theme is already in correct format if present) if (isStandardDocument(record)) { const preferences = record.preferences as | SiteStandardPublication.Preferences | undefined; return { ...record, theme: record.theme, preferences, } as NormalizedDocument; } if (isLeafletDocument(record)) { // Convert from pub.leaflet to site.standard const publishedAt = record.publishedAt; if (!publishedAt) { return null; } // For standalone documents (no publication), construct a site URL from the author // This matches the pattern used in publishToPublication.ts for new standalone docs const site = record.publication || `https://leaflet.pub/p/${record.author}`; // Extract path from URI if available const path = uri ? new AtUri(uri).rkey : undefined; // Wrap pages in pub.leaflet.content structure const content: $Typed | undefined = record.pages ? { $type: "pub.leaflet.content" as const, pages: record.pages, } : undefined; // Extract preferences if present (available after lexicon rebuild) const leafletPrefs = (record as Record) .preferences as SiteStandardPublication.Preferences | undefined; return { $type: "site.standard.document", title: record.title, site, path, publishedAt, description: record.description, tags: record.tags, coverImage: record.coverImage, bskyPostRef: record.postRef, content, theme: record.theme, preferences: leafletPrefs ? { ...leafletPrefs, $type: "site.standard.publication#preferences" as const } : undefined, }; } return null; } /** * Normalizes a publication record from either format to the standard format. * * @param record - The publication record from the database (either pub.leaflet or site.standard) * @returns A normalized publication in site.standard format, or null if invalid/unrecognized */ export function normalizePublication( record: unknown, ): NormalizedPublication | null { if (!record || typeof record !== "object") return null; // Pass through site.standard records directly, but validate the theme if (isStandardPublication(record)) { // Validate theme - only keep if it's a valid pub.leaflet.publication#theme const theme = PubLeafletPublication.isTheme(record.theme) ? (record.theme as $Typed) : undefined; return { ...record, theme, }; } if (isLeafletPublication(record)) { // Convert from pub.leaflet to site.standard const url = record.base_path ? `https://${record.base_path}` : undefined; if (!url) { return null; } const basicTheme = leafletThemeToBasicTheme(record.theme); // Validate theme - only keep if it's a valid pub.leaflet.publication#theme with $type set // For legacy records without $type, add it during normalization let theme: $Typed | undefined; if (record.theme) { if (PubLeafletPublication.isTheme(record.theme)) { theme = record.theme as $Typed; } else { // Legacy theme without $type - add it theme = { ...record.theme, $type: "pub.leaflet.publication#theme", }; } } // Convert preferences to site.standard format (strip/replace $type) const preferences: SiteStandardPublication.Preferences | undefined = record.preferences ? { showInDiscover: record.preferences.showInDiscover, showComments: record.preferences.showComments, showMentions: record.preferences.showMentions, showPrevNext: record.preferences.showPrevNext, showRecommends: record.preferences.showRecommends, } : undefined; return { $type: "site.standard.publication", name: record.name, url, description: record.description, icon: record.icon, basicTheme, theme, preferences, }; } return null; } /** * Type guard to check if a normalized document has leaflet content */ export function hasLeafletContent( doc: NormalizedDocument, ): doc is NormalizedDocument & { content: $Typed; } { return ( doc.content !== undefined && (doc.content as { $type?: string }).$type === "pub.leaflet.content" ); } /** * Gets the pages array from a normalized document, handling both formats */ export function getDocumentPages( doc: NormalizedDocument, ): PubLeafletContent.Main["pages"] | undefined { if (!doc.content) return undefined; if (hasLeafletContent(doc)) { return doc.content.pages; } // Unknown content type return undefined; }