A CLI for publishing standard.site documents to ATProto
sequoia.pub
standard
site
lexicon
cli
publishing
1import { log } from "@clack/prompts";
2import { command, flag, option, optional, string } from "cmd-ts";
3import { glob } from "glob";
4import * as fs from "node:fs/promises";
5import * as path from "node:path";
6import { findConfig, loadConfig, loadState } from "../lib/config";
7
8export 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});