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