packages/cli/src/lib/config.ts 4.2 K raw
1
import * as fs from "node:fs/promises";
2
import * as path from "node:path";
3
import type {
4
	PublisherConfig,
5
	PublisherState,
6
	FrontmatterMapping,
7
	BlueskyConfig,
8
} from "./types";
9
10
export const CONFIG_FILENAME = "sequoia.json";
11
const STATE_FILENAME = ".sequoia-state.json";
12
13
async function fileExists(filePath: string): Promise<boolean> {
14
	try {
15
		await fs.access(filePath);
16
		return true;
17
	} catch {
18
		return false;
19
	}
20
}
21
22
export 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
43
export 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
73
export 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
155
export 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
170
export 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
178
export function getStatePath(configDir: string): string {
179
	return path.join(configDir, STATE_FILENAME);
180
}