a tool for shared writing and social publishing
at feature/analytics 334 lines 11 kB view raw
1/** 2 * Normalization utilities for converting between pub.leaflet and site.standard lexicon formats. 3 * 4 * The standard format (site.standard.*) is used as the canonical representation for 5 * reading data from the database, while both formats are accepted for storage. 6 * 7 * ## Site Field Format 8 * 9 * The `site` field in site.standard.document supports two URI formats: 10 * - AT-URIs (at://did/collection/rkey) - Used when document belongs to an AT Protocol publication 11 * - HTTPS URLs (https://example.com) - Used for standalone documents or external sites 12 * 13 * Both formats are valid and should be handled by consumers. 14 */ 15 16import type * as PubLeafletDocument from "../api/types/pub/leaflet/document"; 17import * as PubLeafletPublication from "../api/types/pub/leaflet/publication"; 18import type * as PubLeafletContent from "../api/types/pub/leaflet/content"; 19import type * as SiteStandardDocument from "../api/types/site/standard/document"; 20import type * as SiteStandardPublication from "../api/types/site/standard/publication"; 21import type * as SiteStandardThemeBasic from "../api/types/site/standard/theme/basic"; 22import type * as PubLeafletThemeColor from "../api/types/pub/leaflet/theme/color"; 23import type { $Typed } from "../api/util"; 24import { AtUri } from "@atproto/syntax"; 25 26// Normalized document type - uses the generated site.standard.document type 27// with an additional optional theme field for backwards compatibility 28export type NormalizedDocument = SiteStandardDocument.Record & { 29 // Keep the original theme for components that need leaflet-specific styling 30 theme?: PubLeafletPublication.Theme; 31 preferences?: SiteStandardPublication.Preferences; 32}; 33 34// Normalized publication type - uses the generated site.standard.publication type 35// with the theme narrowed to only the valid pub.leaflet.publication#theme type 36// (isTheme validates that $type is present, so we use $Typed) 37// Note: We explicitly list fields rather than using Omit because the generated Record type 38// has an index signature [k: string]: unknown that interferes with property typing 39export type NormalizedPublication = { 40 $type: "site.standard.publication"; 41 name: string; 42 url: string; 43 description?: string; 44 icon?: SiteStandardPublication.Record["icon"]; 45 basicTheme?: SiteStandardThemeBasic.Main; 46 theme?: $Typed<PubLeafletPublication.Theme>; 47 preferences?: SiteStandardPublication.Preferences; 48}; 49 50/** 51 * Checks if the record is a pub.leaflet.document 52 */ 53export function isLeafletDocument( 54 record: unknown, 55): record is PubLeafletDocument.Record { 56 if (!record || typeof record !== "object") return false; 57 const r = record as Record<string, unknown>; 58 return ( 59 r.$type === "pub.leaflet.document" || 60 // Legacy records without $type but with pages array 61 (Array.isArray(r.pages) && typeof r.author === "string") 62 ); 63} 64 65/** 66 * Checks if the record is a site.standard.document 67 */ 68export function isStandardDocument( 69 record: unknown, 70): record is SiteStandardDocument.Record { 71 if (!record || typeof record !== "object") return false; 72 const r = record as Record<string, unknown>; 73 return r.$type === "site.standard.document"; 74} 75 76/** 77 * Checks if the record is a pub.leaflet.publication 78 */ 79export function isLeafletPublication( 80 record: unknown, 81): record is PubLeafletPublication.Record { 82 if (!record || typeof record !== "object") return false; 83 const r = record as Record<string, unknown>; 84 return ( 85 r.$type === "pub.leaflet.publication" || 86 // Legacy records without $type but with name and no url 87 (typeof r.name === "string" && !("url" in r)) 88 ); 89} 90 91/** 92 * Checks if the record is a site.standard.publication 93 */ 94export function isStandardPublication( 95 record: unknown, 96): record is SiteStandardPublication.Record { 97 if (!record || typeof record !== "object") return false; 98 const r = record as Record<string, unknown>; 99 return r.$type === "site.standard.publication"; 100} 101 102/** 103 * Extracts RGB values from a color union type 104 */ 105function extractRgb( 106 color: 107 | $Typed<PubLeafletThemeColor.Rgba> 108 | $Typed<PubLeafletThemeColor.Rgb> 109 | { $type: string } 110 | undefined, 111): { r: number; g: number; b: number } | undefined { 112 if (!color || typeof color !== "object") return undefined; 113 const c = color as Record<string, unknown>; 114 if ( 115 typeof c.r === "number" && 116 typeof c.g === "number" && 117 typeof c.b === "number" 118 ) { 119 return { r: c.r, g: c.g, b: c.b }; 120 } 121 return undefined; 122} 123 124/** 125 * Converts a pub.leaflet theme to a site.standard.theme.basic format 126 */ 127export function leafletThemeToBasicTheme( 128 theme: PubLeafletPublication.Theme | undefined, 129): SiteStandardThemeBasic.Main | undefined { 130 if (!theme) return undefined; 131 132 const background = extractRgb(theme.backgroundColor); 133 const accent = 134 extractRgb(theme.accentBackground) || extractRgb(theme.primary); 135 const accentForeground = extractRgb(theme.accentText); 136 137 // If we don't have the required colors, return undefined 138 if (!background || !accent) return undefined; 139 140 // Default foreground to dark if not specified 141 const foreground = { r: 0, g: 0, b: 0 }; 142 143 // Default accent foreground to white if not specified 144 const finalAccentForeground = accentForeground || { r: 255, g: 255, b: 255 }; 145 146 return { 147 $type: "site.standard.theme.basic", 148 background: { $type: "site.standard.theme.color#rgb", ...background }, 149 foreground: { $type: "site.standard.theme.color#rgb", ...foreground }, 150 accent: { $type: "site.standard.theme.color#rgb", ...accent }, 151 accentForeground: { 152 $type: "site.standard.theme.color#rgb", 153 ...finalAccentForeground, 154 }, 155 }; 156} 157 158/** 159 * Normalizes a document record from either format to the standard format. 160 * 161 * @param record - The document record from the database (either pub.leaflet or site.standard) 162 * @param uri - Optional document URI, used to extract the rkey for the path field when normalizing pub.leaflet records 163 * @returns A normalized document in site.standard format, or null if invalid/unrecognized 164 */ 165export function normalizeDocument( 166 record: unknown, 167 uri?: string, 168): NormalizedDocument | null { 169 if (!record || typeof record !== "object") return null; 170 171 // Pass through site.standard records directly (theme is already in correct format if present) 172 if (isStandardDocument(record)) { 173 const preferences = record.preferences as 174 | SiteStandardPublication.Preferences 175 | undefined; 176 return { 177 ...record, 178 theme: record.theme, 179 preferences, 180 } as NormalizedDocument; 181 } 182 183 if (isLeafletDocument(record)) { 184 // Convert from pub.leaflet to site.standard 185 const publishedAt = record.publishedAt; 186 187 if (!publishedAt) { 188 return null; 189 } 190 191 // For standalone documents (no publication), construct a site URL from the author 192 // This matches the pattern used in publishToPublication.ts for new standalone docs 193 const site = record.publication || `https://leaflet.pub/p/${record.author}`; 194 195 // Extract path from URI if available 196 const path = uri ? new AtUri(uri).rkey : undefined; 197 198 // Wrap pages in pub.leaflet.content structure 199 const content: $Typed<PubLeafletContent.Main> | undefined = record.pages 200 ? { 201 $type: "pub.leaflet.content" as const, 202 pages: record.pages, 203 } 204 : undefined; 205 206 // Extract preferences if present (available after lexicon rebuild) 207 const leafletPrefs = (record as Record<string, unknown>) 208 .preferences as SiteStandardPublication.Preferences | undefined; 209 210 return { 211 $type: "site.standard.document", 212 title: record.title, 213 site, 214 path, 215 publishedAt, 216 description: record.description, 217 tags: record.tags, 218 coverImage: record.coverImage, 219 bskyPostRef: record.postRef, 220 content, 221 theme: record.theme, 222 preferences: leafletPrefs 223 ? { ...leafletPrefs, $type: "site.standard.publication#preferences" as const } 224 : undefined, 225 }; 226 } 227 228 return null; 229} 230 231/** 232 * Normalizes a publication record from either format to the standard format. 233 * 234 * @param record - The publication record from the database (either pub.leaflet or site.standard) 235 * @returns A normalized publication in site.standard format, or null if invalid/unrecognized 236 */ 237export function normalizePublication( 238 record: unknown, 239): NormalizedPublication | null { 240 if (!record || typeof record !== "object") return null; 241 242 // Pass through site.standard records directly, but validate the theme 243 if (isStandardPublication(record)) { 244 // Validate theme - only keep if it's a valid pub.leaflet.publication#theme 245 const theme = PubLeafletPublication.isTheme(record.theme) 246 ? (record.theme as $Typed<PubLeafletPublication.Theme>) 247 : undefined; 248 return { 249 ...record, 250 theme, 251 }; 252 } 253 254 if (isLeafletPublication(record)) { 255 // Convert from pub.leaflet to site.standard 256 const url = record.base_path ? `https://${record.base_path}` : undefined; 257 258 if (!url) { 259 return null; 260 } 261 262 const basicTheme = leafletThemeToBasicTheme(record.theme); 263 264 // Validate theme - only keep if it's a valid pub.leaflet.publication#theme with $type set 265 // For legacy records without $type, add it during normalization 266 let theme: $Typed<PubLeafletPublication.Theme> | undefined; 267 if (record.theme) { 268 if (PubLeafletPublication.isTheme(record.theme)) { 269 theme = record.theme as $Typed<PubLeafletPublication.Theme>; 270 } else { 271 // Legacy theme without $type - add it 272 theme = { 273 ...record.theme, 274 $type: "pub.leaflet.publication#theme", 275 }; 276 } 277 } 278 279 // Convert preferences to site.standard format (strip/replace $type) 280 const preferences: SiteStandardPublication.Preferences | undefined = 281 record.preferences 282 ? { 283 showInDiscover: record.preferences.showInDiscover, 284 showComments: record.preferences.showComments, 285 showMentions: record.preferences.showMentions, 286 showPrevNext: record.preferences.showPrevNext, 287 showRecommends: record.preferences.showRecommends, 288 } 289 : undefined; 290 291 return { 292 $type: "site.standard.publication", 293 name: record.name, 294 url, 295 description: record.description, 296 icon: record.icon, 297 basicTheme, 298 theme, 299 preferences, 300 }; 301 } 302 303 return null; 304} 305 306/** 307 * Type guard to check if a normalized document has leaflet content 308 */ 309export function hasLeafletContent( 310 doc: NormalizedDocument, 311): doc is NormalizedDocument & { 312 content: $Typed<PubLeafletContent.Main>; 313} { 314 return ( 315 doc.content !== undefined && 316 (doc.content as { $type?: string }).$type === "pub.leaflet.content" 317 ); 318} 319 320/** 321 * Gets the pages array from a normalized document, handling both formats 322 */ 323export function getDocumentPages( 324 doc: NormalizedDocument, 325): PubLeafletContent.Main["pages"] | undefined { 326 if (!doc.content) return undefined; 327 328 if (hasLeafletContent(doc)) { 329 return doc.content.pages; 330 } 331 332 // Unknown content type 333 return undefined; 334}