a tool for shared writing and social publishing
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}