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