this repo has no description

feat: change document path to /pub/{rkey}/{slug}

Replace path-based document addressing with a stable canonical path
tied to the ATProto record key. New records are created with a
placeholder path, then immediately updated via putRecord with the
final /pub/{rkey}/{slug} path.

- Remove pathPrefix and pathTemplate config options
- Remove resolvePostPath() and resolvePathTemplate() from markdown.ts
- createDocument() always makes 2 API calls (create + putRecord)
- updateDocument() builds path from parsed rkey
- sync and publish commands switch to atUri-based record matching

+39 -142
-6
packages/cli/src/commands/init.ts
··· 85 message: "Build output directory (for link tag injection):", 86 placeholder: "./dist", 87 }), 88 - pathPrefix: () => 89 - text({ 90 - message: "URL path prefix for posts:", 91 - placeholder: "/posts, /blog, /articles, etc.", 92 - }), 93 }, 94 { onCancel }, 95 ); ··· 328 imagesDir: siteConfig.imagesDir || undefined, 329 publicDir: siteConfig.publicDir || "./public", 330 outputDir: siteConfig.outputDir || "./dist", 331 - pathPrefix: siteConfig.pathPrefix || "/posts", 332 publicationUri, 333 pdsUrl, 334 frontmatter: frontmatterMapping,
··· 85 message: "Build output directory (for link tag injection):", 86 placeholder: "./dist", 87 }), 88 }, 89 { onCancel }, 90 ); ··· 323 imagesDir: siteConfig.imagesDir || undefined, 324 publicDir: siteConfig.publicDir || "./public", 325 outputDir: siteConfig.outputDir || "./dist", 326 publicationUri, 327 pdsUrl, 328 frontmatter: frontmatterMapping,
+12 -18
packages/cli/src/commands/publish.ts
··· 19 addBskyPostRefToDocument, 20 deleteRecord, 21 listDocuments, 22 } from "../lib/atproto"; 23 import { 24 scanContentDirectory, 25 getContentHash, 26 updateFrontmatterWithAtUri, 27 - resolvePostPath, 28 } from "../lib/markdown"; 29 import type { BlogPost, BlobObject, StrongRef } from "../lib/types"; 30 import { exitOnCancel } from "../lib/prompts"; ··· 260 const pdsDocuments = await listDocuments(ag, config.publicationUri); 261 s.stop(`Found ${pdsDocuments.length} documents on PDS`); 262 263 - const pathPrefix = config.pathPrefix || "/posts"; 264 - const postsByPath = new Map<string, BlogPost>(); 265 - for (const post of posts) { 266 - postsByPath.set(`${pathPrefix}/${post.slug}`, post); 267 - } 268 const deletedAtUris = new Set(deletedEntries.map((e) => e.atUri)); 269 for (const doc of pdsDocuments) { 270 - if (!postsByPath.has(doc.value.path) && !deletedAtUris.has(doc.uri)) { 271 unmatchedEntries.push({ 272 atUri: doc.uri, 273 title: doc.value.title || doc.value.path, ··· 313 } 314 } 315 316 - let postUrl = ""; 317 - if (verbose) { 318 - const postPath = resolvePostPath( 319 - post, 320 - config.pathPrefix, 321 - config.pathTemplate, 322 - ); 323 - postUrl = `\n ${config.siteUrl}${postPath}`; 324 - } 325 log.message( 326 - ` ${icon} ${post.filePath} (${reason})${bskyNote}${postUrl}`, 327 ); 328 } 329 } ··· 455 } else { 456 // Create Bluesky post 457 try { 458 - const canonicalUrl = `${config.siteUrl}${resolvePostPath(post, config.pathPrefix, config.pathTemplate)}`; 459 460 bskyPostRef = await createBlueskyPost(agent, { 461 title: post.frontmatter.title,
··· 19 addBskyPostRefToDocument, 20 deleteRecord, 21 listDocuments, 22 + parseAtUri, 23 } from "../lib/atproto"; 24 import { 25 scanContentDirectory, 26 getContentHash, 27 updateFrontmatterWithAtUri, 28 } from "../lib/markdown"; 29 import type { BlogPost, BlobObject, StrongRef } from "../lib/types"; 30 import { exitOnCancel } from "../lib/prompts"; ··· 260 const pdsDocuments = await listDocuments(ag, config.publicationUri); 261 s.stop(`Found ${pdsDocuments.length} documents on PDS`); 262 263 + const knownAtUris = new Set( 264 + posts 265 + .map((p) => p.frontmatter.atUri) 266 + .filter((uri): uri is string => uri != null), 267 + ); 268 const deletedAtUris = new Set(deletedEntries.map((e) => e.atUri)); 269 for (const doc of pdsDocuments) { 270 + if (!knownAtUris.has(doc.uri) && !deletedAtUris.has(doc.uri)) { 271 unmatchedEntries.push({ 272 atUri: doc.uri, 273 title: doc.value.title || doc.value.path, ··· 313 } 314 } 315 316 log.message( 317 + ` ${icon} ${post.filePath} (${reason})${bskyNote}`, 318 ); 319 } 320 } ··· 446 } else { 447 // Create Bluesky post 448 try { 449 + const parsedUri = parseAtUri(atUri); 450 + const canonicalUrl = parsedUri 451 + ? `${config.siteUrl}/pub/${parsedUri.rkey}/${post.slug}` 452 + : config.siteUrl; 453 454 bskyPostRef = await createBlueskyPost(agent, { 455 title: post.frontmatter.title,
+8 -14
packages/cli/src/commands/sync.ts
··· 18 getContentHash, 19 getTextContent, 20 updateFrontmatterWithAtUri, 21 - resolvePostPath, 22 } from "../lib/markdown"; 23 import { exitOnCancel } from "../lib/prompts"; 24 ··· 212 }); 213 s.stop(`Found ${localPosts.length} local posts`); 214 215 - // Build a map of path -> local post for matching 216 - // Document path is like /posts/my-post-slug (or custom pathPrefix/pathTemplate) 217 - const postsByPath = new Map<string, (typeof localPosts)[0]>(); 218 for (const post of localPosts) { 219 - const postPath = resolvePostPath( 220 - post, 221 - config.pathPrefix, 222 - config.pathTemplate, 223 - ); 224 - postsByPath.set(postPath, post); 225 } 226 227 // Load existing state ··· 236 log.message("\nMatching documents to local files:\n"); 237 238 for (const doc of documents) { 239 - const docPath = doc.value.path; 240 - const localPost = postsByPath.get(docPath); 241 242 if (localPost) { 243 matchedCount++; 244 log.message(` ✓ ${doc.value.title}`); 245 - log.message(` Path: ${docPath}`); 246 log.message(` URI: ${doc.uri}`); 247 log.message(` File: ${path.basename(localPost.filePath)}`); 248 ··· 275 } else { 276 unmatchedCount++; 277 log.message(` ✗ ${doc.value.title} (no matching local file)`); 278 - log.message(` Path: ${docPath}`); 279 log.message(` URI: ${doc.uri}`); 280 } 281 log.message("");
··· 18 getContentHash, 19 getTextContent, 20 updateFrontmatterWithAtUri, 21 } from "../lib/markdown"; 22 import { exitOnCancel } from "../lib/prompts"; 23 ··· 211 }); 212 s.stop(`Found ${localPosts.length} local posts`); 213 214 + // Build a map of atUri -> local post for matching 215 + const postsByAtUri = new Map<string, (typeof localPosts)[0]>(); 216 for (const post of localPosts) { 217 + if (post.frontmatter.atUri) { 218 + postsByAtUri.set(post.frontmatter.atUri, post); 219 + } 220 } 221 222 // Load existing state ··· 231 log.message("\nMatching documents to local files:\n"); 232 233 for (const doc of documents) { 234 + const localPost = postsByAtUri.get(doc.uri); 235 236 if (localPost) { 237 matchedCount++; 238 log.message(` ✓ ${doc.value.title}`); 239 + log.message(` Path: ${doc.value.path}`); 240 log.message(` URI: ${doc.uri}`); 241 log.message(` File: ${path.basename(localPost.filePath)}`); 242 ··· 269 } else { 270 unmatchedCount++; 271 log.message(` ✗ ${doc.value.title} (no matching local file)`); 272 + log.message(` Path: ${doc.value.path}`); 273 log.message(` URI: ${doc.uri}`); 274 } 275 log.message("");
+1 -12
packages/cli/src/commands/update.ts
··· 70 const configSummary = [ 71 `Site URL: ${config.siteUrl}`, 72 `Content Dir: ${config.contentDir}`, 73 - `Path Prefix: ${config.pathPrefix || "/posts"}`, 74 `Publication URI: ${config.publicationUri}`, 75 config.imagesDir ? `Images Dir: ${config.imagesDir}` : null, 76 config.outputDir ? `Output Dir: ${config.outputDir}` : null, ··· 89 await select({ 90 message: "Select a section to edit:", 91 options: [ 92 - { label: "Site settings (siteUrl, pathPrefix)", value: "site" }, 93 { 94 label: 95 "Directory paths (contentDir, imagesDir, publicDir, outputDir)", ··· 153 imagesDir: configUpdated.imagesDir, 154 publicDir: configUpdated.publicDir, 155 outputDir: configUpdated.outputDir, 156 - pathPrefix: configUpdated.pathPrefix, 157 publicationUri: configUpdated.publicationUri, 158 pdsUrl: configUpdated.pdsUrl, 159 frontmatter: configUpdated.frontmatter, 160 ignore: configUpdated.ignore, 161 removeIndexFromSlug: configUpdated.removeIndexFromSlug, 162 stripDatePrefix: configUpdated.stripDatePrefix, 163 - pathTemplate: configUpdated.pathTemplate, 164 textContentField: configUpdated.textContentField, 165 bluesky: configUpdated.bluesky, 166 }); ··· 190 }), 191 ); 192 193 - const pathPrefix = exitOnCancel( 194 - await text({ 195 - message: "URL path prefix for posts:", 196 - initialValue: config.pathPrefix || "/posts", 197 - }), 198 - ); 199 - 200 return { 201 ...config, 202 siteUrl, 203 - pathPrefix: pathPrefix || undefined, 204 }; 205 } 206
··· 70 const configSummary = [ 71 `Site URL: ${config.siteUrl}`, 72 `Content Dir: ${config.contentDir}`, 73 `Publication URI: ${config.publicationUri}`, 74 config.imagesDir ? `Images Dir: ${config.imagesDir}` : null, 75 config.outputDir ? `Output Dir: ${config.outputDir}` : null, ··· 88 await select({ 89 message: "Select a section to edit:", 90 options: [ 91 + { label: "Site settings (siteUrl)", value: "site" }, 92 { 93 label: 94 "Directory paths (contentDir, imagesDir, publicDir, outputDir)", ··· 152 imagesDir: configUpdated.imagesDir, 153 publicDir: configUpdated.publicDir, 154 outputDir: configUpdated.outputDir, 155 publicationUri: configUpdated.publicationUri, 156 pdsUrl: configUpdated.pdsUrl, 157 frontmatter: configUpdated.frontmatter, 158 ignore: configUpdated.ignore, 159 removeIndexFromSlug: configUpdated.removeIndexFromSlug, 160 stripDatePrefix: configUpdated.stripDatePrefix, 161 textContentField: configUpdated.textContentField, 162 bluesky: configUpdated.bluesky, 163 }); ··· 187 }), 188 ); 189 190 return { 191 ...config, 192 siteUrl, 193 }; 194 } 195
+18 -29
packages/cli/src/lib/atproto.ts
··· 2 import * as mimeTypes from "mime-types"; 3 import * as fs from "node:fs/promises"; 4 import * as path from "node:path"; 5 - import { getTextContent, resolvePostPath } from "./markdown"; 6 import { getOAuthClient } from "./oauth-client"; 7 import type { 8 BlobObject, ··· 245 config: PublisherConfig, 246 coverImage?: BlobObject, 247 ): Promise<string> { 248 - const postPath = resolvePostPath( 249 - post, 250 - config.pathPrefix, 251 - config.pathTemplate, 252 - ); 253 const publishDate = new Date(post.frontmatter.publishDate); 254 const trimmedContent = post.content.trim(); 255 const textContent = getTextContent(post, config.textContentField); ··· 260 $type: "site.standard.document", 261 title, 262 site: config.publicationUri, 263 - path: postPath, 264 textContent: textContent.slice(0, 10000), 265 publishedAt: publishDate.toISOString(), 266 }; 267 - 268 - if (!config.canonicalUrlBuilder) { 269 - record.canonicalUrl = `${config.siteUrl}${postPath}`; 270 - } 271 272 if (post.frontmatter.description) { 273 record.description = post.frontmatter.description; ··· 288 }); 289 290 const atUri = response.data.uri; 291 292 - if (config.canonicalUrlBuilder) { 293 - const parsed = parseAtUri(atUri); 294 - if (parsed) { 295 - record.canonicalUrl = config.canonicalUrlBuilder(atUri, post); 296 - await agent.com.atproto.repo.putRecord({ 297 - repo: agent.did!, 298 - collection: parsed.collection, 299 - rkey: parsed.rkey, 300 - record, 301 - }); 302 - } 303 } 304 305 return atUri; ··· 321 322 const [, , collection, rkey] = uriMatch; 323 324 - const postPath = resolvePostPath( 325 - post, 326 - config.pathPrefix, 327 - config.pathTemplate, 328 - ); 329 const publishDate = new Date(post.frontmatter.publishDate); 330 const trimmedContent = post.content.trim(); 331 const textContent = getTextContent(post, config.textContentField); ··· 345 $type: "site.standard.document", 346 title, 347 site: config.publicationUri, 348 - path: postPath, 349 textContent: textContent.slice(0, 10000), 350 publishedAt: publishDate.toISOString(), 351 canonicalUrl: config.canonicalUrlBuilder 352 ? config.canonicalUrlBuilder(atUri, post) 353 - : `${config.siteUrl}${postPath}`, 354 }; 355 356 if (post.frontmatter.description) {
··· 2 import * as mimeTypes from "mime-types"; 3 import * as fs from "node:fs/promises"; 4 import * as path from "node:path"; 5 + import { getTextContent } from "./markdown"; 6 import { getOAuthClient } from "./oauth-client"; 7 import type { 8 BlobObject, ··· 245 config: PublisherConfig, 246 coverImage?: BlobObject, 247 ): Promise<string> { 248 const publishDate = new Date(post.frontmatter.publishDate); 249 const trimmedContent = post.content.trim(); 250 const textContent = getTextContent(post, config.textContentField); ··· 255 $type: "site.standard.document", 256 title, 257 site: config.publicationUri, 258 + path: `/${post.slug}`, 259 textContent: textContent.slice(0, 10000), 260 publishedAt: publishDate.toISOString(), 261 }; 262 263 if (post.frontmatter.description) { 264 record.description = post.frontmatter.description; ··· 279 }); 280 281 const atUri = response.data.uri; 282 + const parsed = parseAtUri(atUri); 283 284 + if (parsed) { 285 + const finalPath = `/pub/${parsed.rkey}/${post.slug}`; 286 + record.path = finalPath; 287 + record.canonicalUrl = config.canonicalUrlBuilder 288 + ? config.canonicalUrlBuilder(atUri, post) 289 + : `${config.siteUrl}${finalPath}`; 290 + await agent.com.atproto.repo.putRecord({ 291 + repo: agent.did!, 292 + collection: parsed.collection, 293 + rkey: parsed.rkey, 294 + record, 295 + }); 296 } 297 298 return atUri; ··· 314 315 const [, , collection, rkey] = uriMatch; 316 317 + const finalPath = `/pub/${rkey}/${post.slug}`; 318 const publishDate = new Date(post.frontmatter.publishDate); 319 const trimmedContent = post.content.trim(); 320 const textContent = getTextContent(post, config.textContentField); ··· 334 $type: "site.standard.document", 335 title, 336 site: config.publicationUri, 337 + path: finalPath, 338 textContent: textContent.slice(0, 10000), 339 publishedAt: publishDate.toISOString(), 340 canonicalUrl: config.canonicalUrlBuilder 341 ? config.canonicalUrlBuilder(atUri, post) 342 + : `${config.siteUrl}${finalPath}`, 343 }; 344 345 if (post.frontmatter.description) {
-10
packages/cli/src/lib/config.ts
··· 76 imagesDir?: string; 77 publicDir?: string; 78 outputDir?: string; 79 - pathPrefix?: string; 80 publicationUri: string; 81 pdsUrl?: string; 82 frontmatter?: FrontmatterMapping; 83 ignore?: string[]; 84 removeIndexFromSlug?: boolean; 85 stripDatePrefix?: boolean; 86 - pathTemplate?: string; 87 textContentField?: string; 88 bluesky?: BlueskyConfig; 89 }): string { ··· 104 config.outputDir = options.outputDir; 105 } 106 107 - if (options.pathPrefix && options.pathPrefix !== "/posts") { 108 - config.pathPrefix = options.pathPrefix; 109 - } 110 - 111 config.publicationUri = options.publicationUri; 112 113 if (options.pdsUrl && options.pdsUrl !== "https://bsky.social") { ··· 128 129 if (options.stripDatePrefix) { 130 config.stripDatePrefix = options.stripDatePrefix; 131 - } 132 - 133 - if (options.pathTemplate) { 134 - config.pathTemplate = options.pathTemplate; 135 } 136 137 if (options.textContentField) {
··· 76 imagesDir?: string; 77 publicDir?: string; 78 outputDir?: string; 79 publicationUri: string; 80 pdsUrl?: string; 81 frontmatter?: FrontmatterMapping; 82 ignore?: string[]; 83 removeIndexFromSlug?: boolean; 84 stripDatePrefix?: boolean; 85 textContentField?: string; 86 bluesky?: BlueskyConfig; 87 }): string { ··· 102 config.outputDir = options.outputDir; 103 } 104 105 config.publicationUri = options.publicationUri; 106 107 if (options.pdsUrl && options.pdsUrl !== "https://bsky.social") { ··· 122 123 if (options.stripDatePrefix) { 124 config.stripDatePrefix = options.stripDatePrefix; 125 } 126 127 if (options.textContentField) {
-51
packages/cli/src/lib/markdown.ts
··· 197 .replace(/[^\w-]/g, ""); 198 } 199 200 - export function resolvePathTemplate(template: string, post: BlogPost): string { 201 - const publishDate = new Date(post.frontmatter.publishDate); 202 - const year = String(publishDate.getFullYear()); 203 - const yearUTC = String(publishDate.getUTCFullYear()); 204 - const month = String(publishDate.getMonth() + 1).padStart(2, "0"); 205 - const monthUTC = String(publishDate.getUTCMonth() + 1).padStart(2, "0"); 206 - const day = String(publishDate.getDate()).padStart(2, "0"); 207 - const dayUTC = String(publishDate.getUTCDate()).padStart(2, "0"); 208 - 209 - const slugifiedTitle = slugifyTitle(post.frontmatter.title); 210 - 211 - // Replace known tokens 212 - let result = template 213 - .replace(/\{slug\}/g, post.slug) 214 - .replace(/\{year\}/g, year) 215 - .replace(/\{yearUTC\}/g, yearUTC) 216 - .replace(/\{month\}/g, month) 217 - .replace(/\{monthUTC\}/g, monthUTC) 218 - .replace(/\{day\}/g, day) 219 - .replace(/\{dayUTC\}/g, dayUTC) 220 - .replace(/\{title\}/g, slugifiedTitle); 221 - 222 - // Replace any remaining {field} tokens with raw frontmatter values 223 - result = result.replace(/\{(\w+)\}/g, (_match, field: string) => { 224 - const value = post.rawFrontmatter[field]; 225 - if (value != null && typeof value === "string") { 226 - return value; 227 - } 228 - return ""; 229 - }); 230 - 231 - // Ensure leading slash 232 - if (!result.startsWith("/")) { 233 - result = `/${result}`; 234 - } 235 - 236 - return result; 237 - } 238 - 239 - export function resolvePostPath( 240 - post: BlogPost, 241 - pathPrefix?: string, 242 - pathTemplate?: string, 243 - ): string { 244 - if (pathTemplate) { 245 - return resolvePathTemplate(pathTemplate, post); 246 - } 247 - const prefix = pathPrefix || "/posts"; 248 - return `${prefix}/${post.slug}`; 249 - } 250 - 251 export async function getContentHash(content: string): Promise<string> { 252 const encoder = new TextEncoder(); 253 const data = encoder.encode(content);
··· 197 .replace(/[^\w-]/g, ""); 198 } 199 200 export async function getContentHash(content: string): Promise<string> { 201 const encoder = new TextEncoder(); 202 const data = encoder.encode(content);
-2
packages/cli/src/lib/types.ts
··· 31 imagesDir?: string; // Directory containing cover images 32 publicDir?: string; // Static/public folder for .well-known files (default: public) 33 outputDir?: string; // Built output directory for inject command 34 - pathPrefix?: string; // URL path prefix for posts (default: /posts) 35 publicationUri: string; 36 pdsUrl?: string; 37 identity?: string; // Which stored identity to use (matches identifier) ··· 39 ignore?: string[]; // Glob patterns for files to ignore (e.g., ["_index.md", "**/drafts/**"]) 40 removeIndexFromSlug?: boolean; // Remove "/index" or "/_index" suffix from paths (default: false) 41 stripDatePrefix?: boolean; // Remove YYYY-MM-DD- prefix from filenames (Jekyll-style, default: false) 42 - pathTemplate?: string; // URL path template with tokens like {year}/{month}/{day}/{slug} (overrides pathPrefix + slug) 43 textContentField?: string; // Frontmatter field to use for textContent instead of markdown body 44 canonicalUrlBuilder?: (atUri: string, post: BlogPost) => string; 45 bluesky?: BlueskyConfig; // Optional Bluesky posting configuration
··· 31 imagesDir?: string; // Directory containing cover images 32 publicDir?: string; // Static/public folder for .well-known files (default: public) 33 outputDir?: string; // Built output directory for inject command 34 publicationUri: string; 35 pdsUrl?: string; 36 identity?: string; // Which stored identity to use (matches identifier) ··· 38 ignore?: string[]; // Glob patterns for files to ignore (e.g., ["_index.md", "**/drafts/**"]) 39 removeIndexFromSlug?: boolean; // Remove "/index" or "/_index" suffix from paths (default: false) 40 stripDatePrefix?: boolean; // Remove YYYY-MM-DD- prefix from filenames (Jekyll-style, default: false) 41 textContentField?: string; // Frontmatter field to use for textContent instead of markdown body 42 canonicalUrlBuilder?: (atUri: string, post: BlogPost) => string; 43 bluesky?: BlueskyConfig; // Optional Bluesky posting configuration