packages/cli/src/commands/inject.ts 6.1 K raw
1
import { command, flag, option, optional, string } from "cmd-ts";
2
import { log } from "@clack/prompts";
3
import * as path from "path";
4
import { Glob } from "bun";
5
import { loadConfig, loadState, findConfig } from "../lib/config";
6
7
export const injectCommand = command({
8
	name: "inject",
9
	description:
10
		"Inject site.standard.document link tags into built HTML files",
11
	args: {
12
		outputDir: option({
13
			long: "output",
14
			short: "o",
15
			description: "Output directory to scan for HTML files",
16
			type: optional(string),
17
		}),
18
		dryRun: flag({
19
			long: "dry-run",
20
			short: "n",
21
			description: "Preview what would be injected without making changes",
22
		}),
23
	},
24
	handler: async ({ outputDir: outputDirArg, dryRun }) => {
25
		// Load config
26
		const configPath = await findConfig();
27
		if (!configPath) {
28
			log.error("No sequoia.json found. Run 'sequoia init' first.");
29
			process.exit(1);
30
		}
31
32
		const config = await loadConfig(configPath);
33
		const configDir = path.dirname(configPath);
34
35
		// Determine output directory
36
		const outputDir = outputDirArg || config.outputDir || "./dist";
37
		const resolvedOutputDir = path.isAbsolute(outputDir)
38
			? outputDir
39
			: path.join(configDir, outputDir);
40
41
		log.info(`Scanning for HTML files in: ${resolvedOutputDir}`);
42
43
		// Load state to get atUri mappings
44
		const state = await loadState(configDir);
45
46
		// Generic filenames where the slug is the parent directory, not the filename
47
		// Covers: SvelteKit (+page), Astro/Hugo (index), Next.js (page), etc.
48
		const genericFilenames = new Set([
49
			"+page",
50
			"index",
51
			"_index",
52
			"page",
53
			"readme",
54
		]);
55
56
		// Build a map of slug/path to atUri from state
57
		const pathToAtUri = new Map<string, string>();
58
		for (const [filePath, postState] of Object.entries(state.posts)) {
59
			if (postState.atUri) {
60
				// Extract slug from file path (e.g., ./content/blog/my-post.md -> my-post)
61
				let basename = path.basename(filePath, path.extname(filePath));
62
63
				// If the filename is a generic convention name, use the parent directory as slug
64
				if (genericFilenames.has(basename.toLowerCase())) {
65
					// Split path and filter out route groups like (blog-article)
66
					const pathParts = filePath
67
						.split(/[/\\]/)
68
						.filter((p) => p && !(p.startsWith("(") && p.endsWith(")")));
69
					// The slug should be the second-to-last part (last is the filename)
70
					if (pathParts.length >= 2) {
71
						const slug = pathParts[pathParts.length - 2];
72
						if (slug && slug !== "." && slug !== "content" && slug !== "routes" && slug !== "src") {
73
							basename = slug;
74
						}
75
					}
76
				}
77
78
				pathToAtUri.set(basename, postState.atUri);
79
80
				// Also add variations that might match HTML file paths
81
				// e.g., /blog/my-post, /posts/my-post, my-post/index
82
				const dirName = path.basename(path.dirname(filePath));
83
				// Skip route groups and common directory names
84
				if (dirName !== "." && dirName !== "content" && !(dirName.startsWith("(") && dirName.endsWith(")"))) {
85
					pathToAtUri.set(`${dirName}/${basename}`, postState.atUri);
86
				}
87
			}
88
		}
89
90
		if (pathToAtUri.size === 0) {
91
			log.warn(
92
				"No published posts found in state. Run 'sequoia publish' first.",
93
			);
94
			return;
95
		}
96
97
		log.info(`Found ${pathToAtUri.size} published posts in state`);
98
99
		// Scan for HTML files
100
		const glob = new Glob("**/*.html");
101
		const htmlFiles: string[] = [];
102
103
		for await (const file of glob.scan(resolvedOutputDir)) {
104
			htmlFiles.push(path.join(resolvedOutputDir, file));
105
		}
106
107
		if (htmlFiles.length === 0) {
108
			log.warn(`No HTML files found in ${resolvedOutputDir}`);
109
			return;
110
		}
111
112
		log.info(`Found ${htmlFiles.length} HTML files`);
113
114
		let injectedCount = 0;
115
		let skippedCount = 0;
116
		let alreadyHasCount = 0;
117
118
		for (const htmlPath of htmlFiles) {
119
			// Try to match this HTML file to a published post
120
			const relativePath = path.relative(resolvedOutputDir, htmlPath);
121
			const htmlDir = path.dirname(relativePath);
122
			const htmlBasename = path.basename(relativePath, ".html");
123
124
			// Try different matching strategies
125
			let atUri: string | undefined;
126
127
			// Strategy 1: Direct basename match (e.g., my-post.html -> my-post)
128
			atUri = pathToAtUri.get(htmlBasename);
129
130
			// Strategy 2: Directory name for index.html (e.g., my-post/index.html -> my-post)
131
			if (!atUri && htmlBasename === "index" && htmlDir !== ".") {
132
				const slug = path.basename(htmlDir);
133
				atUri = pathToAtUri.get(slug);
134
135
				// Also try parent/slug pattern
136
				if (!atUri) {
137
					const parentDir = path.dirname(htmlDir);
138
					if (parentDir !== ".") {
139
						atUri = pathToAtUri.get(`${path.basename(parentDir)}/${slug}`);
140
					}
141
				}
142
			}
143
144
			// Strategy 3: Full path match (e.g., blog/my-post.html -> blog/my-post)
145
			if (!atUri && htmlDir !== ".") {
146
				atUri = pathToAtUri.get(`${htmlDir}/${htmlBasename}`);
147
			}
148
149
			if (!atUri) {
150
				skippedCount++;
151
				continue;
152
			}
153
154
			// Read the HTML file
155
			const file = Bun.file(htmlPath);
156
			let content = await file.text();
157
158
			// Check if link tag already exists
159
			const linkTag = `<link rel="site.standard.document" href="${atUri}">`;
160
			if (content.includes('rel="site.standard.document"')) {
161
				alreadyHasCount++;
162
				continue;
163
			}
164
165
			// Find </head> and inject before it
166
			const headCloseIndex = content.indexOf("</head>");
167
			if (headCloseIndex === -1) {
168
				log.warn(`  No </head> found in ${relativePath}, skipping`);
169
				skippedCount++;
170
				continue;
171
			}
172
173
			if (dryRun) {
174
				log.message(`  Would inject into: ${relativePath}`);
175
				log.message(`    ${linkTag}`);
176
				injectedCount++;
177
				continue;
178
			}
179
180
			// Inject the link tag
181
			const indent = "  "; // Standard indentation
182
			content =
183
				content.slice(0, headCloseIndex) +
184
				`${indent}${linkTag}\n${indent}` +
185
				content.slice(headCloseIndex);
186
187
			await Bun.write(htmlPath, content);
188
			log.success(`  Injected into: ${relativePath}`);
189
			injectedCount++;
190
		}
191
192
		// Summary
193
		log.message("\n---");
194
		if (dryRun) {
195
			log.info("Dry run complete. No changes made.");
196
		}
197
		log.info(`Injected: ${injectedCount}`);
198
		log.info(`Already has tag: ${alreadyHasCount}`);
199
		log.info(`Skipped (no match): ${skippedCount}`);
200
201
		if (skippedCount > 0 && !dryRun) {
202
			log.info(
203
				"\nTip: Skipped files had no matching published post. This is normal for non-post pages.",
204
			);
205
		}
206
	},
207
});