A CLI for publishing standard.site documents to ATProto sequoia.pub
standard site lexicon cli publishing
at main 182 lines 5.6 kB view raw
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});