A CLI for publishing standard.site documents to ATProto
sequoia.pub
standard
site
lexicon
cli
publishing
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}