packages/cli/src/commands/inject.ts 5.5 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
			// Check if link tag already exists
134
			const linkTag = `<link rel="site.standard.document" href="${atUri}">`;
135
			if (content.includes('rel="site.standard.document"')) {
136
				alreadyHasCount++;
137
				continue;
138
			}
139
140
			// Find </head> and inject before it
141
			const headCloseIndex = content.indexOf("</head>");
142
			if (headCloseIndex === -1) {
143
				log.warn(`  No </head> found in ${relativePath}, skipping`);
144
				skippedCount++;
145
				continue;
146
			}
147
148
			if (dryRun) {
149
				log.message(`  Would inject into: ${relativePath}`);
150
				log.message(`    ${linkTag}`);
151
				injectedCount++;
152
				continue;
153
			}
154
155
			// Inject the link tag
156
			const indent = "  "; // Standard indentation
157
			content =
158
				content.slice(0, headCloseIndex) +
159
				`${indent}${linkTag}\n${indent}` +
160
				content.slice(headCloseIndex);
161
162
			await fs.writeFile(htmlPath, content);
163
			log.success(`  Injected into: ${relativePath}`);
164
			injectedCount++;
165
		}
166
167
		// Summary
168
		log.message("\n---");
169
		if (dryRun) {
170
			log.info("Dry run complete. No changes made.");
171
		}
172
		log.info(`Injected: ${injectedCount}`);
173
		log.info(`Already has tag: ${alreadyHasCount}`);
174
		log.info(`Skipped (no match): ${skippedCount}`);
175
176
		if (skippedCount > 0 && !dryRun) {
177
			log.info(
178
				"\nTip: Skipped files had no matching published post. This is normal for non-post pages.",
179
			);
180
		}
181
	},
182
});