packages/cli/src/commands/inject.ts 6.6 K raw
1
import { log } from "@clack/prompts";
2
import { command, flag, option, optional, string } from "cmd-ts";
3
import { glob } from "glob";
4
import * as fs from "node:fs/promises";
5
import * as path from "node:path";
6
import { findConfig, loadConfig, loadState } from "../lib/config";
7
8
export const injectCommand = command({
9
	name: "inject",
10
	description: "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
		// Build a map of slug to atUri from state
47
		// The slug is stored in state by the publish command, using the configured slug options
48
		const slugToAtUri = new Map<string, string>();
49
		for (const [filePath, postState] of Object.entries(state.posts)) {
50
			if (postState.atUri && postState.slug) {
51
				// Use the slug stored in state (computed by publish with config options)
52
				slugToAtUri.set(postState.slug, postState.atUri);
53
54
				// Also add the last segment for simpler matching
55
				// e.g., "other/my-other-post" -> also map "my-other-post"
56
				const lastSegment = postState.slug.split("/").pop();
57
				if (lastSegment && lastSegment !== postState.slug) {
58
					slugToAtUri.set(lastSegment, postState.atUri);
59
				}
60
			} else if (postState.atUri) {
61
				// Fallback for older state files without slug field
62
				// Extract slug from file path (e.g., ./content/blog/my-post.md -> my-post)
63
				const basename = path.basename(filePath, path.extname(filePath));
64
				slugToAtUri.set(basename.toLowerCase(), postState.atUri);
65
			}
66
		}
67
68
		if (slugToAtUri.size === 0) {
69
			log.warn(
70
				"No published posts found in state. Run 'sequoia publish' first.",
71
			);
72
			return;
73
		}
74
75
		log.info(`Found ${slugToAtUri.size} slug mappings from published posts`);
76
77
		// Scan for HTML files
78
		const htmlFiles = await glob("**/*.html", {
79
			cwd: resolvedOutputDir,
80
			absolute: false,
81
		});
82
83
		if (htmlFiles.length === 0) {
84
			log.warn(`No HTML files found in ${resolvedOutputDir}`);
85
			return;
86
		}
87
88
		log.info(`Found ${htmlFiles.length} HTML files`);
89
90
		let injectedCount = 0;
91
		let skippedCount = 0;
92
		let alreadyHasCount = 0;
93
94
		for (const file of htmlFiles) {
95
			const htmlPath = path.join(resolvedOutputDir, file);
96
			// Try to match this HTML file to a published post
97
			const relativePath = file;
98
			const htmlDir = path.dirname(relativePath);
99
			const htmlBasename = path.basename(relativePath, ".html");
100
101
			// Try different matching strategies
102
			let atUri: string | undefined;
103
104
			// Strategy 1: Direct basename match (e.g., my-post.html -> my-post)
105
			atUri = slugToAtUri.get(htmlBasename);
106
107
			// Strategy 2: For index.html, try the directory path
108
			// e.g., posts/40th-puzzle-box/what-a-gift/index.html -> 40th-puzzle-box/what-a-gift
109
			if (!atUri && htmlBasename === "index" && htmlDir !== ".") {
110
				// Try full directory path (for nested subdirectories)
111
				atUri = slugToAtUri.get(htmlDir);
112
113
				// Also try just the last directory segment
114
				if (!atUri) {
115
					const lastDir = path.basename(htmlDir);
116
					atUri = slugToAtUri.get(lastDir);
117
				}
118
			}
119
120
			// Strategy 3: Full path match (e.g., blog/my-post.html -> blog/my-post)
121
			if (!atUri && htmlDir !== ".") {
122
				atUri = slugToAtUri.get(`${htmlDir}/${htmlBasename}`);
123
			}
124
125
			if (!atUri) {
126
				skippedCount++;
127
				continue;
128
			}
129
130
			// Read the HTML file
131
			let content = await fs.readFile(htmlPath, "utf-8");
132
133
			// Inject the tags
134
			let injected = injectLinkTags(
135
				dryRun,
136
				relativePath,
137
				content,
138
				atUri,
139
				config.publicationUri,
140
			);
141
			switch (injected) {
142
				case Injected.AlreadyPresent:
143
					alreadyHasCount++;
144
					continue;
145
				case Injected.Skipped:
146
					skippedCount++;
147
					continue;
148
				case Injected.Faked:
149
					injectedCount++;
150
					continue;
151
				default:
152
					content = injected;
153
			}
154
155
			await fs.writeFile(htmlPath, content);
156
			log.success(`  Injected into: ${relativePath}`);
157
			injectedCount++;
158
		}
159
160
		// Summary
161
		log.message("\n---");
162
		if (dryRun) {
163
			log.info("Dry run complete. No changes made.");
164
		}
165
		log.info(`Injected: ${injectedCount}`);
166
		log.info(`Already has tag: ${alreadyHasCount}`);
167
		log.info(`Skipped (no match): ${skippedCount}`);
168
169
		if (skippedCount > 0 && !dryRun) {
170
			log.info(
171
				"\nTip: Skipped files had no matching published post. This is normal for non-post pages.",
172
			);
173
		}
174
	},
175
});
176
177
export enum Injected {
178
	AlreadyPresent = 0,
179
	Skipped,
180
	Faked,
181
}
182
183
export function injectLinkTags(
184
	dryRun: boolean,
185
	relativePath: string,
186
	content: string,
187
	atUri: string,
188
	publicationUri: string,
189
): string | Injected {
190
	// Check if link tags already exist
191
	let documentLinkTag: string | undefined =
192
		`<link rel="site.standard.document" href="${atUri}">`;
193
	let publicationLinkTag: string | undefined =
194
		`<link rel="site.standard.publication" href="${publicationUri}">`;
195
	if (content.includes('rel="site.standard.document"')) {
196
		documentLinkTag = undefined;
197
	}
198
	if (content.includes('rel="site.standard.publication"')) {
199
		publicationLinkTag = undefined;
200
	}
201
202
	if (!documentLinkTag && !publicationLinkTag) {
203
		return Injected.AlreadyPresent;
204
	}
205
206
	// Find </head> and inject before it
207
	const headCloseIndex = content.indexOf("</head>");
208
	if (headCloseIndex === -1) {
209
		log.warn(`  No </head> found in ${relativePath}, skipping`);
210
		return Injected.Skipped;
211
	}
212
213
	if (dryRun) {
214
		log.message(`  Would inject into: ${relativePath}`);
215
		if (documentLinkTag) {
216
			log.message(`    ${documentLinkTag}`);
217
		}
218
		if (publicationLinkTag) {
219
			log.message(`    ${publicationLinkTag}`);
220
		}
221
		return Injected.Faked;
222
	}
223
224
	// Inject the link tags
225
	const indent = "  "; // Standard indentation
226
	const after = content.slice(headCloseIndex);
227
	content = content.slice(0, headCloseIndex);
228
	if (documentLinkTag) {
229
		content += `${indent}${documentLinkTag}\n${indent}`;
230
	}
231
	if (publicationLinkTag) {
232
		content += `${indent}${publicationLinkTag}\n${indent}`;
233
	}
234
	content += after;
235
236
	return content;
237
}