A CLI for publishing standard.site documents to ATProto
at main 180 lines 4.3 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 publishContent?: boolean; 89 bluesky?: BlueskyConfig; 90}): string { 91 const config: Record<string, unknown> = { 92 $schema: 93 "https://tangled.org/stevedylan.dev/sequoia/raw/main/sequoia.schema.json", 94 siteUrl: options.siteUrl, 95 contentDir: options.contentDir, 96 }; 97 98 if (options.imagesDir) { 99 config.imagesDir = options.imagesDir; 100 } 101 102 if (options.publicDir && options.publicDir !== "./public") { 103 config.publicDir = options.publicDir; 104 } 105 106 if (options.outputDir) { 107 config.outputDir = options.outputDir; 108 } 109 110 if (options.pathPrefix && options.pathPrefix !== "/posts") { 111 config.pathPrefix = options.pathPrefix; 112 } 113 114 config.publicationUri = options.publicationUri; 115 116 if (options.pdsUrl && options.pdsUrl !== "https://bsky.social") { 117 config.pdsUrl = options.pdsUrl; 118 } 119 120 if (options.frontmatter && Object.keys(options.frontmatter).length > 0) { 121 config.frontmatter = options.frontmatter; 122 } 123 124 if (options.ignore && options.ignore.length > 0) { 125 config.ignore = options.ignore; 126 } 127 128 if (options.removeIndexFromSlug) { 129 config.removeIndexFromSlug = options.removeIndexFromSlug; 130 } 131 132 if (options.stripDatePrefix) { 133 config.stripDatePrefix = options.stripDatePrefix; 134 } 135 136 if (options.pathTemplate) { 137 config.pathTemplate = options.pathTemplate; 138 } 139 140 if (options.textContentField) { 141 config.textContentField = options.textContentField; 142 } 143 144 if (options.publishContent) { 145 config.publishContent = options.publishContent; 146 } 147 148 if (options.bluesky) { 149 config.bluesky = options.bluesky; 150 } 151 152 return JSON.stringify(config, null, 2); 153} 154 155export async function loadState(configDir: string): Promise<PublisherState> { 156 const statePath = path.join(configDir, STATE_FILENAME); 157 158 if (!(await fileExists(statePath))) { 159 return { posts: {} }; 160 } 161 162 try { 163 const content = await fs.readFile(statePath, "utf-8"); 164 return JSON.parse(content) as PublisherState; 165 } catch { 166 return { posts: {} }; 167 } 168} 169 170export async function saveState( 171 configDir: string, 172 state: PublisherState, 173): Promise<void> { 174 const statePath = path.join(configDir, STATE_FILENAME); 175 await fs.writeFile(statePath, JSON.stringify(state, null, 2)); 176} 177 178export function getStatePath(configDir: string): string { 179 return path.join(configDir, STATE_FILENAME); 180}