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