A CLI for publishing standard.site documents to ATProto sequoia.pub
standard site lexicon cli publishing

chore: checkpoint

+166 -168
+166 -168
packages/cli/src/commands/init.ts
··· 8 8 select, 9 9 spinner, 10 10 log, 11 + group, 11 12 } from "@clack/prompts"; 12 13 import * as path from "path"; 13 14 import { findConfig, generateConfigTemplate } from "../lib/config"; 14 15 import { loadCredentials } from "../lib/credentials"; 15 16 import { createAgent, createPublication } from "../lib/atproto"; 16 17 import type { FrontmatterMapping } from "../lib/types"; 17 - import { exitOnCancel } from "../lib/prompts"; 18 + 19 + const onCancel = () => { 20 + outro("Setup cancelled"); 21 + process.exit(0); 22 + }; 18 23 19 24 export const initCommand = command({ 20 25 name: "init", ··· 26 31 // Check if config already exists 27 32 const existingConfig = await findConfig(); 28 33 if (existingConfig) { 29 - const overwrite = exitOnCancel( 30 - await confirm({ 31 - message: `Config already exists at ${existingConfig}. Overwrite?`, 32 - initialValue: false, 33 - }), 34 - ); 34 + const overwrite = await confirm({ 35 + message: `Config already exists at ${existingConfig}. Overwrite?`, 36 + initialValue: false, 37 + }); 38 + if (overwrite === Symbol.for("cancel")) { 39 + onCancel(); 40 + } 35 41 if (!overwrite) { 36 42 log.info("Keeping existing configuration"); 37 43 return; ··· 40 46 41 47 note("Follow the prompts to build your config for publishing", "Setup"); 42 48 43 - const siteUrl = exitOnCancel( 44 - await text({ 45 - message: "Site URL (canonical URL of your site):", 46 - placeholder: "https://example.com", 47 - }), 48 - ); 49 - 50 - if (!siteUrl) { 51 - log.error("Site URL is required"); 52 - process.exit(1); 53 - } 54 - 55 - const contentDir = exitOnCancel( 56 - await text({ 57 - message: "Content directory:", 58 - placeholder: "./src/content/blog", 59 - }), 49 + // Site configuration group 50 + const siteConfig = await group( 51 + { 52 + siteUrl: () => 53 + text({ 54 + message: "Site URL (canonical URL of your site):", 55 + placeholder: "https://example.com", 56 + validate: (value) => { 57 + if (!value) return "Site URL is required"; 58 + try { 59 + new URL(value); 60 + } catch { 61 + return "Please enter a valid URL"; 62 + } 63 + }, 64 + }), 65 + contentDir: () => 66 + text({ 67 + message: "Content directory:", 68 + placeholder: "./src/content/blog", 69 + }), 70 + imagesDir: () => 71 + text({ 72 + message: "Cover images directory (leave empty to skip):", 73 + placeholder: "./src/assets", 74 + }), 75 + publicDir: () => 76 + text({ 77 + message: "Public/static directory (for .well-known files):", 78 + placeholder: "./public", 79 + }), 80 + outputDir: () => 81 + text({ 82 + message: "Build output directory (for link tag injection):", 83 + placeholder: "./dist", 84 + }), 85 + pathPrefix: () => 86 + text({ 87 + message: "URL path prefix for posts:", 88 + placeholder: "/posts, /blog, /articles, etc.", 89 + }), 90 + }, 91 + { onCancel }, 60 92 ); 61 93 62 - const imagesDir = exitOnCancel( 63 - await text({ 64 - message: "Cover images directory (leave empty to skip):", 65 - placeholder: "./src/assets", 66 - }), 67 - ); 68 - 69 - // Public/static directory for .well-known files 70 - const publicDir = exitOnCancel( 71 - await text({ 72 - message: "Public/static directory (for .well-known files):", 73 - placeholder: "./public", 74 - }), 75 - ); 76 - 77 - // Output directory for inject command 78 - const outputDir = exitOnCancel( 79 - await text({ 80 - message: "Build output directory (for link tag injection):", 81 - placeholder: "./dist", 82 - }), 83 - ); 84 - 85 - // Path prefix for posts 86 - const pathPrefix = exitOnCancel( 87 - await text({ 88 - message: "URL path prefix for posts:", 89 - placeholder: "/posts, /blog, /articles, etc.", 90 - }), 91 - ); 92 - 93 - // Frontmatter mapping configuration 94 94 log.info( 95 95 "Configure your frontmatter field mappings (press Enter to use defaults):", 96 96 ); 97 97 98 - const titleField = exitOnCancel( 99 - await text({ 100 - message: "Field name for title:", 101 - defaultValue: "title", 102 - placeholder: "title", 103 - }), 104 - ); 105 - 106 - const descField = exitOnCancel( 107 - await text({ 108 - message: "Field name for description:", 109 - defaultValue: "description", 110 - placeholder: "description", 111 - }), 112 - ); 113 - 114 - const dateField = exitOnCancel( 115 - await text({ 116 - message: "Field name for publish date:", 117 - defaultValue: "publishDate", 118 - placeholder: "publishDate, pubDate, date, etc.", 119 - }), 120 - ); 121 - 122 - const coverField = exitOnCancel( 123 - await text({ 124 - message: "Field name for cover image:", 125 - defaultValue: "ogImage", 126 - placeholder: "ogImage, coverImage, image, hero, etc.", 127 - }), 128 - ); 129 - 130 - const tagsField = exitOnCancel( 131 - await text({ 132 - message: "Field name for tags:", 133 - defaultValue: "tags", 134 - placeholder: "tags, categories, keywords, etc.", 135 - }), 98 + // Frontmatter mapping group 99 + const frontmatterConfig = await group( 100 + { 101 + titleField: () => 102 + text({ 103 + message: "Field name for title:", 104 + defaultValue: "title", 105 + placeholder: "title", 106 + }), 107 + descField: () => 108 + text({ 109 + message: "Field name for description:", 110 + defaultValue: "description", 111 + placeholder: "description", 112 + }), 113 + dateField: () => 114 + text({ 115 + message: "Field name for publish date:", 116 + defaultValue: "publishDate", 117 + placeholder: "publishDate, pubDate, date, etc.", 118 + }), 119 + coverField: () => 120 + text({ 121 + message: "Field name for cover image:", 122 + defaultValue: "ogImage", 123 + placeholder: "ogImage, coverImage, image, hero, etc.", 124 + }), 125 + tagsField: () => 126 + text({ 127 + message: "Field name for tags:", 128 + defaultValue: "tags", 129 + placeholder: "tags, categories, keywords, etc.", 130 + }), 131 + }, 132 + { onCancel }, 136 133 ); 137 134 135 + // Build frontmatter mapping object 138 136 let frontmatterMapping: FrontmatterMapping | undefined = {}; 139 137 140 - if (titleField && titleField !== "title") { 141 - frontmatterMapping.title = titleField; 138 + if (frontmatterConfig.titleField !== "title") { 139 + frontmatterMapping.title = frontmatterConfig.titleField; 142 140 } 143 - if (descField && descField !== "description") { 144 - frontmatterMapping.description = descField; 141 + if (frontmatterConfig.descField !== "description") { 142 + frontmatterMapping.description = frontmatterConfig.descField; 145 143 } 146 - if (dateField && dateField !== "publishDate") { 147 - frontmatterMapping.publishDate = dateField; 144 + if (frontmatterConfig.dateField !== "publishDate") { 145 + frontmatterMapping.publishDate = frontmatterConfig.dateField; 148 146 } 149 - if (coverField && coverField !== "ogImage") { 150 - frontmatterMapping.coverImage = coverField; 147 + if (frontmatterConfig.coverField !== "ogImage") { 148 + frontmatterMapping.coverImage = frontmatterConfig.coverField; 151 149 } 152 - if (tagsField && tagsField !== "tags") { 153 - frontmatterMapping.tags = tagsField; 150 + if (frontmatterConfig.tagsField !== "tags") { 151 + frontmatterMapping.tags = frontmatterConfig.tagsField; 154 152 } 155 153 156 154 // Only keep frontmatterMapping if it has any custom fields ··· 159 157 } 160 158 161 159 // Publication setup 162 - const publicationChoice = exitOnCancel( 163 - await select({ 164 - message: "Publication setup:", 165 - options: [ 166 - { label: "Create a new publication", value: "create" }, 167 - { label: "Use an existing publication AT URI", value: "existing" }, 168 - ], 169 - }), 170 - ); 160 + const publicationChoice = await select({ 161 + message: "Publication setup:", 162 + options: [ 163 + { label: "Create a new publication", value: "create" }, 164 + { label: "Use an existing publication AT URI", value: "existing" }, 165 + ], 166 + }); 167 + 168 + if (publicationChoice === Symbol.for("cancel")) { 169 + onCancel(); 170 + } 171 171 172 172 let publicationUri: string; 173 - let credentials = await loadCredentials(); 173 + const credentials = await loadCredentials(); 174 174 175 175 if (publicationChoice === "create") { 176 176 // Need credentials to create a publication ··· 195 195 process.exit(1); 196 196 } 197 197 198 - const pubName = exitOnCancel( 199 - await text({ 200 - message: "Publication name:", 201 - placeholder: "My Blog", 202 - }), 203 - ); 204 - 205 - if (!pubName) { 206 - log.error("Publication name is required"); 207 - process.exit(1); 208 - } 209 - 210 - const pubDescription = exitOnCancel( 211 - await text({ 212 - message: "Publication description (optional):", 213 - placeholder: "A blog about...", 214 - }), 215 - ); 216 - 217 - const iconPath = exitOnCancel( 218 - await pathPrompt({ 219 - message: "Icon image path (leave empty to skip):", 220 - }), 221 - ); 222 - 223 - const showInDiscover = exitOnCancel( 224 - await confirm({ 225 - message: "Show in Discover feed?", 226 - initialValue: true, 227 - }), 198 + const publicationConfig = await group( 199 + { 200 + name: () => 201 + text({ 202 + message: "Publication name:", 203 + placeholder: "My Blog", 204 + validate: (value) => { 205 + if (!value) return "Publication name is required"; 206 + }, 207 + }), 208 + description: () => 209 + text({ 210 + message: "Publication description (optional):", 211 + placeholder: "A blog about...", 212 + }), 213 + iconPath: () => 214 + text({ 215 + message: "Icon image path (leave empty to skip):", 216 + placeholder: "./public/favicon.png", 217 + }), 218 + showInDiscover: () => 219 + confirm({ 220 + message: "Show in Discover feed?", 221 + initialValue: true, 222 + }), 223 + }, 224 + { onCancel }, 228 225 ); 229 226 230 227 s.start("Creating publication..."); 231 228 try { 232 229 publicationUri = await createPublication(agent, { 233 - url: siteUrl, 234 - name: pubName, 235 - description: pubDescription || undefined, 236 - iconPath: iconPath || undefined, 237 - showInDiscover, 230 + url: siteConfig.siteUrl, 231 + name: publicationConfig.name, 232 + description: publicationConfig.description || undefined, 233 + iconPath: publicationConfig.iconPath || undefined, 234 + showInDiscover: publicationConfig.showInDiscover, 238 235 }); 239 236 s.stop(`Publication created: ${publicationUri}`); 240 237 } catch (error) { ··· 243 240 process.exit(1); 244 241 } 245 242 } else { 246 - const uri = exitOnCancel( 247 - await text({ 248 - message: "Publication AT URI:", 249 - placeholder: "at://did:plc:.../site.standard.publication/...", 250 - }), 251 - ); 243 + const uri = await text({ 244 + message: "Publication AT URI:", 245 + placeholder: "at://did:plc:.../site.standard.publication/...", 246 + validate: (value) => { 247 + if (!value) return "Publication URI is required"; 248 + }, 249 + }); 252 250 253 - if (!uri) { 254 - log.error("Publication URI is required"); 255 - process.exit(1); 251 + if (uri === Symbol.for("cancel")) { 252 + onCancel(); 256 253 } 257 - publicationUri = uri; 254 + publicationUri = uri as string; 258 255 } 259 256 260 257 // Get PDS URL from credentials (already loaded earlier) ··· 262 259 263 260 // Generate config file 264 261 const configContent = generateConfigTemplate({ 265 - siteUrl: siteUrl, 266 - contentDir: contentDir || "./content", 267 - imagesDir: imagesDir || undefined, 268 - publicDir: publicDir || "./public", 269 - outputDir: outputDir || "./dist", 270 - pathPrefix: pathPrefix || "/posts", 262 + siteUrl: siteConfig.siteUrl, 263 + contentDir: siteConfig.contentDir || "./content", 264 + imagesDir: siteConfig.imagesDir || undefined, 265 + publicDir: siteConfig.publicDir || "./public", 266 + outputDir: siteConfig.outputDir || "./dist", 267 + pathPrefix: siteConfig.pathPrefix || "/posts", 271 268 publicationUri, 272 269 pdsUrl, 273 270 frontmatter: frontmatterMapping, ··· 279 276 log.success(`Configuration saved to ${configPath}`); 280 277 281 278 // Create .well-known/site.standard.publication file 282 - const resolvedPublicDir = path.isAbsolute(publicDir || "./public") 283 - ? publicDir || "./public" 284 - : path.join(process.cwd(), publicDir || "./public"); 279 + const publicDir = siteConfig.publicDir || "./public"; 280 + const resolvedPublicDir = path.isAbsolute(publicDir) 281 + ? publicDir 282 + : path.join(process.cwd(), publicDir); 285 283 const wellKnownDir = path.join(resolvedPublicDir, ".well-known"); 286 284 const wellKnownPath = path.join(wellKnownDir, "site.standard.publication"); 287 285