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