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