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