forked from
stevedylan.dev/sequoia
A CLI for publishing standard.site documents to ATProto
1import * as path from "path";
2import type { PublisherConfig, PublisherState, FrontmatterMapping } from "./types";
3
4const CONFIG_FILENAME = "sequoia.json";
5const STATE_FILENAME = ".sequoia-state.json";
6
7export async function findConfig(
8 startDir: string = process.cwd(),
9): Promise<string | null> {
10 let currentDir = startDir;
11
12 while (true) {
13 const configPath = path.join(currentDir, CONFIG_FILENAME);
14 const file = Bun.file(configPath);
15
16 if (await file.exists()) {
17 return configPath;
18 }
19
20 const parentDir = path.dirname(currentDir);
21 if (parentDir === currentDir) {
22 // Reached root
23 return null;
24 }
25 currentDir = parentDir;
26 }
27}
28
29export async function loadConfig(
30 configPath?: string,
31): Promise<PublisherConfig> {
32 const resolvedPath = configPath || (await findConfig());
33
34 if (!resolvedPath) {
35 throw new Error(
36 `Could not find ${CONFIG_FILENAME}. Run 'sequoia init' to create one.`,
37 );
38 }
39
40 try {
41 const file = Bun.file(resolvedPath);
42 const content = await file.text();
43 const config = JSON.parse(content) as PublisherConfig;
44
45 // Validate required fields
46 if (!config.siteUrl) throw new Error("siteUrl is required in config");
47 if (!config.contentDir) throw new Error("contentDir is required in config");
48 if (!config.publicationUri)
49 throw new Error("publicationUri is required in config");
50
51 return config;
52 } catch (error) {
53 if (error instanceof Error && error.message.includes("required")) {
54 throw error;
55 }
56 throw new Error(`Failed to load config from ${resolvedPath}: ${error}`);
57 }
58}
59
60export function generateConfigTemplate(options: {
61 siteUrl: string;
62 contentDir: string;
63 imagesDir?: string;
64 publicDir?: string;
65 outputDir?: string;
66 pathPrefix?: string;
67 publicationUri: string;
68 pdsUrl?: string;
69 frontmatter?: FrontmatterMapping;
70 ignore?: string[];
71}): string {
72 const config: Record<string, unknown> = {
73 siteUrl: options.siteUrl,
74 contentDir: options.contentDir,
75 };
76
77 if (options.imagesDir) {
78 config.imagesDir = options.imagesDir;
79 }
80
81 if (options.publicDir && options.publicDir !== "./public") {
82 config.publicDir = options.publicDir;
83 }
84
85 if (options.outputDir) {
86 config.outputDir = options.outputDir;
87 }
88
89 if (options.pathPrefix && options.pathPrefix !== "/posts") {
90 config.pathPrefix = options.pathPrefix;
91 }
92
93 config.publicationUri = options.publicationUri;
94
95 if (options.pdsUrl && options.pdsUrl !== "https://bsky.social") {
96 config.pdsUrl = options.pdsUrl;
97 }
98
99 if (options.frontmatter && Object.keys(options.frontmatter).length > 0) {
100 config.frontmatter = options.frontmatter;
101 }
102
103 if (options.ignore && options.ignore.length > 0) {
104 config.ignore = options.ignore;
105 }
106
107 return JSON.stringify(config, null, 2);
108}
109
110export async function loadState(configDir: string): Promise<PublisherState> {
111 const statePath = path.join(configDir, STATE_FILENAME);
112 const file = Bun.file(statePath);
113
114 if (!(await file.exists())) {
115 return { posts: {} };
116 }
117
118 try {
119 const content = await file.text();
120 return JSON.parse(content) as PublisherState;
121 } catch {
122 return { posts: {} };
123 }
124}
125
126export async function saveState(
127 configDir: string,
128 state: PublisherState,
129): Promise<void> {
130 const statePath = path.join(configDir, STATE_FILENAME);
131 await Bun.write(statePath, JSON.stringify(state, null, 2));
132}
133
134export function getStatePath(configDir: string): string {
135 return path.join(configDir, STATE_FILENAME);
136}