a tool for shared writing and social publishing
at update/reader 317 lines 10 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 = extractRgb(theme.accentBackground) || extractRgb(theme.primary); 133 const accentForeground = extractRgb(theme.accentText); 134 135 // If we don't have the required colors, return undefined 136 if (!background || !accent) return undefined; 137 138 // Default foreground to dark if not specified 139 const foreground = { r: 0, g: 0, b: 0 }; 140 141 // Default accent foreground to white if not specified 142 const finalAccentForeground = accentForeground || { r: 255, g: 255, b: 255 }; 143 144 return { 145 $type: "site.standard.theme.basic", 146 background: { $type: "site.standard.theme.color#rgb", ...background }, 147 foreground: { $type: "site.standard.theme.color#rgb", ...foreground }, 148 accent: { $type: "site.standard.theme.color#rgb", ...accent }, 149 accentForeground: { 150 $type: "site.standard.theme.color#rgb", 151 ...finalAccentForeground, 152 }, 153 }; 154} 155 156/** 157 * Normalizes a document record from either format to the standard format. 158 * 159 * @param record - The document record from the database (either pub.leaflet or site.standard) 160 * @param uri - Optional document URI, used to extract the rkey for the path field when normalizing pub.leaflet records 161 * @returns A normalized document in site.standard format, or null if invalid/unrecognized 162 */ 163export function normalizeDocument(record: unknown, uri?: string): NormalizedDocument | null { 164 if (!record || typeof record !== "object") return null; 165 166 // Pass through site.standard records directly (theme is already in correct format if present) 167 if (isStandardDocument(record)) { 168 return { 169 ...record, 170 theme: record.theme, 171 } as NormalizedDocument; 172 } 173 174 if (isLeafletDocument(record)) { 175 // Convert from pub.leaflet to site.standard 176 const publishedAt = record.publishedAt; 177 178 if (!publishedAt) { 179 return null; 180 } 181 182 // For standalone documents (no publication), construct a site URL from the author 183 // This matches the pattern used in publishToPublication.ts for new standalone docs 184 const site = record.publication || `https://leaflet.pub/p/${record.author}`; 185 186 // Extract path from URI if available 187 const path = uri ? new AtUri(uri).rkey : undefined; 188 189 // Wrap pages in pub.leaflet.content structure 190 const content: $Typed<PubLeafletContent.Main> | undefined = record.pages 191 ? { 192 $type: "pub.leaflet.content" as const, 193 pages: record.pages, 194 } 195 : undefined; 196 197 return { 198 $type: "site.standard.document", 199 title: record.title, 200 site, 201 path, 202 publishedAt, 203 description: record.description, 204 tags: record.tags, 205 coverImage: record.coverImage, 206 bskyPostRef: record.postRef, 207 content, 208 theme: record.theme, 209 }; 210 } 211 212 return null; 213} 214 215/** 216 * Normalizes a publication record from either format to the standard format. 217 * 218 * @param record - The publication record from the database (either pub.leaflet or site.standard) 219 * @returns A normalized publication in site.standard format, or null if invalid/unrecognized 220 */ 221export function normalizePublication( 222 record: unknown 223): NormalizedPublication | null { 224 if (!record || typeof record !== "object") return null; 225 226 // Pass through site.standard records directly, but validate the theme 227 if (isStandardPublication(record)) { 228 // Validate theme - only keep if it's a valid pub.leaflet.publication#theme 229 const theme = PubLeafletPublication.isTheme(record.theme) 230 ? (record.theme as $Typed<PubLeafletPublication.Theme>) 231 : undefined; 232 return { 233 ...record, 234 theme, 235 }; 236 } 237 238 if (isLeafletPublication(record)) { 239 // Convert from pub.leaflet to site.standard 240 const url = record.base_path ? `https://${record.base_path}` : undefined; 241 242 if (!url) { 243 return null; 244 } 245 246 const basicTheme = leafletThemeToBasicTheme(record.theme); 247 248 // Validate theme - only keep if it's a valid pub.leaflet.publication#theme with $type set 249 // For legacy records without $type, add it during normalization 250 let theme: $Typed<PubLeafletPublication.Theme> | undefined; 251 if (record.theme) { 252 if (PubLeafletPublication.isTheme(record.theme)) { 253 theme = record.theme as $Typed<PubLeafletPublication.Theme>; 254 } else { 255 // Legacy theme without $type - add it 256 theme = { 257 ...record.theme, 258 $type: "pub.leaflet.publication#theme", 259 }; 260 } 261 } 262 263 // Convert preferences to site.standard format (strip/replace $type) 264 const preferences: SiteStandardPublication.Preferences | undefined = 265 record.preferences 266 ? { 267 showInDiscover: record.preferences.showInDiscover, 268 showComments: record.preferences.showComments, 269 showMentions: record.preferences.showMentions, 270 showPrevNext: record.preferences.showPrevNext, 271 } 272 : undefined; 273 274 return { 275 $type: "site.standard.publication", 276 name: record.name, 277 url, 278 description: record.description, 279 icon: record.icon, 280 basicTheme, 281 theme, 282 preferences, 283 }; 284 } 285 286 return null; 287} 288 289/** 290 * Type guard to check if a normalized document has leaflet content 291 */ 292export function hasLeafletContent( 293 doc: NormalizedDocument 294): doc is NormalizedDocument & { 295 content: $Typed<PubLeafletContent.Main>; 296} { 297 return ( 298 doc.content !== undefined && 299 (doc.content as { $type?: string }).$type === "pub.leaflet.content" 300 ); 301} 302 303/** 304 * Gets the pages array from a normalized document, handling both formats 305 */ 306export function getDocumentPages( 307 doc: NormalizedDocument 308): PubLeafletContent.Main["pages"] | undefined { 309 if (!doc.content) return undefined; 310 311 if (hasLeafletContent(doc)) { 312 return doc.content.pages; 313 } 314 315 // Unknown content type 316 return undefined; 317}