A CLI for publishing standard.site documents to ATProto sequoia.pub
standard site lexicon cli publishing
59
fork

Configure Feed

Select the types of activity you want to include in your feed.

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});