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