A CLI for publishing standard.site documents to ATProto
at v0.1.0 136 lines 3.4 kB view raw
1import * as path from "path"; 2import type { PublisherConfig, PublisherState, FrontmatterMapping } from "./types"; 3 4const CONFIG_FILENAME = "sequoia.json"; 5const STATE_FILENAME = ".sequoia-state.json"; 6 7export async function findConfig( 8 startDir: string = process.cwd(), 9): Promise<string | null> { 10 let currentDir = startDir; 11 12 while (true) { 13 const configPath = path.join(currentDir, CONFIG_FILENAME); 14 const file = Bun.file(configPath); 15 16 if (await file.exists()) { 17 return configPath; 18 } 19 20 const parentDir = path.dirname(currentDir); 21 if (parentDir === currentDir) { 22 // Reached root 23 return null; 24 } 25 currentDir = parentDir; 26 } 27} 28 29export async function loadConfig( 30 configPath?: string, 31): Promise<PublisherConfig> { 32 const resolvedPath = configPath || (await findConfig()); 33 34 if (!resolvedPath) { 35 throw new Error( 36 `Could not find ${CONFIG_FILENAME}. Run 'sequoia init' to create one.`, 37 ); 38 } 39 40 try { 41 const file = Bun.file(resolvedPath); 42 const content = await file.text(); 43 const config = JSON.parse(content) as PublisherConfig; 44 45 // Validate required fields 46 if (!config.siteUrl) throw new Error("siteUrl is required in config"); 47 if (!config.contentDir) throw new Error("contentDir is required in config"); 48 if (!config.publicationUri) 49 throw new Error("publicationUri is required in config"); 50 51 return config; 52 } catch (error) { 53 if (error instanceof Error && error.message.includes("required")) { 54 throw error; 55 } 56 throw new Error(`Failed to load config from ${resolvedPath}: ${error}`); 57 } 58} 59 60export function generateConfigTemplate(options: { 61 siteUrl: string; 62 contentDir: string; 63 imagesDir?: string; 64 publicDir?: string; 65 outputDir?: string; 66 pathPrefix?: string; 67 publicationUri: string; 68 pdsUrl?: string; 69 frontmatter?: FrontmatterMapping; 70 ignore?: string[]; 71}): string { 72 const config: Record<string, unknown> = { 73 siteUrl: options.siteUrl, 74 contentDir: options.contentDir, 75 }; 76 77 if (options.imagesDir) { 78 config.imagesDir = options.imagesDir; 79 } 80 81 if (options.publicDir && options.publicDir !== "./public") { 82 config.publicDir = options.publicDir; 83 } 84 85 if (options.outputDir) { 86 config.outputDir = options.outputDir; 87 } 88 89 if (options.pathPrefix && options.pathPrefix !== "/posts") { 90 config.pathPrefix = options.pathPrefix; 91 } 92 93 config.publicationUri = options.publicationUri; 94 95 if (options.pdsUrl && options.pdsUrl !== "https://bsky.social") { 96 config.pdsUrl = options.pdsUrl; 97 } 98 99 if (options.frontmatter && Object.keys(options.frontmatter).length > 0) { 100 config.frontmatter = options.frontmatter; 101 } 102 103 if (options.ignore && options.ignore.length > 0) { 104 config.ignore = options.ignore; 105 } 106 107 return JSON.stringify(config, null, 2); 108} 109 110export async function loadState(configDir: string): Promise<PublisherState> { 111 const statePath = path.join(configDir, STATE_FILENAME); 112 const file = Bun.file(statePath); 113 114 if (!(await file.exists())) { 115 return { posts: {} }; 116 } 117 118 try { 119 const content = await file.text(); 120 return JSON.parse(content) as PublisherState; 121 } catch { 122 return { posts: {} }; 123 } 124} 125 126export async function saveState( 127 configDir: string, 128 state: PublisherState, 129): Promise<void> { 130 const statePath = path.join(configDir, STATE_FILENAME); 131 await Bun.write(statePath, JSON.stringify(state, null, 2)); 132} 133 134export function getStatePath(configDir: string): string { 135 return path.join(configDir, STATE_FILENAME); 136}