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 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}