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