A CLI for publishing standard.site documents to ATProto
at main 172 lines 4.1 kB view raw
1import * as fs from "node:fs/promises"; 2import * as path from "node:path"; 3import type { 4 PublisherConfig, 5 PublisherState, 6 FrontmatterMapping, 7 BlueskyConfig, 8} from "./types"; 9 10const CONFIG_FILENAME = "sequoia.json"; 11const STATE_FILENAME = ".sequoia-state.json"; 12 13async function fileExists(filePath: string): Promise<boolean> { 14 try { 15 await fs.access(filePath); 16 return true; 17 } catch { 18 return false; 19 } 20} 21 22export async function findConfig( 23 startDir: string = process.cwd(), 24): Promise<string | null> { 25 let currentDir = startDir; 26 27 while (true) { 28 const configPath = path.join(currentDir, CONFIG_FILENAME); 29 30 if (await fileExists(configPath)) { 31 return configPath; 32 } 33 34 const parentDir = path.dirname(currentDir); 35 if (parentDir === currentDir) { 36 // Reached root 37 return null; 38 } 39 currentDir = parentDir; 40 } 41} 42 43export async function loadConfig( 44 configPath?: string, 45): Promise<PublisherConfig> { 46 const resolvedPath = configPath || (await findConfig()); 47 48 if (!resolvedPath) { 49 throw new Error( 50 `Could not find ${CONFIG_FILENAME}. Run 'sequoia init' to create one.`, 51 ); 52 } 53 54 try { 55 const content = await fs.readFile(resolvedPath, "utf-8"); 56 const config = JSON.parse(content) as PublisherConfig; 57 58 // Validate required fields 59 if (!config.siteUrl) throw new Error("siteUrl is required in config"); 60 if (!config.contentDir) throw new Error("contentDir is required in config"); 61 if (!config.publicationUri) 62 throw new Error("publicationUri is required in config"); 63 64 return config; 65 } catch (error) { 66 if (error instanceof Error && error.message.includes("required")) { 67 throw error; 68 } 69 throw new Error(`Failed to load config from ${resolvedPath}: ${error}`); 70 } 71} 72 73export function generateConfigTemplate(options: { 74 siteUrl: string; 75 contentDir: string; 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 { 90 const config: Record<string, unknown> = { 91 siteUrl: options.siteUrl, 92 contentDir: options.contentDir, 93 }; 94 95 if (options.imagesDir) { 96 config.imagesDir = options.imagesDir; 97 } 98 99 if (options.publicDir && options.publicDir !== "./public") { 100 config.publicDir = options.publicDir; 101 } 102 103 if (options.outputDir) { 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") { 114 config.pdsUrl = options.pdsUrl; 115 } 116 117 if (options.frontmatter && Object.keys(options.frontmatter).length > 0) { 118 config.frontmatter = options.frontmatter; 119 } 120 121 if (options.ignore && options.ignore.length > 0) { 122 config.ignore = options.ignore; 123 } 124 125 if (options.removeIndexFromSlug) { 126 config.removeIndexFromSlug = options.removeIndexFromSlug; 127 } 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) { 138 config.textContentField = options.textContentField; 139 } 140 if (options.bluesky) { 141 config.bluesky = options.bluesky; 142 } 143 144 return JSON.stringify(config, null, 2); 145} 146 147export async function loadState(configDir: string): Promise<PublisherState> { 148 const statePath = path.join(configDir, STATE_FILENAME); 149 150 if (!(await fileExists(statePath))) { 151 return { posts: {} }; 152 } 153 154 try { 155 const content = await fs.readFile(statePath, "utf-8"); 156 return JSON.parse(content) as PublisherState; 157 } catch { 158 return { posts: {} }; 159 } 160} 161 162export async function saveState( 163 configDir: string, 164 state: PublisherState, 165): Promise<void> { 166 const statePath = path.join(configDir, STATE_FILENAME); 167 await fs.writeFile(statePath, JSON.stringify(state, null, 2)); 168} 169 170export function getStatePath(configDir: string): string { 171 return path.join(configDir, STATE_FILENAME); 172}