packages/cli/src/lib/config.ts 4.7 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
/**
14
 * Default values for optional `PublisherConfig` fields that have documented defaults.
15
 * Must be kept in sync with the field comments on `PublisherConfig` in `types.ts`.
16
 */
17
export const DEFAULT_PUBLISHER_CONFIG = {
18
	publicDir: "./public",
19
	outputDir: "./dist",
20
	pathPrefix: "/posts",
21
	publishContent: true,
22
	removeIndexFromSlug: false,
23
	stripDatePrefix: false,
24
	autoSync: true,
25
} satisfies Partial<PublisherConfig>;
26
27
async function fileExists(filePath: string): Promise<boolean> {
28
	try {
29
		await fs.access(filePath);
30
		return true;
31
	} catch {
32
		return false;
33
	}
34
}
35
36
export async function findConfig(
37
	startDir: string = process.cwd(),
38
): Promise<string | null> {
39
	let currentDir = startDir;
40
41
	while (true) {
42
		const configPath = path.join(currentDir, CONFIG_FILENAME);
43
44
		if (await fileExists(configPath)) {
45
			return configPath;
46
		}
47
48
		const parentDir = path.dirname(currentDir);
49
		if (parentDir === currentDir) {
50
			// Reached root
51
			return null;
52
		}
53
		currentDir = parentDir;
54
	}
55
}
56
57
export async function loadConfig(
58
	configPath?: string,
59
): Promise<PublisherConfig> {
60
	const resolvedPath = configPath || (await findConfig());
61
62
	if (!resolvedPath) {
63
		throw new Error(
64
			`Could not find ${CONFIG_FILENAME}. Run 'sequoia init' to create one.`,
65
		);
66
	}
67
68
	try {
69
		const content = await fs.readFile(resolvedPath, "utf-8");
70
		const config = JSON.parse(content) as PublisherConfig;
71
72
		// Validate required fields
73
		if (!config.siteUrl) throw new Error("siteUrl is required in config");
74
		if (!config.contentDir) throw new Error("contentDir is required in config");
75
		if (!config.publicationUri)
76
			throw new Error("publicationUri is required in config");
77
78
		return { ...DEFAULT_PUBLISHER_CONFIG, ...config };
79
	} catch (error) {
80
		if (error instanceof Error && error.message.includes("required")) {
81
			throw error;
82
		}
83
		throw new Error(`Failed to load config from ${resolvedPath}: ${error}`);
84
	}
85
}
86
87
export function generateConfigTemplate(options: {
88
	siteUrl: string;
89
	contentDir: string;
90
	imagesDir?: string;
91
	publicDir?: string;
92
	outputDir?: string;
93
	pathPrefix?: string;
94
	publicationUri: string;
95
	pdsUrl?: string;
96
	frontmatter?: FrontmatterMapping;
97
	ignore?: string[];
98
	removeIndexFromSlug?: boolean;
99
	stripDatePrefix?: boolean;
100
	pathTemplate?: string;
101
	textContentField?: string;
102
	publishContent?: boolean;
103
	bluesky?: BlueskyConfig;
104
}): string {
105
	const config: Record<string, unknown> = {
106
		$schema:
107
			"https://tangled.org/stevedylan.dev/sequoia/raw/main/sequoia.schema.json",
108
		siteUrl: options.siteUrl,
109
		contentDir: options.contentDir,
110
	};
111
112
	if (options.imagesDir) {
113
		config.imagesDir = options.imagesDir;
114
	}
115
116
	if (options.publicDir && options.publicDir !== "./public") {
117
		config.publicDir = options.publicDir;
118
	}
119
120
	if (options.outputDir) {
121
		config.outputDir = options.outputDir;
122
	}
123
124
	if (options.pathPrefix && options.pathPrefix !== "/posts") {
125
		config.pathPrefix = options.pathPrefix;
126
	}
127
128
	config.publicationUri = options.publicationUri;
129
130
	if (options.pdsUrl && options.pdsUrl !== "https://bsky.social") {
131
		config.pdsUrl = options.pdsUrl;
132
	}
133
134
	if (options.frontmatter && Object.keys(options.frontmatter).length > 0) {
135
		config.frontmatter = options.frontmatter;
136
	}
137
138
	if (options.ignore && options.ignore.length > 0) {
139
		config.ignore = options.ignore;
140
	}
141
142
	if (options.removeIndexFromSlug) {
143
		config.removeIndexFromSlug = options.removeIndexFromSlug;
144
	}
145
146
	if (options.stripDatePrefix) {
147
		config.stripDatePrefix = options.stripDatePrefix;
148
	}
149
150
	if (options.pathTemplate) {
151
		config.pathTemplate = options.pathTemplate;
152
	}
153
154
	if (options.textContentField) {
155
		config.textContentField = options.textContentField;
156
	}
157
158
	if (options.publishContent !== undefined) {
159
		config.publishContent = options.publishContent;
160
	}
161
162
	if (options.bluesky) {
163
		config.bluesky = options.bluesky;
164
	}
165
166
	return JSON.stringify(config, null, 2);
167
}
168
169
export async function loadState(configDir: string): Promise<PublisherState> {
170
	const statePath = path.join(configDir, STATE_FILENAME);
171
172
	if (!(await fileExists(statePath))) {
173
		return { posts: {} };
174
	}
175
176
	try {
177
		const content = await fs.readFile(statePath, "utf-8");
178
		return JSON.parse(content) as PublisherState;
179
	} catch {
180
		return { posts: {} };
181
	}
182
}
183
184
export async function saveState(
185
	configDir: string,
186
	state: PublisherState,
187
): Promise<void> {
188
	const statePath = path.join(configDir, STATE_FILENAME);
189
	await fs.writeFile(statePath, JSON.stringify(state, null, 2));
190
}
191
192
export function getStatePath(configDir: string): string {
193
	return path.join(configDir, STATE_FILENAME);
194
}