a tool for shared writing and social publishing

Compare changes

Choose any two refs to compare.

+124 -66
+6 -3
actions/publishToPublication.ts
··· 199 199 } 200 200 201 201 // Determine the collection to use - preserve existing schema if updating 202 - const existingCollection = existingDocUri ? new AtUri(existingDocUri).collection : undefined; 202 + const existingCollection = existingDocUri 203 + ? new AtUri(existingDocUri).collection 204 + : undefined; 203 205 const documentType = getDocumentType(existingCollection); 204 206 205 207 // Build the pages array (used by both formats) ··· 228 230 if (documentType === "site.standard.document") { 229 231 // site.standard.document format 230 232 // For standalone docs, use HTTPS URL; for publication docs, use the publication AT-URI 231 - const siteUri = publication_uri || `https://leaflet.pub/p/${credentialSession.did}`; 233 + const siteUri = 234 + publication_uri || `https://leaflet.pub/p/${credentialSession.did}`; 232 235 233 236 record = { 234 237 $type: "site.standard.document", 235 238 title: title || "Untitled", 236 239 site: siteUri, 237 - path: rkey, 240 + path: "/" + rkey, 238 241 publishedAt: 239 242 publishedAt || existingRecord.publishedAt || new Date().toISOString(), 240 243 ...(description && { description }),
+30 -48
app/api/inngest/functions/migrate_user_to_standard.ts
··· 109 109 }) 110 110 .filter((x) => x !== null); 111 111 112 - // Run all PDS writes in parallel 113 - const pubPdsResults = await Promise.all( 114 - publicationsToMigrate.map(({ pub, rkey, newRecord }) => 115 - step.run(`pds-write-publication-${pub.uri}`, async () => { 112 + // Run PDS + DB writes together for each publication 113 + const pubResults = await Promise.all( 114 + publicationsToMigrate.map(({ pub, rkey, normalized, newRecord }) => 115 + step.run(`migrate-publication-${pub.uri}`, async () => { 116 + // PDS write 116 117 const agent = await createAuthenticatedAgent(did); 117 118 const putResult = await agent.com.atproto.repo.putRecord({ 118 119 repo: did, ··· 121 122 record: newRecord, 122 123 validate: false, 123 124 }); 124 - return { oldUri: pub.uri, newUri: putResult.data.uri }; 125 - }), 126 - ), 127 - ); 125 + const newUri = putResult.data.uri; 128 126 129 - // Run all DB writes in parallel 130 - const pubDbResults = await Promise.all( 131 - publicationsToMigrate.map(({ pub, normalized, newRecord }, index) => { 132 - const newUri = pubPdsResults[index].newUri; 133 - return step.run(`db-write-publication-${pub.uri}`, async () => { 127 + // DB write 134 128 const { error: dbError } = await supabaseServerClient 135 129 .from("publications") 136 130 .upsert({ ··· 149 143 }; 150 144 } 151 145 return { success: true as const, oldUri: pub.uri, newUri }; 152 - }); 153 - }), 146 + }), 147 + ), 154 148 ); 155 149 156 150 // Process results 157 - for (const result of pubDbResults) { 151 + for (const result of pubResults) { 158 152 if (result.success) { 159 153 publicationUriMap[result.oldUri] = result.newUri; 160 154 stats.publicationsMigrated++; ··· 239 233 $type: "site.standard.document", 240 234 title: normalized.title || "Untitled", 241 235 site: siteValue, 242 - path: rkey, 236 + path: "/" + rkey, 243 237 publishedAt: normalized.publishedAt || new Date().toISOString(), 244 238 description: normalized.description, 245 239 content: normalized.content, ··· 252 246 }) 253 247 .filter((x) => x !== null); 254 248 255 - // Run all PDS writes in parallel 256 - const docPdsResults = await Promise.all( 257 - documentsToMigrate.map(({ doc, rkey, newRecord }) => 258 - step.run(`pds-write-document-${doc.uri}`, async () => { 249 + // Run PDS + DB writes together for each document 250 + const docResults = await Promise.all( 251 + documentsToMigrate.map(({ doc, rkey, newRecord, oldPubUri }) => 252 + step.run(`migrate-document-${doc.uri}`, async () => { 253 + // PDS write 259 254 const agent = await createAuthenticatedAgent(did); 260 255 const putResult = await agent.com.atproto.repo.putRecord({ 261 256 repo: did, ··· 264 259 record: newRecord, 265 260 validate: false, 266 261 }); 267 - return { oldUri: doc.uri, newUri: putResult.data.uri }; 268 - }), 269 - ), 270 - ); 262 + const newUri = putResult.data.uri; 271 263 272 - // Run all DB writes in parallel 273 - const docDbResults = await Promise.all( 274 - documentsToMigrate.map(({ doc, newRecord, oldPubUri }, index) => { 275 - const newUri = docPdsResults[index].newUri; 276 - return step.run(`db-write-document-${doc.uri}`, async () => { 264 + // DB write 277 265 const { error: dbError } = await supabaseServerClient 278 266 .from("documents") 279 267 .upsert({ ··· 302 290 } 303 291 304 292 return { success: true as const, oldUri: doc.uri, newUri }; 305 - }); 306 - }), 293 + }), 294 + ), 307 295 ); 308 296 309 297 // Process results 310 - for (const result of docDbResults) { 298 + for (const result of docResults) { 311 299 if (result.success) { 312 300 documentUriMap[result.oldUri] = result.newUri; 313 301 stats.documentsMigrated++; ··· 428 416 }) 429 417 .filter((x) => x !== null); 430 418 431 - // Run all PDS writes in parallel 432 - const subPdsResults = await Promise.all( 419 + // Run PDS + DB writes together for each subscription 420 + const subResults = await Promise.all( 433 421 subscriptionsToMigrate.map(({ sub, rkey, newRecord }) => 434 - step.run(`pds-write-subscription-${sub.uri}`, async () => { 422 + step.run(`migrate-subscription-${sub.uri}`, async () => { 423 + // PDS write 435 424 const agent = await createAuthenticatedAgent(did); 436 425 const putResult = await agent.com.atproto.repo.putRecord({ 437 426 repo: did, ··· 440 429 record: newRecord, 441 430 validate: false, 442 431 }); 443 - return { oldUri: sub.uri, newUri: putResult.data.uri }; 444 - }), 445 - ), 446 - ); 432 + const newUri = putResult.data.uri; 447 433 448 - // Run all DB writes in parallel 449 - const subDbResults = await Promise.all( 450 - subscriptionsToMigrate.map(({ sub, newRecord }, index) => { 451 - const newUri = subPdsResults[index].newUri; 452 - return step.run(`db-write-subscription-${sub.uri}`, async () => { 434 + // DB write 453 435 const { error: dbError } = await supabaseServerClient 454 436 .from("publication_subscriptions") 455 437 .update({ ··· 467 449 }; 468 450 } 469 451 return { success: true as const, oldUri: sub.uri, newUri }; 470 - }); 471 - }), 452 + }), 453 + ), 472 454 ); 473 455 474 456 // Process results 475 - for (const result of subDbResults) { 457 + for (const result of subResults) { 476 458 if (result.success) { 477 459 userSubscriptionUriMap[result.oldUri] = result.newUri; 478 460 stats.userSubscriptionsMigrated++;
+34
app/lish/[did]/[publication]/.well-known/site.standard.publication/route.ts
··· 1 + import { publicationNameOrUriFilter } from "src/utils/uriHelpers"; 2 + import { supabaseServerClient } from "supabase/serverClient"; 3 + 4 + export async function GET( 5 + req: Request, 6 + props: { 7 + params: Promise<{ publication: string; did: string }>; 8 + }, 9 + ) { 10 + let params = await props.params; 11 + let did = decodeURIComponent(params.did); 12 + let publication_name = decodeURIComponent(params.publication); 13 + let [{ data: publications }] = await Promise.all([ 14 + supabaseServerClient 15 + .from("publications") 16 + .select( 17 + `*, 18 + publication_subscriptions(*), 19 + documents_in_publications(documents( 20 + *, 21 + comments_on_documents(count), 22 + document_mentions_in_bsky(count) 23 + )) 24 + `, 25 + ) 26 + .eq("identity_did", did) 27 + .or(publicationNameOrUriFilter(did, publication_name)) 28 + .order("uri", { ascending: false }) 29 + .limit(1), 30 + ]); 31 + let publication = publications?.[0]; 32 + if (!did || !publication) return new Response(null, { status: 404 }); 33 + return new Response(publication.uri); 34 + }
+7 -4
app/p/[didOrHandle]/[rkey]/page.tsx
··· 38 38 39 39 return { 40 40 icons: { 41 - other: { 42 - rel: "alternate", 43 - url: document.uri, 44 - }, 41 + other: [ 42 + { 43 + rel: "alternate", 44 + url: document.uri, 45 + }, 46 + { rel: "site.standard.document", url: document.uri }, 47 + ], 45 48 }, 46 49 title: docRecord.title, 47 50 description: docRecord?.description || "",
+2 -1
components/Blocks/PublicationPollBlock.tsx
··· 27 27 setAreYouSure?: (value: boolean) => void; 28 28 }, 29 29 ) => { 30 - let { data: publicationData } = useLeafletPublicationData(); 30 + let { data: publicationData, normalizedDocument } = 31 + useLeafletPublicationData(); 31 32 let isSelected = useUIState((s) => 32 33 s.selectedBlocks.find((b) => b.value === props.entityID), 33 34 );
+2 -2
lexicons/api/lexicons.ts
··· 2215 2215 type: 'ref', 2216 2216 }, 2217 2217 theme: { 2218 - type: 'ref', 2219 - ref: 'lex:pub.leaflet.publication#theme', 2218 + type: 'union', 2219 + refs: ['lex:pub.leaflet.publication#theme'], 2220 2220 }, 2221 2221 description: { 2222 2222 maxGraphemes: 300,
+1 -1
lexicons/api/types/site/standard/publication.ts
··· 15 15 export interface Record { 16 16 $type: 'site.standard.publication' 17 17 basicTheme?: SiteStandardThemeBasic.Main 18 - theme?: PubLeafletPublication.Theme 18 + theme?: $Typed<PubLeafletPublication.Theme> | { $type: string } 19 19 description?: string 20 20 icon?: BlobRef 21 21 name: string
+2 -2
lexicons/site/standard/publication.json
··· 9 9 "type": "ref" 10 10 }, 11 11 "theme": { 12 - "type": "ref", 13 - "ref": "pub.leaflet.publication#theme" 12 + "type": "union", 13 + "refs": ["pub.leaflet.publication#theme"] 14 14 }, 15 15 "description": { 16 16 "maxGraphemes": 300,
+40 -5
lexicons/src/normalize.ts
··· 14 14 */ 15 15 16 16 import type * as PubLeafletDocument from "../api/types/pub/leaflet/document"; 17 - import type * as PubLeafletPublication from "../api/types/pub/leaflet/publication"; 17 + import * as PubLeafletPublication from "../api/types/pub/leaflet/publication"; 18 18 import type * as PubLeafletContent from "../api/types/pub/leaflet/content"; 19 19 import type * as SiteStandardDocument from "../api/types/site/standard/document"; 20 20 import type * as SiteStandardPublication from "../api/types/site/standard/publication"; ··· 31 31 }; 32 32 33 33 // Normalized publication type - uses the generated site.standard.publication type 34 - export type NormalizedPublication = SiteStandardPublication.Record; 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 38 + export 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 + }; 35 48 36 49 /** 37 50 * Checks if the record is a pub.leaflet.document ··· 210 223 ): NormalizedPublication | null { 211 224 if (!record || typeof record !== "object") return null; 212 225 213 - // Pass through site.standard records directly 226 + // Pass through site.standard records directly, but validate the theme 214 227 if (isStandardPublication(record)) { 215 - return record; 228 + // Validate theme - only keep if it's a valid pub.leaflet.publication#theme 229 + const theme = PubLeafletPublication.isTheme(record.theme) 230 + ? (record.theme as $Typed<PubLeafletPublication.Theme>) 231 + : undefined; 232 + return { 233 + ...record, 234 + theme, 235 + }; 216 236 } 217 237 218 238 if (isLeafletPublication(record)) { ··· 225 245 226 246 const basicTheme = leafletThemeToBasicTheme(record.theme); 227 247 248 + // Validate theme - only keep if it's a valid pub.leaflet.publication#theme with $type set 249 + // For legacy records without $type, add it during normalization 250 + let theme: $Typed<PubLeafletPublication.Theme> | undefined; 251 + if (record.theme) { 252 + if (PubLeafletPublication.isTheme(record.theme)) { 253 + theme = record.theme as $Typed<PubLeafletPublication.Theme>; 254 + } else { 255 + // Legacy theme without $type - add it 256 + theme = { 257 + ...record.theme, 258 + $type: "pub.leaflet.publication#theme", 259 + }; 260 + } 261 + } 262 + 228 263 // Convert preferences to site.standard format (strip/replace $type) 229 264 const preferences: SiteStandardPublication.Preferences | undefined = 230 265 record.preferences ··· 243 278 description: record.description, 244 279 icon: record.icon, 245 280 basicTheme, 246 - theme: record.theme, 281 + theme, 247 282 preferences, 248 283 }; 249 284 }