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