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