···11import { NextRequest } from "next/server";
22import { IdResolver } from "@atproto/identity";
33-import { AtUri } from "@atproto/syntax";
43import { supabaseServerClient } from "supabase/serverClient";
54import sharp from "sharp";
65import { redirect } from "next/navigation";
76import { normalizePublicationRecord } from "src/utils/normalizeRecords";
77+import { publicationNameOrUriFilter } from "src/utils/uriHelpers";
8899let idResolver = new IdResolver();
1010···1818 const params = await props.params;
1919 try {
2020 let did = decodeURIComponent(params.did);
2121- let uri;
2222- if (/^(?!\.$|\.\.S)[A-Za-z0-9._:~-]{1,512}$/.test(params.publication)) {
2323- uri = AtUri.make(
2424- did,
2525- "pub.leaflet.publication",
2626- params.publication,
2727- ).toString();
2828- }
2929- let { data: publication } = await supabaseServerClient
2121+ let publication_name = decodeURIComponent(params.publication);
2222+ let { data: publications } = await supabaseServerClient
3023 .from("publications")
3124 .select(
3225 `*,
···3528 `,
3629 )
3730 .eq("identity_did", did)
3838- .or(`name.eq."${params.publication}", uri.eq."${uri}"`)
3939- .single();
3131+ .or(publicationNameOrUriFilter(did, publication_name))
3232+ .order("uri", { ascending: false })
3333+ .limit(1);
3434+ let publication = publications?.[0];
40354136 const record = normalizePublicationRecord(publication?.record);
4237 if (!record?.icon) return redirect("/icon.png");
+6-12
app/lish/[did]/[publication]/layout.tsx
···11import { supabaseServerClient } from "supabase/serverClient";
22import { Metadata } from "next";
33-import { AtUri } from "@atproto/syntax";
43import { normalizePublicationRecord } from "src/utils/normalizeRecords";
44+import { publicationNameOrUriFilter } from "src/utils/uriHelpers";
5566export default async function PublicationLayout(props: {
77 children: React.ReactNode;
···1919 let did = decodeURIComponent(params.did);
2020 if (!params.did || !params.publication) return { title: "Publication 404" };
21212222- let uri;
2322 let publication_name = decodeURIComponent(params.publication);
2424- if (/^(?!\.$|\.\.S)[A-Za-z0-9._:~-]{1,512}$/.test(publication_name)) {
2525- uri = AtUri.make(
2626- did,
2727- "pub.leaflet.publication",
2828- publication_name,
2929- ).toString();
3030- }
3131- let { data: publication } = await supabaseServerClient
2323+ let { data: publications } = await supabaseServerClient
3224 .from("publications")
3325 .select(
3426 `*,
···3729 `,
3830 )
3931 .eq("identity_did", did)
4040- .or(`name.eq."${publication_name}", uri.eq."${uri}"`)
4141- .single();
3232+ .or(publicationNameOrUriFilter(did, publication_name))
3333+ .order("uri", { ascending: false })
3434+ .limit(1);
3535+ let publication = publications?.[0];
4236 if (!publication) return { title: "Publication 404" };
43374438 const pubRecord = normalizePublicationRecord(publication?.record);
+6-11
app/lish/[did]/[publication]/page.tsx
···22import { AtUri } from "@atproto/syntax";
33import { getPublicationURL } from "app/lish/createPub/getPublicationURL";
44import { BskyAgent } from "@atproto/api";
55+import { publicationNameOrUriFilter } from "src/utils/uriHelpers";
56import { SubscribeWithBluesky } from "app/lish/Subscribe";
67import React from "react";
78import {
···2728 let did = decodeURIComponent(params.did);
2829 if (!did) return <PubNotFound />;
2930 let agent = new BskyAgent({ service: "https://public.api.bsky.app" });
3030- let uri;
3131 let publication_name = decodeURIComponent(params.publication);
3232- if (/^(?!\.$|\.\.S)[A-Za-z0-9._:~-]{1,512}$/.test(publication_name)) {
3333- uri = AtUri.make(
3434- did,
3535- "pub.leaflet.publication",
3636- publication_name,
3737- ).toString();
3838- }
3939- let [{ data: publication }, { data: profile }] = await Promise.all([
3232+ let [{ data: publications }, { data: profile }] = await Promise.all([
4033 supabaseServerClient
4134 .from("publications")
4235 .select(
···5043 `,
5144 )
5245 .eq("identity_did", did)
5353- .or(`name.eq."${publication_name}", uri.eq."${uri}"`)
5454- .single(),
4646+ .or(publicationNameOrUriFilter(did, publication_name))
4747+ .order("uri", { ascending: false })
4848+ .limit(1),
5549 agent.getProfile({ actor: did }),
5650 ]);
5151+ let publication = publications?.[0];
57525853 const record = normalizePublicationRecord(publication?.record);
5954
+46-21
app/lish/createPub/createPublication.ts
···11"use server";
22import { TID } from "@atproto/common";
33-import { AtpBaseClient, PubLeafletPublication } from "lexicons/api";
33+import {
44+ AtpBaseClient,
55+ PubLeafletPublication,
66+ SiteStandardPublication,
77+} from "lexicons/api";
48import {
59 restoreOAuthSession,
610 OAuthSessionError,
711} from "src/atproto-oauth";
812import { getIdentityData } from "actions/getIdentityData";
913import { supabaseServerClient } from "supabase/serverClient";
1010-import { Un$Typed } from "@atproto/api";
1114import { Json } from "supabase/database.types";
1215import { Vercel } from "@vercel/sdk";
1316import { isProductionDomain } from "src/utils/isProductionDeployment";
1417import { string } from "zod";
1818+import { getPublicationType } from "src/utils/collectionHelpers";
15191620const VERCEL_TOKEN = process.env.VERCEL_TOKEN;
1721const vercel = new Vercel({
···6468 let agent = new AtpBaseClient(
6569 credentialSession.fetchHandler.bind(credentialSession),
6670 );
6767- let record: Un$Typed<PubLeafletPublication.Record> = {
6868- name,
6969- base_path: domain,
7070- preferences,
7171- };
72717373- if (description) {
7474- record.description = description;
7575- }
7272+ // Use site.standard.publication for new publications
7373+ const publicationType = getPublicationType();
7474+ const url = `https://${domain}`;
7575+7676+ // Build record based on publication type
7777+ let record: SiteStandardPublication.Record | PubLeafletPublication.Record;
7878+ let iconBlob: Awaited<ReturnType<typeof agent.com.atproto.repo.uploadBlob>>["data"]["blob"] | undefined;
76797780 // Upload the icon if provided
7881 if (iconFile && iconFile.size > 0) {
···8184 new Uint8Array(buffer),
8285 { encoding: iconFile.type },
8386 );
8787+ iconBlob = uploadResult.data.blob;
8888+ }
84898585- if (uploadResult.data.blob) {
8686- record.icon = uploadResult.data.blob;
8787- }
9090+ if (publicationType === "site.standard.publication") {
9191+ record = {
9292+ $type: "site.standard.publication",
9393+ name,
9494+ url,
9595+ ...(description && { description }),
9696+ ...(iconBlob && { icon: iconBlob }),
9797+ preferences: {
9898+ showInDiscover: preferences.showInDiscover,
9999+ showComments: preferences.showComments,
100100+ showMentions: preferences.showMentions,
101101+ showPrevNext: preferences.showPrevNext,
102102+ },
103103+ } satisfies SiteStandardPublication.Record;
104104+ } else {
105105+ record = {
106106+ $type: "pub.leaflet.publication",
107107+ name,
108108+ base_path: domain,
109109+ ...(description && { description }),
110110+ ...(iconBlob && { icon: iconBlob }),
111111+ preferences,
112112+ } satisfies PubLeafletPublication.Record;
88113 }
891149090- let result = await agent.pub.leaflet.publication.create(
9191- { repo: credentialSession.did!, rkey: TID.nextStr(), validate: false },
115115+ let { data: result } = await agent.com.atproto.repo.putRecord({
116116+ repo: credentialSession.did!,
117117+ rkey: TID.nextStr(),
118118+ collection: publicationType,
92119 record,
9393- );
120120+ validate: false,
121121+ });
9412295123 //optimistically write to our db!
96124 let { data: publication } = await supabaseServerClient
···98126 .upsert({
99127 uri: result.uri,
100128 identity_did: credentialSession.did!,
101101- name: record.name,
102102- record: {
103103- ...record,
104104- $type: "pub.leaflet.publication",
105105- } as unknown as Json,
129129+ name,
130130+ record: record as unknown as Json,
106131 })
107132 .select()
108133 .single();
+20-13
app/lish/createPub/updatePublication.ts
···1515 normalizePublicationRecord,
1616 type NormalizedPublication,
1717} from "src/utils/normalizeRecords";
1818+import { getPublicationType } from "src/utils/collectionHelpers";
18191920type UpdatePublicationResult =
2021 | { success: true; publication: any }
···6263 return { success: false };
6364 }
6465 let aturi = new AtUri(existingPub.uri);
6666+ // Preserve existing schema when updating
6767+ const publicationType = getPublicationType(aturi.collection);
65686666- let record: PubLeafletPublication.Record = {
6767- $type: "pub.leaflet.publication",
6969+ let record = {
7070+ $type: publicationType,
6871 ...(existingPub.record as object),
6972 name,
7070- };
7373+ } as PubLeafletPublication.Record;
7174 if (preferences) {
7275 record.preferences = preferences;
7376 }
···9396 repo: credentialSession.did!,
9497 rkey: aturi.rkey,
9598 record,
9696- collection: record.$type,
9999+ collection: publicationType,
97100 validate: false,
98101 });
99102···146149 return { success: false };
147150 }
148151 let aturi = new AtUri(existingPub.uri);
152152+ // Preserve existing schema when updating
153153+ const publicationType = getPublicationType(aturi.collection);
149154150150- // Normalize the existing record to read its properties, then build a new pub.leaflet record
155155+ // Normalize the existing record to read its properties
151156 const normalizedPub = normalizePublicationRecord(existingPub.record);
152157 // Extract base_path from url if it exists (url format is https://domain, base_path is just domain)
153158 const existingBasePath = normalizedPub?.url
154159 ? normalizedPub.url.replace(/^https?:\/\//, "")
155160 : undefined;
156161157157- let record: PubLeafletPublication.Record = {
158158- $type: "pub.leaflet.publication",
162162+ let record = {
163163+ $type: publicationType,
159164 name: normalizedPub?.name || "",
160165 description: normalizedPub?.description,
161166 icon: normalizedPub?.icon,
···170175 }
171176 : undefined,
172177 base_path,
173173- };
178178+ } as PubLeafletPublication.Record;
174179175180 let result = await agent.com.atproto.repo.putRecord({
176181 repo: credentialSession.did!,
177182 rkey: aturi.rkey,
178183 record,
179179- collection: record.$type,
184184+ collection: publicationType,
180185 validate: false,
181186 });
182187···242247 return { success: false };
243248 }
244249 let aturi = new AtUri(existingPub.uri);
250250+ // Preserve existing schema when updating
251251+ const publicationType = getPublicationType(aturi.collection);
245252246253 // Normalize the existing record to read its properties
247254 const normalizedPub = normalizePublicationRecord(existingPub.record);
···250257 ? normalizedPub.url.replace(/^https?:\/\//, "")
251258 : undefined;
252259253253- let record: PubLeafletPublication.Record = {
254254- $type: "pub.leaflet.publication",
260260+ let record = {
261261+ $type: publicationType,
255262 name: normalizedPub?.name || "",
256263 description: normalizedPub?.description,
257264 icon: normalizedPub?.icon,
···301308 ...theme.accentText,
302309 },
303310 },
304304- };
311311+ } as PubLeafletPublication.Record;
305312306313 let result = await agent.com.atproto.repo.putRecord({
307314 repo: credentialSession.did!,
308315 rkey: aturi.rkey,
309316 record,
310310- collection: record.$type,
317317+ collection: publicationType,
311318 validate: false,
312319 });
313320
+6-2
app/lish/uri/[uri]/route.ts
···55 normalizePublicationRecord,
66 type NormalizedPublication,
77} from "src/utils/normalizeRecords";
88+import {
99+ isDocumentCollection,
1010+ isPublicationCollection,
1111+} from "src/utils/collectionHelpers";
812913/**
1014 * Redirect route for AT URIs (publications and documents)
···1923 const atUriString = decodeURIComponent(uriParam);
2024 const uri = new AtUri(atUriString);
21252222- if (uri.collection === "pub.leaflet.publication") {
2626+ if (isPublicationCollection(uri.collection)) {
2327 // Get the publication record to retrieve base_path
2428 const { data: publication } = await supabaseServerClient
2529 .from("publications")
···40444145 // Redirect to the publication's hosted domain (temporary redirect since url can change)
4246 return NextResponse.redirect(normalizedPub.url, 307);
4343- } else if (uri.collection === "pub.leaflet.document") {
4747+ } else if (isDocumentCollection(uri.collection)) {
4448 // Document link - need to find the publication it belongs to
4549 const { data: docInPub } = await supabaseServerClient
4650 .from("documents_in_publications")
+6-5
app/p/[didOrHandle]/[rkey]/opengraph-image.ts
···11import { getMicroLinkOgImage } from "src/utils/getMicroLinkOgImage";
22import { supabaseServerClient } from "supabase/serverClient";
33-import { AtUri } from "@atproto/syntax";
44-import { ids } from "lexicons/api/lexicons";
53import { jsonToLex } from "@atproto/lexicon";
64import { idResolver } from "app/(home-pages)/reader/idResolver";
75import { fetchAtprotoBlob } from "app/api/atproto_images/route";
86import { normalizeDocumentRecord } from "src/utils/normalizeRecords";
77+import { documentUriFilter } from "src/utils/uriHelpers";
98109export const revalidate = 60;
1110···28272928 if (did) {
3029 // Try to get the document's cover image
3131- let { data: document } = await supabaseServerClient
3030+ let { data: documents } = await supabaseServerClient
3231 .from("documents")
3332 .select("data")
3434- .eq("uri", AtUri.make(did, ids.PubLeafletDocument, params.rkey).toString())
3535- .single();
3333+ .or(documentUriFilter(did, params.rkey))
3434+ .order("uri", { ascending: false })
3535+ .limit(1);
3636+ let document = documents?.[0];
36373738 if (document) {
3839 const docRecord = normalizeDocumentRecord(jsonToLex(document.data));
+8-13
app/p/[didOrHandle]/[rkey]/page.tsx
···11import { supabaseServerClient } from "supabase/serverClient";
22-import { AtUri } from "@atproto/syntax";
33-import { ids } from "lexicons/api/lexicons";
42import { Metadata } from "next";
53import { idResolver } from "app/(home-pages)/reader/idResolver";
64import { DocumentPageRenderer } from "app/lish/[did]/[publication]/[rkey]/DocumentPageRenderer";
75import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout";
86import { normalizeDocumentRecord } from "src/utils/normalizeRecords";
77+import { documentUriFilter } from "src/utils/uriHelpers";
98109export async function generateMetadata(props: {
1110 params: Promise<{ didOrHandle: string; rkey: string }>;
···2423 }
2524 }
26252727- let { data: document } = await supabaseServerClient
2626+ let { data: documents } = await supabaseServerClient
2827 .from("documents")
2929- .select("*, documents_in_publications(publications(*))")
3030- .eq("uri", AtUri.make(did, ids.PubLeafletDocument, params.rkey))
3131- .single();
2828+ .select("*")
2929+ .or(documentUriFilter(did, params.rkey))
3030+ .order("uri", { ascending: false })
3131+ .limit(1);
3232+ let document = documents?.[0];
32333334 if (!document) return { title: "404" };
34353536 const docRecord = normalizeDocumentRecord(document.data);
3637 if (!docRecord) return { title: "404" };
37383838- // For documents in publications, include publication name
3939- let publicationName =
4040- document.documents_in_publications[0]?.publications?.name;
4141-4239 return {
4340 icons: {
4441 other: {
···4643 url: document.uri,
4744 },
4845 },
4949- title: publicationName
5050- ? `${docRecord.title} - ${publicationName}`
5151- : docRecord.title,
4646+ title: docRecord.title,
5247 description: docRecord?.description || "",
5348 };
5449}
+6-2
components/AtMentionLink.tsx
···11import { AtUri } from "@atproto/api";
22import { atUriToUrl } from "src/utils/mentionUtils";
33+import {
44+ isDocumentCollection,
55+ isPublicationCollection,
66+} from "src/utils/collectionHelpers";
3748/**
59 * Component for rendering at-uri mentions (publications and documents) as clickable links.
···1620 className?: string;
1721}) {
1822 const aturi = new AtUri(atURI);
1919- const isPublication = aturi.collection === "pub.leaflet.publication";
2020- const isDocument = aturi.collection === "pub.leaflet.document";
2323+ const isPublication = isPublicationCollection(aturi.collection);
2424+ const isDocument = isDocumentCollection(aturi.collection);
21252226 // Show publication icon if available
2327 const icon =
+8-4
components/Blocks/TextBlock/schema.ts
···22import { Schema, Node, MarkSpec, NodeSpec } from "prosemirror-model";
33import { marks } from "prosemirror-schema-basic";
44import { theme } from "tailwind.config";
55+import {
66+ isDocumentCollection,
77+ isPublicationCollection,
88+} from "src/utils/collectionHelpers";
59610let baseSchema = {
711 marks: {
···149153 // components/AtMentionLink.tsx. If you update one, update the other.
150154 let className = "atMention mention";
151155 let aturi = new AtUri(node.attrs.atURI);
152152- if (aturi.collection === "pub.leaflet.publication")
156156+ if (isPublicationCollection(aturi.collection))
153157 className += " font-bold";
154154- if (aturi.collection === "pub.leaflet.document") className += " italic";
158158+ if (isDocumentCollection(aturi.collection)) className += " italic";
155159156160 // For publications and documents, show icon
157161 if (
158158- aturi.collection === "pub.leaflet.publication" ||
159159- aturi.collection === "pub.leaflet.document"
162162+ isPublicationCollection(aturi.collection) ||
163163+ isDocumentCollection(aturi.collection)
160164 ) {
161165 return [
162166 "span",
+57
src/utils/collectionHelpers.ts
···11+import { ids } from "lexicons/api/lexicons";
22+33+/**
44+ * Check if a collection is a document collection (either namespace).
55+ */
66+export function isDocumentCollection(collection: string): boolean {
77+ return (
88+ collection === ids.PubLeafletDocument ||
99+ collection === ids.SiteStandardDocument
1010+ );
1111+}
1212+1313+/**
1414+ * Check if a collection is a publication collection (either namespace).
1515+ */
1616+export function isPublicationCollection(collection: string): boolean {
1717+ return (
1818+ collection === ids.PubLeafletPublication ||
1919+ collection === ids.SiteStandardPublication
2020+ );
2121+}
2222+2323+/**
2424+ * Check if a collection belongs to the site.standard namespace.
2525+ */
2626+export function isSiteStandardCollection(collection: string): boolean {
2727+ return collection.startsWith("site.standard.");
2828+}
2929+3030+/**
3131+ * Check if a collection belongs to the pub.leaflet namespace.
3232+ */
3333+export function isPubLeafletCollection(collection: string): boolean {
3434+ return collection.startsWith("pub.leaflet.");
3535+}
3636+3737+/**
3838+ * Get the document $type to use based on an existing URI's collection.
3939+ * If no existing URI or collection isn't a document, defaults to site.standard.document.
4040+ */
4141+export function getDocumentType(existingCollection?: string): "pub.leaflet.document" | "site.standard.document" {
4242+ if (existingCollection === ids.PubLeafletDocument) {
4343+ return ids.PubLeafletDocument as "pub.leaflet.document";
4444+ }
4545+ return ids.SiteStandardDocument as "site.standard.document";
4646+}
4747+4848+/**
4949+ * Get the publication $type to use based on an existing URI's collection.
5050+ * If no existing URI or collection isn't a publication, defaults to site.standard.publication.
5151+ */
5252+export function getPublicationType(existingCollection?: string): "pub.leaflet.publication" | "site.standard.publication" {
5353+ if (existingCollection === ids.PubLeafletPublication) {
5454+ return ids.PubLeafletPublication as "pub.leaflet.publication";
5555+ }
5656+ return ids.SiteStandardPublication as "site.standard.publication";
5757+}
+6-2
src/utils/mentionUtils.ts
···11import { AtUri } from "@atproto/api";
22+import {
33+ isDocumentCollection,
44+ isPublicationCollection,
55+} from "src/utils/collectionHelpers";
2637/**
48 * Converts a DID to a Bluesky profile URL
···1418 try {
1519 const uri = new AtUri(atUri);
16201717- if (uri.collection === "pub.leaflet.publication") {
2121+ if (isPublicationCollection(uri.collection)) {
1822 // Publication URL: /lish/{did}/{rkey}
1923 return `/lish/${uri.host}/${uri.rkey}`;
2020- } else if (uri.collection === "pub.leaflet.document") {
2424+ } else if (isDocumentCollection(uri.collection)) {
2125 // Document URL - we need to resolve this via the API
2226 // For now, create a redirect route that will handle it
2327 return `/lish/uri/${encodeURIComponent(atUri)}`;
+34
src/utils/uriHelpers.ts
···11+import { AtUri } from "@atproto/syntax";
22+import { ids } from "lexicons/api/lexicons";
33+44+/**
55+ * Returns an OR filter string for Supabase queries to match either namespace URI.
66+ * Used for querying documents that may be stored under either pub.leaflet.document
77+ * or site.standard.document namespaces.
88+ */
99+export function documentUriFilter(did: string, rkey: string): string {
1010+ const standard = AtUri.make(did, ids.SiteStandardDocument, rkey).toString();
1111+ const legacy = AtUri.make(did, ids.PubLeafletDocument, rkey).toString();
1212+ return `uri.eq.${standard},uri.eq.${legacy}`;
1313+}
1414+1515+/**
1616+ * Returns an OR filter string for Supabase queries to match either namespace URI.
1717+ * Used for querying publications that may be stored under either pub.leaflet.publication
1818+ * or site.standard.publication namespaces.
1919+ */
2020+export function publicationUriFilter(did: string, rkey: string): string {
2121+ const standard = AtUri.make(did, ids.SiteStandardPublication, rkey).toString();
2222+ const legacy = AtUri.make(did, ids.PubLeafletPublication, rkey).toString();
2323+ return `uri.eq.${standard},uri.eq.${legacy}`;
2424+}
2525+2626+/**
2727+ * Returns an OR filter string for Supabase queries to match a publication by name
2828+ * or by either namespace URI. Used when the rkey might be the publication name.
2929+ */
3030+export function publicationNameOrUriFilter(did: string, nameOrRkey: string): string {
3131+ const standard = AtUri.make(did, ids.SiteStandardPublication, nameOrRkey).toString();
3232+ const legacy = AtUri.make(did, ids.PubLeafletPublication, nameOrRkey).toString();
3333+ return `name.eq."${nameOrRkey}",uri.eq.${standard},uri.eq.${legacy}`;
3434+}