···11import { supabaseServerClient } from "supabase/serverClient";
22import { AtUri } from "@atproto/syntax";
33-import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api";
44-import Link from "next/link";
53import { getPublicationURL } from "app/lish/createPub/getPublicationURL";
64import { BskyAgent } from "@atproto/api";
75import { SubscribeWithBluesky } from "app/lish/Subscribe";
···1210} from "components/ThemeManager/PublicationThemeProvider";
1311import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout";
1412import { SpeedyLink } from "components/SpeedyLink";
1515-import { QuoteTiny } from "components/Icons/QuoteTiny";
1616-import { CommentTiny } from "components/Icons/CommentTiny";
1713import { InteractionPreview } from "components/InteractionsPreview";
1814import { LocalizedDate } from "./LocalizedDate";
1915import { PublicationHomeLayout } from "./PublicationHomeLayout";
2016import { PublicationAuthor } from "./PublicationAuthor";
2117import { Separator } from "components/Layout";
1818+import {
1919+ normalizePublicationRecord,
2020+ normalizeDocumentRecord,
2121+} from "src/utils/normalizeRecords";
22222323export default async function Publication(props: {
2424 params: Promise<{ publication: string; did: string }>;
···5555 agent.getProfile({ actor: did }),
5656 ]);
57575858- let record = publication?.record as PubLeafletPublication.Record | null;
5858+ const record = normalizePublicationRecord(publication?.record);
59596060 let showPageBackground = record?.theme?.showPageBackground;
6161···112112 {publication.documents_in_publications
113113 .filter((d) => !!d?.documents)
114114 .sort((a, b) => {
115115- let aRecord = a.documents?.data! as PubLeafletDocument.Record;
116116- let bRecord = b.documents?.data! as PubLeafletDocument.Record;
117117- const aDate = aRecord.publishedAt
115115+ const aRecord = normalizeDocumentRecord(a.documents?.data);
116116+ const bRecord = normalizeDocumentRecord(b.documents?.data);
117117+ const aDate = aRecord?.publishedAt
118118 ? new Date(aRecord.publishedAt)
119119 : new Date(0);
120120- const bDate = bRecord.publishedAt
120120+ const bDate = bRecord?.publishedAt
121121 ? new Date(bRecord.publishedAt)
122122 : new Date(0);
123123 return bDate.getTime() - aDate.getTime(); // Sort by most recent first
124124 })
125125 .map((doc) => {
126126 if (!doc.documents) return null;
127127+ const doc_record = normalizeDocumentRecord(doc.documents.data);
128128+ if (!doc_record) return null;
127129 let uri = new AtUri(doc.documents.uri);
128128- let doc_record = doc.documents
129129- .data as PubLeafletDocument.Record;
130130 let quotes =
131131 doc.documents.document_mentions_in_bsky[0].count || 0;
132132 let comments =
133133 record?.preferences?.showComments === false
134134 ? 0
135135 : doc.documents.comments_on_documents[0].count || 0;
136136- let tags = (doc_record?.tags as string[] | undefined) || [];
136136+ let tags = doc_record.tags || [];
137137138138 return (
139139 <React.Fragment key={doc.documents?.uri}>
+10-7
app/lish/createPub/UpdatePubForm.tsx
···77 updatePublication,
88 updatePublicationBasePath,
99} from "./updatePublication";
1010-import { usePublicationData } from "../[did]/[publication]/dashboard/PublicationSWRProvider";
1111-import { PubLeafletPublication } from "lexicons/api";
1010+import {
1111+ usePublicationData,
1212+ useNormalizedPublicationRecord,
1313+} from "../[did]/[publication]/dashboard/PublicationSWRProvider";
1214import useSWR, { mutate } from "swr";
1315import { AddTiny } from "components/Icons/AddTiny";
1416import { DotLoader } from "components/utils/DotLoader";
···3032}) => {
3133 let { data } = usePublicationData();
3234 let { publication: pubData } = data || {};
3333- let record = pubData?.record as PubLeafletPublication.Record;
3535+ let record = useNormalizedPublicationRecord();
3436 let [formState, setFormState] = useState<"normal" | "loading">("normal");
35373638 let [nameValue, setNameValue] = useState(record?.name || "");
···6062 let [iconPreview, setIconPreview] = useState<string | null>(null);
6163 let fileInputRef = useRef<HTMLInputElement>(null);
6264 useEffect(() => {
6363- if (!pubData || !pubData.record) return;
6565+ if (!pubData || !pubData.record || !record) return;
6466 setNameValue(record.name);
6567 setDescriptionValue(record.description || "");
6668 if (record.icon)
6769 setIconPreview(
6870 `/api/atproto_images?did=${pubData.identity_did}&cid=${(record.icon.ref as unknown as { $link: string })["$link"]}`,
6971 );
7070- }, [pubData]);
7272+ }, [pubData, record]);
7173 let toast = useToaster();
72747375 return (
···202204export function CustomDomainForm() {
203205 let { data } = usePublicationData();
204206 let { publication: pubData } = data || {};
207207+ let record = useNormalizedPublicationRecord();
205208 if (!pubData) return null;
206206- let record = pubData?.record as PubLeafletPublication.Record;
209209+ if (!record) return null;
207210 let [state, setState] = useState<
208211 | { type: "default" }
209212 | { type: "addDomain" }
···243246 <Domain
244247 domain={d.domain}
245248 publication_uri={pubData.uri}
246246- base_path={record.base_path || ""}
249249+ base_path={record.url.replace(/^https?:\/\//, "")}
247250 setDomain={(v) => {
248251 setState({
249252 type: "domainSettings",
+34-9
app/lish/createPub/getPublicationURL.ts
···22import { PubLeafletPublication } from "lexicons/api";
33import { isProductionDomain } from "src/utils/isProductionDeployment";
44import { Json } from "supabase/database.types";
55+import {
66+ normalizePublicationRecord,
77+ isLeafletPublication,
88+ type NormalizedPublication,
99+} from "src/utils/normalizeRecords";
51066-export function getPublicationURL(pub: { uri: string; record: Json }) {
77- let record = pub.record as PubLeafletPublication.Record;
88- if (isProductionDomain() && record?.base_path)
99- return `https://${record.base_path}`;
1010- else return getBasePublicationURL(pub);
1111+type PublicationInput =
1212+ | { uri: string; record: Json | NormalizedPublication | null }
1313+ | { uri: string; record: unknown };
1414+1515+/**
1616+ * Gets the public URL for a publication.
1717+ * Works with both pub.leaflet.publication and site.standard.publication records.
1818+ */
1919+export function getPublicationURL(pub: PublicationInput): string {
2020+ const normalized = normalizePublicationRecord(pub.record);
2121+2222+ // If we have a normalized record with a URL (site.standard format), use it
2323+ if (normalized?.url && isProductionDomain()) {
2424+ return normalized.url;
2525+ }
2626+2727+ // Fall back to checking raw record for legacy base_path
2828+ if (isLeafletPublication(pub.record) && pub.record.base_path && isProductionDomain()) {
2929+ return `https://${pub.record.base_path}`;
3030+ }
3131+3232+ return getBasePublicationURL(pub);
1133}
12341313-export function getBasePublicationURL(pub: { uri: string; record: Json }) {
1414- let record = pub.record as PubLeafletPublication.Record;
1515- let aturi = new AtUri(pub.uri);
1616- return `/lish/${aturi.host}/${encodeURIComponent(aturi.rkey || record?.name)}`;
3535+export function getBasePublicationURL(pub: PublicationInput): string {
3636+ const normalized = normalizePublicationRecord(pub.record);
3737+ const aturi = new AtUri(pub.uri);
3838+3939+ // Use normalized name if available, fall back to rkey
4040+ const name = normalized?.name || aturi.rkey;
4141+ return `/lish/${aturi.host}/${encodeURIComponent(name || "")}`;
1742}
+46-4
app/lish/createPub/updatePublication.ts
···1111import { Json } from "supabase/database.types";
1212import { AtUri } from "@atproto/syntax";
1313import { $Typed } from "@atproto/api";
1414+import {
1515+ normalizePublicationRecord,
1616+ type NormalizedPublication,
1717+} from "src/utils/normalizeRecords";
14181519type UpdatePublicationResult =
1620 | { success: true; publication: any }
···143147 }
144148 let aturi = new AtUri(existingPub.uri);
145149150150+ // Normalize the existing record to read its properties, then build a new pub.leaflet record
151151+ const normalizedPub = normalizePublicationRecord(existingPub.record);
152152+ // Extract base_path from url if it exists (url format is https://domain, base_path is just domain)
153153+ const existingBasePath = normalizedPub?.url
154154+ ? normalizedPub.url.replace(/^https?:\/\//, "")
155155+ : undefined;
156156+146157 let record: PubLeafletPublication.Record = {
147147- ...(existingPub.record as PubLeafletPublication.Record),
158158+ $type: "pub.leaflet.publication",
159159+ name: normalizedPub?.name || "",
160160+ description: normalizedPub?.description,
161161+ icon: normalizedPub?.icon,
162162+ theme: normalizedPub?.theme,
163163+ preferences: normalizedPub?.preferences
164164+ ? {
165165+ $type: "pub.leaflet.publication#preferences" as const,
166166+ showInDiscover: normalizedPub.preferences.showInDiscover,
167167+ showComments: normalizedPub.preferences.showComments,
168168+ showMentions: normalizedPub.preferences.showMentions,
169169+ showPrevNext: normalizedPub.preferences.showPrevNext,
170170+ }
171171+ : undefined,
148172 base_path,
149173 };
150174···219243 }
220244 let aturi = new AtUri(existingPub.uri);
221245222222- let oldRecord = existingPub.record as PubLeafletPublication.Record;
246246+ // Normalize the existing record to read its properties
247247+ const normalizedPub = normalizePublicationRecord(existingPub.record);
248248+ // Extract base_path from url if it exists (url format is https://domain, base_path is just domain)
249249+ const existingBasePath = normalizedPub?.url
250250+ ? normalizedPub.url.replace(/^https?:\/\//, "")
251251+ : undefined;
252252+223253 let record: PubLeafletPublication.Record = {
224224- ...oldRecord,
225254 $type: "pub.leaflet.publication",
255255+ name: normalizedPub?.name || "",
256256+ description: normalizedPub?.description,
257257+ icon: normalizedPub?.icon,
258258+ base_path: existingBasePath,
259259+ preferences: normalizedPub?.preferences
260260+ ? {
261261+ $type: "pub.leaflet.publication#preferences" as const,
262262+ showInDiscover: normalizedPub.preferences.showInDiscover,
263263+ showComments: normalizedPub.preferences.showComments,
264264+ showMentions: normalizedPub.preferences.showMentions,
265265+ showPrevNext: normalizedPub.preferences.showPrevNext,
266266+ }
267267+ : undefined,
226268 theme: {
227269 backgroundImage: theme.backgroundImage
228270 ? {
···238280 }
239281 : theme.backgroundImage === null
240282 ? undefined
241241- : oldRecord.theme?.backgroundImage,
283283+ : normalizedPub?.theme?.backgroundImage,
242284 backgroundColor: theme.backgroundColor
243285 ? {
244286 ...theme.backgroundColor,
+7-4
app/lish/feeds/[...path]/route.ts
···22import { DidResolver } from "@atproto/identity";
33import { parseReqNsid, verifyJwt } from "@atproto/xrpc-server";
44import { supabaseServerClient } from "supabase/serverClient";
55-import { PubLeafletDocument } from "lexicons/api";
55+import {
66+ normalizeDocumentRecord,
77+ type NormalizedDocument,
88+} from "src/utils/normalizeRecords";
69710const serviceDid = "did:web:leaflet.pub:lish:feeds";
811export async function GET(
···3437 let posts = pub.publications?.documents_in_publications || [];
3538 return posts.flatMap((p) => {
3639 if (!p.documents?.data) return [];
3737- let record = p.documents.data as PubLeafletDocument.Record;
3838- if (!record.postRef) return [];
3939- return { post: record.postRef.uri };
4040+ const normalizedDoc = normalizeDocumentRecord(p.documents.data);
4141+ if (!normalizedDoc?.bskyPostRef) return [];
4242+ return { post: normalizedDoc.bskyPostRef.uri };
4043 });
4144 }),
4245 ],
+20-22
app/lish/uri/[uri]/route.ts
···11import { NextRequest, NextResponse } from "next/server";
22import { AtUri } from "@atproto/api";
33import { supabaseServerClient } from "supabase/serverClient";
44-import { PubLeafletPublication } from "lexicons/api";
44+import {
55+ normalizePublicationRecord,
66+ type NormalizedPublication,
77+} from "src/utils/normalizeRecords";
5869/**
710 * Redirect route for AT URIs (publications and documents)
···2831 return new NextResponse("Publication not found", { status: 404 });
2932 }
30333131- const record = publication.record as PubLeafletPublication.Record;
3232- const basePath = record.base_path;
3333-3434- if (!basePath) {
3535- return new NextResponse("Publication has no base_path", {
3434+ const normalizedPub = normalizePublicationRecord(publication.record);
3535+ if (!normalizedPub?.url) {
3636+ return new NextResponse("Publication has no url", {
3637 status: 404,
3738 });
3839 }
39404040- // Redirect to the publication's hosted domain (temporary redirect since base_path can change)
4141- return NextResponse.redirect(basePath, 307);
4141+ // Redirect to the publication's hosted domain (temporary redirect since url can change)
4242+ return NextResponse.redirect(normalizedPub.url, 307);
4243 } else if (uri.collection === "pub.leaflet.document") {
4344 // Document link - need to find the publication it belongs to
4445 const { data: docInPub } = await supabaseServerClient
···49505051 if (docInPub?.publication && docInPub.publications) {
5152 // Document is in a publication - redirect to domain/rkey
5252- const record = docInPub.publications
5353- .record as PubLeafletPublication.Record;
5454- const basePath = record.base_path;
5353+ const normalizedPub = normalizePublicationRecord(
5454+ docInPub.publications.record,
5555+ );
55565656- if (!basePath) {
5757- return new NextResponse("Publication has no base_path", {
5757+ if (!normalizedPub?.url) {
5858+ return new NextResponse("Publication has no url", {
5859 status: 404,
5960 });
6061 }
61626262- // Ensure basePath ends without trailing slash
6363- const cleanBasePath = basePath.endsWith("/")
6464- ? basePath.slice(0, -1)
6565- : basePath;
6363+ // Ensure url ends without trailing slash
6464+ const cleanUrl = normalizedPub.url.endsWith("/")
6565+ ? normalizedPub.url.slice(0, -1)
6666+ : normalizedPub.url;
66676767- // Redirect to the document on the publication's domain (temporary redirect since base_path can change)
6868- return NextResponse.redirect(
6969- `https://${cleanBasePath}/${uri.rkey}`,
7070- 307,
7171- );
6868+ // Redirect to the document on the publication's domain (temporary redirect since url can change)
6969+ return NextResponse.redirect(`${cleanUrl}/${uri.rkey}`, 307);
7270 }
73717472 // If not in a publication, check if it's a standalone document
+3-3
app/p/[didOrHandle]/[rkey]/opengraph-image.ts
···22import { supabaseServerClient } from "supabase/serverClient";
33import { AtUri } from "@atproto/syntax";
44import { ids } from "lexicons/api/lexicons";
55-import { PubLeafletDocument } from "lexicons/api";
65import { jsonToLex } from "@atproto/lexicon";
76import { idResolver } from "app/(home-pages)/reader/idResolver";
87import { fetchAtprotoBlob } from "app/api/atproto_images/route";
88+import { normalizeDocumentRecord } from "src/utils/normalizeRecords";
991010export const revalidate = 60;
1111···3535 .single();
36363737 if (document) {
3838- let docRecord = jsonToLex(document.data) as PubLeafletDocument.Record;
3939- if (docRecord.coverImage) {
3838+ const docRecord = normalizeDocumentRecord(jsonToLex(document.data));
3939+ if (docRecord?.coverImage) {
4040 try {
4141 // Get CID from the blob ref (handle both serialized and hydrated forms)
4242 let cid =
+3-2
app/p/[didOrHandle]/[rkey]/page.tsx
···11import { supabaseServerClient } from "supabase/serverClient";
22import { AtUri } from "@atproto/syntax";
33import { ids } from "lexicons/api/lexicons";
44-import { PubLeafletDocument } from "lexicons/api";
54import { Metadata } from "next";
65import { idResolver } from "app/(home-pages)/reader/idResolver";
76import { DocumentPageRenderer } from "app/lish/[did]/[publication]/[rkey]/DocumentPageRenderer";
87import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout";
88+import { normalizeDocumentRecord } from "src/utils/normalizeRecords";
991010export async function generateMetadata(props: {
1111 params: Promise<{ didOrHandle: string; rkey: string }>;
···32323333 if (!document) return { title: "404" };
34343535- let docRecord = document.data as PubLeafletDocument.Record;
3535+ const docRecord = normalizeDocumentRecord(document.data);
3636+ if (!docRecord) return { title: "404" };
36373738 // For documents in publications, include publication name
3839 let publicationName =
+18-16
appview/index.ts
···231231 .eq("uri", evt.uri.toString());
232232 }
233233 }
234234+ // site.standard.document records go into the main "documents" table
235235+ // The normalization layer handles reading both pub.leaflet and site.standard formats
234236 if (evt.collection === ids.SiteStandardDocument) {
235237 if (evt.event === "create" || evt.event === "update") {
236238 let record = SiteStandardDocument.validateRecord(evt.record);
···238240 console.log(record.error);
239241 return;
240242 }
241241- await supabase
242242- .from("identities")
243243- .upsert({ atp_did: evt.did }, { onConflict: "atp_did" });
244244- let docResult = await supabase.from("site_standard_documents").upsert({
243243+ let docResult = await supabase.from("documents").upsert({
245244 uri: evt.uri.toString(),
246245 data: record.value as Json,
247247- identity_did: evt.did,
248246 });
249247 if (docResult.error) console.log(docResult.error);
248248+249249+ // site.standard.document uses "site" field to reference the publication
250250 if (record.value.site) {
251251 let siteURI = new AtUri(record.value.site);
252252···255255 return;
256256 }
257257 let docInPublicationResult = await supabase
258258- .from("site_standard_documents_in_publications")
258258+ .from("documents_in_publications")
259259 .upsert({
260260 publication: record.value.site,
261261 document: evt.uri.toString(),
262262 });
263263 await supabase
264264- .from("site_standard_documents_in_publications")
264264+ .from("documents_in_publications")
265265 .delete()
266266 .neq("publication", record.value.site)
267267 .eq("document", evt.uri.toString());
···271271 }
272272 }
273273 if (evt.event === "delete") {
274274- await supabase
275275- .from("site_standard_documents")
276276- .delete()
277277- .eq("uri", evt.uri.toString());
274274+ await supabase.from("documents").delete().eq("uri", evt.uri.toString());
278275 }
279276 }
277277+278278+ // site.standard.publication records go into the main "publications" table
280279 if (evt.collection === ids.SiteStandardPublication) {
281280 if (evt.event === "create" || evt.event === "update") {
282281 let record = SiteStandardPublication.validateRecord(evt.record);
···284283 await supabase
285284 .from("identities")
286285 .upsert({ atp_did: evt.did }, { onConflict: "atp_did" });
287287- await supabase.from("site_standard_publications").upsert({
286286+ await supabase.from("publications").upsert({
288287 uri: evt.uri.toString(),
289288 identity_did: evt.did,
290290- data: record.value as Json,
289289+ name: record.value.name,
290290+ record: record.value as Json,
291291 });
292292 }
293293 if (evt.event === "delete") {
294294 await supabase
295295- .from("site_standard_publications")
295295+ .from("publications")
296296 .delete()
297297 .eq("uri", evt.uri.toString());
298298 }
299299 }
300300+301301+ // site.standard.graph.subscription records go into the main "publication_subscriptions" table
300302 if (evt.collection === ids.SiteStandardGraphSubscription) {
301303 if (evt.event === "create" || evt.event === "update") {
302304 let record = SiteStandardGraphSubscription.validateRecord(evt.record);
···304306 await supabase
305307 .from("identities")
306308 .upsert({ atp_did: evt.did }, { onConflict: "atp_did" });
307307- await supabase.from("site_standard_subscriptions").upsert({
309309+ await supabase.from("publication_subscriptions").upsert({
308310 uri: evt.uri.toString(),
309311 identity: evt.did,
310312 publication: record.value.publication,
···313315 }
314316 if (evt.event === "delete") {
315317 await supabase
316316- .from("site_standard_subscriptions")
318318+ .from("publication_subscriptions")
317319 .delete()
318320 .eq("uri", evt.uri.toString());
319321 }
+7-4
components/ActionBar/Publications.tsx
···55import { theme } from "tailwind.config";
66import { getBasePublicationURL } from "app/lish/createPub/getPublicationURL";
77import { Json } from "supabase/database.types";
88-import { PubLeafletPublication } from "lexicons/api";
98import { AtUri } from "@atproto/syntax";
109import { ActionButton } from "./ActionButton";
1010+import {
1111+ normalizePublicationRecord,
1212+ type NormalizedPublication,
1313+} from "src/utils/normalizeRecords";
1114import { SpeedyLink } from "components/SpeedyLink";
1215import { PublishSmall } from "components/Icons/PublishSmall";
1316import { Popover } from "components/Popover";
···8588 record: Json;
8689 current?: boolean;
8790}) => {
8888- let record = props.record as PubLeafletPublication.Record | null;
9191+ let record = normalizePublicationRecord(props.record);
8992 if (!record) return;
90939194 return (
···181184};
182185183186export const PubIcon = (props: {
184184- record: PubLeafletPublication.Record;
187187+ record: NormalizedPublication | null;
185188 uri: string;
186189 small?: boolean;
187190 large?: boolean;
188191 className?: string;
189192}) => {
190190- if (!props.record) return;
193193+ if (!props.record) return null;
191194192195 let iconSizeClassName = `${props.small ? "w-4 h-4" : props.large ? "w-12 h-12" : "w-6 h-6"} rounded-full`;
193196
+7-7
components/Blocks/PublicationPollBlock.tsx
···1111import { useLeafletPublicationData } from "components/PageSWRDataProvider";
1212import {
1313 PubLeafletBlocksPoll,
1414- PubLeafletDocument,
1514 PubLeafletPagesLinearDocument,
1615} from "lexicons/api";
1616+import { getDocumentPages } from "src/utils/normalizeRecords";
1717import { ids } from "lexicons/api/lexicons";
18181919/**
···2222 * but disables adding new options once the poll record exists (indicated by pollUri).
2323 */
2424export const PublicationPollBlock = (props: BlockProps) => {
2525- let { data: publicationData } = useLeafletPublicationData();
2525+ let { data: publicationData, normalizedDocument } = useLeafletPublicationData();
2626 let isSelected = useUIState((s) =>
2727 s.selectedBlocks.find((b) => b.value === props.entityID),
2828 );
2929 // Check if this poll has been published in a publication document
3030 const isPublished = useMemo(() => {
3131- if (!publicationData?.documents?.data) return false;
3131+ if (!normalizedDocument) return false;
32323333- const docRecord = publicationData.documents
3434- .data as PubLeafletDocument.Record;
3333+ const pages = getDocumentPages(normalizedDocument);
3434+ if (!pages) return false;
35353636 // Search through all pages and blocks to find if this poll entity has been published
3737- for (const page of docRecord.pages || []) {
3737+ for (const page of pages) {
3838 if (page.$type === "pub.leaflet.pages.linearDocument") {
3939 const linearPage = page as PubLeafletPagesLinearDocument.Main;
4040 for (const blockWrapper of linearPage.blocks || []) {
···5050 }
5151 }
5252 return false;
5353- }, [publicationData, props.entityID]);
5353+ }, [normalizedDocument, props.entityID]);
54545555 return (
5656 <BlockLayout
+4-8
components/Canvas.tsx
···2121import { QuoteTiny } from "./Icons/QuoteTiny";
2222import { PublicationMetadata } from "./Pages/PublicationMetadata";
2323import { useLeafletPublicationData } from "./PageSWRDataProvider";
2424-import {
2525- PubLeafletPublication,
2626- PubLeafletPublicationRecord,
2727-} from "lexicons/api";
2824import { useHandleCanvasDrop } from "./Blocks/useHandleCanvasDrop";
29253026export function Canvas(props: {
···165161}
166162167163const CanvasMetadata = (props: { isSubpage: boolean | undefined }) => {
168168- let { data: pub } = useLeafletPublicationData();
164164+ let { data: pub, normalizedPublication } = useLeafletPublicationData();
169165 if (!pub || !pub.publications) return null;
170166171171- let pubRecord = pub.publications.record as PubLeafletPublication.Record;
172172- let showComments = pubRecord.preferences?.showComments;
173173- let showMentions = pubRecord.preferences?.showMentions;
167167+ if (!normalizedPublication) return null;
168168+ let showComments = normalizedPublication.preferences?.showComments;
169169+ let showMentions = normalizedPublication.preferences?.showMentions;
174170175171 return (
176172 <div className="flex flex-row gap-3 items-center absolute top-6 right-3 sm:top-4 sm:right-4 bg-bg-page border-border-light rounded-md px-2 py-1 h-fit z-20">
+20-1
components/PageSWRDataProvider.tsx
···66import { callRPC } from "app/api/rpc/client";
77import { getPollData } from "actions/pollActions";
88import type { GetLeafletDataReturnType } from "app/api/rpc/[command]/get_leaflet_data";
99-import { createContext, useContext } from "react";
99+import { createContext, useContext, useMemo } from "react";
1010import { getPublicationMetadataFromLeafletData } from "src/utils/getPublicationMetadataFromLeafletData";
1111import { getPublicationURL } from "app/lish/createPub/getPublicationURL";
1212import { AtUri } from "@atproto/syntax";
1313+import {
1414+ normalizeDocumentRecord,
1515+ normalizePublicationRecord,
1616+ type NormalizedDocument,
1717+ type NormalizedPublication,
1818+} from "src/utils/normalizeRecords";
13191420export const StaticLeafletDataContext = createContext<
1521 null | GetLeafletDataReturnType["result"]["data"]
···7379 // First check for leaflets in publications
7480 let pubData = getPublicationMetadataFromLeafletData(data);
75818282+ // Normalize records so consumers don't have to
8383+ const normalizedPublication = useMemo(
8484+ () => normalizePublicationRecord(pubData?.publications?.record),
8585+ [pubData?.publications?.record]
8686+ );
8787+ const normalizedDocument = useMemo(
8888+ () => normalizeDocumentRecord(pubData?.documents?.data),
8989+ [pubData?.documents?.data]
9090+ );
9191+7692 return {
7793 data: pubData || null,
9494+ // Pre-normalized data - consumers should use these instead of normalizing themselves
9595+ normalizedPublication,
9696+ normalizedDocument,
7897 mutate,
7998 };
8099}
+9-16
components/Pages/PublicationMetadata.tsx
···55import { AsyncValueAutosizeTextarea } from "components/utils/AutosizeTextarea";
66import { Separator } from "components/Layout";
77import { AtUri } from "@atproto/syntax";
88-import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api";
98import {
109 getBasePublicationURL,
1110 getPublicationURL,
···2221import { PostHeaderLayout } from "app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader";
2322export const PublicationMetadata = () => {
2423 let { rep } = useReplicache();
2525- let { data: pub } = useLeafletPublicationData();
2424+ let { data: pub, normalizedDocument, normalizedPublication } = useLeafletPublicationData();
2625 let { identity } = useIdentityData();
2726 let title = useSubscribe(rep, (tx) => tx.get<string>("publication_title"));
2827 let description = useSubscribe(rep, (tx) =>
2928 tx.get<string>("publication_description"),
3029 );
3131- let record = pub?.documents?.data as PubLeafletDocument.Record | null;
3232- let pubRecord = pub?.publications?.record as
3333- | PubLeafletPublication.Record
3434- | undefined;
3535- let publishedAt = record?.publishedAt;
3030+ let publishedAt = normalizedDocument?.publishedAt;
36313732 if (!pub) return null;
3833···121116 <Separator classname="h-4!" />
122117 </>
123118 )}
124124- {pubRecord?.preferences?.showMentions && (
119119+ {normalizedPublication?.preferences?.showMentions && (
125120 <div className="flex gap-1 items-center">
126121 <QuoteTiny />—
127122 </div>
128123 )}
129129- {pubRecord?.preferences?.showComments && (
124124+ {normalizedPublication?.preferences?.showComments && (
130125 <div className="flex gap-1 items-center">
131126 <CommentTiny />—
132127 </div>
···210205};
211206212207export const PublicationMetadataPreview = () => {
213213- let { data: pub } = useLeafletPublicationData();
214214- let record = pub?.documents?.data as PubLeafletDocument.Record | null;
215215- let publishedAt = record?.publishedAt;
208208+ let { data: pub, normalizedDocument } = useLeafletPublicationData();
209209+ let publishedAt = normalizedDocument?.publishedAt;
216210217211 if (!pub) return null;
218212···237231};
238232239233const AddTags = () => {
240240- let { data: pub } = useLeafletPublicationData();
234234+ let { data: pub, normalizedDocument } = useLeafletPublicationData();
241235 let { rep } = useReplicache();
242242- let record = pub?.documents?.data as PubLeafletDocument.Record | null;
243236244237 // Get tags from Replicache local state or published document
245238 let replicacheTags = useSubscribe(rep, (tx) =>
···250243 let tags: string[] = [];
251244 if (Array.isArray(replicacheTags)) {
252245 tags = replicacheTags;
253253- } else if (record?.tags && Array.isArray(record.tags)) {
254254- tags = record.tags as string[];
246246+ } else if (normalizedDocument?.tags && Array.isArray(normalizedDocument.tags)) {
247247+ tags = normalizedDocument.tags as string[];
255248 }
256249257250 // Update tags in replicache local state
+12-4
components/PostListing.tsx
···77import { usePubTheme } from "components/ThemeManager/PublicationThemeProvider";
88import { BaseThemeProvider } from "components/ThemeManager/ThemeProvider";
99import { useSmoker } from "components/Toast";
1010-import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api";
1110import { blobRefToSrc } from "src/utils/blobRefToSrc";
1111+import type {
1212+ NormalizedDocument,
1313+ NormalizedPublication,
1414+} from "src/utils/normalizeRecords";
1215import type { Post } from "app/(home-pages)/reader/getReaderFeed";
13161417import Link from "next/link";
···17201821export const PostListing = (props: Post) => {
1922 let pubRecord = props.publication?.pubRecord as
2020- | PubLeafletPublication.Record
2323+ | NormalizedPublication
2124 | undefined;
22252323- let postRecord = props.documents.data as PubLeafletDocument.Record;
2626+ let postRecord = props.documents.data as NormalizedDocument | null;
2727+2828+ // Don't render anything for records that can't be normalized (e.g., site.standard records without expected fields)
2929+ if (!postRecord) {
3030+ return null;
3131+ }
2432 let postUri = new AtUri(props.documents.uri);
2533 let uri = props.publication ? props.publication?.uri : props.documents.uri;
2634···110118111119const PubInfo = (props: {
112120 href: string;
113113- pubRecord: PubLeafletPublication.Record;
121121+ pubRecord: NormalizedPublication;
114122 uri: string;
115123}) => {
116124 return (
+8-8
components/ThemeManager/PubThemeSetter.tsx
···11-import { usePublicationData } from "app/lish/[did]/[publication]/dashboard/PublicationSWRProvider";
11+import {
22+ usePublicationData,
33+ useNormalizedPublicationRecord,
44+} from "app/lish/[did]/[publication]/dashboard/PublicationSWRProvider";
25import { useState } from "react";
36import { pickers, SectionArrow } from "./ThemeSetter";
47import { Color } from "react-aria-components";
55-import {
66- PubLeafletPublication,
77- PubLeafletThemeBackgroundImage,
88-} from "lexicons/api";
88+import { PubLeafletThemeBackgroundImage } from "lexicons/api";
99import { AtUri } from "@atproto/syntax";
1010import { useLocalPubTheme } from "./PublicationThemeProvider";
1111import { BaseThemeProvider } from "./ThemeProvider";
···3535 let [openPicker, setOpenPicker] = useState<pickers>("null");
3636 let { data, mutate } = usePublicationData();
3737 let { publication: pub } = data || {};
3838- let record = pub?.record as PubLeafletPublication.Record | undefined;
3838+ let record = useNormalizedPublicationRecord();
3939 let [showPageBackground, setShowPageBackground] = useState(
4040 !!record?.theme?.showPageBackground,
4141 );
···246246}) => {
247247 let { data } = usePublicationData();
248248 let { publication } = data || {};
249249- let record = publication?.record as PubLeafletPublication.Record | null;
249249+ let record = useNormalizedPublicationRecord();
250250251251 return (
252252 <div
···314314}) => {
315315 let { data } = usePublicationData();
316316 let { publication } = data || {};
317317- let record = publication?.record as PubLeafletPublication.Record | null;
317317+ let record = useNormalizedPublicationRecord();
318318 return (
319319 <div
320320 style={{
···11import { GetLeafletDataReturnType } from "app/api/rpc/[command]/get_leaflet_data";
22import { Json } from "supabase/database.types";
3344+/**
55+ * Return type for publication metadata extraction.
66+ * Note: `publications.record` and `documents.data` are raw JSON from the database.
77+ * Consumers should use `normalizePublicationRecord()` and `normalizeDocumentRecord()`
88+ * from `src/utils/normalizeRecords` to get properly typed data.
99+ */
1010+export type PublicationMetadata = {
1111+ description: string;
1212+ title: string;
1313+ leaflet: string;
1414+ doc: string | null;
1515+ publications: {
1616+ identity_did: string;
1717+ name: string;
1818+ indexed_at: string;
1919+ /** Raw record - use normalizePublicationRecord() to get typed data */
2020+ record: Json | null;
2121+ uri: string;
2222+ } | null;
2323+ documents: {
2424+ /** Raw data - use normalizeDocumentRecord() to get typed data */
2525+ data: Json;
2626+ indexed_at: string;
2727+ uri: string;
2828+ } | null;
2929+} | null;
3030+431export function getPublicationMetadataFromLeafletData(
532 data?: GetLeafletDataReturnType["result"]["data"],
66-) {
3333+): PublicationMetadata {
734 if (!data) return null;
835936 let pubData:
1010- | {
1111- description: string;
1212- title: string;
1313- leaflet: string;
1414- doc: string | null;
1515- publications: {
1616- identity_did: string;
1717- name: string;
1818- indexed_at: string;
1919- record: Json | null;
2020- uri: string;
2121- } | null;
2222- documents: {
2323- data: Json;
2424- indexed_at: string;
2525- uri: string;
2626- } | null;
2727- }
3737+ | NonNullable<PublicationMetadata>
2838 | undefined
2939 | null =
3040 data?.leaflets_in_publications?.[0] ||
···4656 doc: standaloneDoc.document,
4757 };
4858 }
4949- return pubData;
5959+ return pubData || null;
5060}
+129
src/utils/normalizeRecords.ts
···11+/**
22+ * Utilities for normalizing pub.leaflet and site.standard records from database queries.
33+ *
44+ * These helpers apply the normalization functions from lexicons/src/normalize.ts
55+ * to database query results, providing properly typed normalized records.
66+ */
77+88+import {
99+ normalizeDocument,
1010+ normalizePublication,
1111+ type NormalizedDocument,
1212+ type NormalizedPublication,
1313+} from "lexicons/src/normalize";
1414+import type { Json } from "supabase/database.types";
1515+1616+/**
1717+ * Normalizes a document record from a database query result.
1818+ * Returns the normalized document or null if the record is invalid/unrecognized.
1919+ *
2020+ * @example
2121+ * const doc = normalizeDocumentRecord(dbResult.data);
2222+ * if (doc) {
2323+ * // doc is NormalizedDocument with proper typing
2424+ * console.log(doc.title, doc.site, doc.publishedAt);
2525+ * }
2626+ */
2727+export function normalizeDocumentRecord(
2828+ data: Json | unknown
2929+): NormalizedDocument | null {
3030+ return normalizeDocument(data);
3131+}
3232+3333+/**
3434+ * Normalizes a publication record from a database query result.
3535+ * Returns the normalized publication or null if the record is invalid/unrecognized.
3636+ *
3737+ * @example
3838+ * const pub = normalizePublicationRecord(dbResult.record);
3939+ * if (pub) {
4040+ * // pub is NormalizedPublication with proper typing
4141+ * console.log(pub.name, pub.url);
4242+ * }
4343+ */
4444+export function normalizePublicationRecord(
4545+ record: Json | unknown
4646+): NormalizedPublication | null {
4747+ return normalizePublication(record);
4848+}
4949+5050+/**
5151+ * Type helper for a document row from the database with normalized data.
5252+ * Use this when you need the full row but with typed data.
5353+ */
5454+export type DocumentRowWithNormalizedData<
5555+ T extends { data: Json | unknown }
5656+> = Omit<T, "data"> & {
5757+ data: NormalizedDocument | null;
5858+};
5959+6060+/**
6161+ * Type helper for a publication row from the database with normalized record.
6262+ * Use this when you need the full row but with typed record.
6363+ */
6464+export type PublicationRowWithNormalizedRecord<
6565+ T extends { record: Json | unknown }
6666+> = Omit<T, "record"> & {
6767+ record: NormalizedPublication | null;
6868+};
6969+7070+/**
7171+ * Normalizes a document row in place, returning a properly typed row.
7272+ */
7373+export function normalizeDocumentRow<T extends { data: Json | unknown }>(
7474+ row: T
7575+): DocumentRowWithNormalizedData<T> {
7676+ return {
7777+ ...row,
7878+ data: normalizeDocumentRecord(row.data),
7979+ };
8080+}
8181+8282+/**
8383+ * Normalizes a publication row in place, returning a properly typed row.
8484+ */
8585+export function normalizePublicationRow<T extends { record: Json | unknown }>(
8686+ row: T
8787+): PublicationRowWithNormalizedRecord<T> {
8888+ return {
8989+ ...row,
9090+ record: normalizePublicationRecord(row.record),
9191+ };
9292+}
9393+9494+/**
9595+ * Type guard for filtering normalized document rows with non-null data.
9696+ * Use with .filter() after .map(normalizeDocumentRow) to narrow the type.
9797+ */
9898+export function hasValidDocument<T extends { data: NormalizedDocument | null }>(
9999+ row: T
100100+): row is T & { data: NormalizedDocument } {
101101+ return row.data !== null;
102102+}
103103+104104+/**
105105+ * Type guard for filtering normalized publication rows with non-null record.
106106+ * Use with .filter() after .map(normalizePublicationRow) to narrow the type.
107107+ */
108108+export function hasValidPublication<
109109+ T extends { record: NormalizedPublication | null }
110110+>(row: T): row is T & { record: NormalizedPublication } {
111111+ return row.record !== null;
112112+}
113113+114114+// Re-export the core types and functions for convenience
115115+export {
116116+ normalizeDocument,
117117+ normalizePublication,
118118+ type NormalizedDocument,
119119+ type NormalizedPublication,
120120+} from "lexicons/src/normalize";
121121+122122+export {
123123+ isLeafletDocument,
124124+ isStandardDocument,
125125+ isLeafletPublication,
126126+ isStandardPublication,
127127+ hasLeafletContent,
128128+ getDocumentPages,
129129+} from "lexicons/src/normalize";