A CLI for publishing standard.site documents to ATProto sequoia.pub
standard site lexicon cli publishing
at v0.2.0 148 lines 3.6 kB view raw
1import * as fs from "fs/promises"; 2import * as path from "path"; 3import type { PublisherConfig, PublisherState, FrontmatterMapping, BlueskyConfig } 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 bluesky?: BlueskyConfig; 80}): string { 81 const config: Record<string, unknown> = { 82 siteUrl: options.siteUrl, 83 contentDir: options.contentDir, 84 }; 85 86 if (options.imagesDir) { 87 config.imagesDir = options.imagesDir; 88 } 89 90 if (options.publicDir && options.publicDir !== "./public") { 91 config.publicDir = options.publicDir; 92 } 93 94 if (options.outputDir) { 95 config.outputDir = options.outputDir; 96 } 97 98 if (options.pathPrefix && options.pathPrefix !== "/posts") { 99 config.pathPrefix = options.pathPrefix; 100 } 101 102 config.publicationUri = options.publicationUri; 103 104 if (options.pdsUrl && options.pdsUrl !== "https://bsky.social") { 105 config.pdsUrl = options.pdsUrl; 106 } 107 108 if (options.frontmatter && Object.keys(options.frontmatter).length > 0) { 109 config.frontmatter = options.frontmatter; 110 } 111 112 if (options.ignore && options.ignore.length > 0) { 113 config.ignore = options.ignore; 114 } 115 116 if (options.bluesky) { 117 config.bluesky = options.bluesky; 118 } 119 120 return JSON.stringify(config, null, 2); 121} 122 123export async function loadState(configDir: string): Promise<PublisherState> { 124 const statePath = path.join(configDir, STATE_FILENAME); 125 126 if (!(await fileExists(statePath))) { 127 return { posts: {} }; 128 } 129 130 try { 131 const content = await fs.readFile(statePath, "utf-8"); 132 return JSON.parse(content) as PublisherState; 133 } catch { 134 return { posts: {} }; 135 } 136} 137 138export async function saveState( 139 configDir: string, 140 state: PublisherState, 141): Promise<void> { 142 const statePath = path.join(configDir, STATE_FILENAME); 143 await fs.writeFile(statePath, JSON.stringify(state, null, 2)); 144} 145 146export function getStatePath(configDir: string): string { 147 return path.join(configDir, STATE_FILENAME); 148}