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