From 2e905212d4ac1ca4730830be9a2f15af74781b2f Mon Sep 17 00:00:00 2001 From: Utkarsh Sengar Date: Sat, 23 May 2026 15:55:32 -0700 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20htmlbin=20serve=20=E2=80=94=20local?= =?UTF-8?q?=20preview=20before=20publish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a local server so HTML and Markdown can be iterated on locally and then shipped via the same `publish` flow. Same file in, same artifact out — the local view is a tight feedback loop, not a separate format. Commands: htmlbin serve start (file or directory; auto-reloads on save) htmlbin serve --status inspect the running server in this cwd htmlbin serve --stop shut it down Notable choices: - File or directory in one command. .html served raw; .md rendered via marked with the same prose tokens as htmlbin.dev. Directory mode shows an index sorted by mtime. - Injected chrome mirrors the production drop viewer-bar: full-width, Geist sans for the title, mono meta column, and a red [local] chip as the "this isn't a published drop" signal. Position: fixed so it escapes any max-width on the user's body. - Live reload over SSE, no client deps. fs.watch with 50ms debounce and a dotfile/node_modules filter (so the state file at .htmlbin/serve.json doesn't fire phantom reloads). - Agent-first: one JSON event per line on stdout (started, reload, client-connected, idle-shutdown, stopped) when --output json. Browser auto-open and idle timeout flip to agent-friendly defaults when CLAUDE_CODE / CURSOR_AGENT / etc. is set. State file is the cross-turn discovery primitive. - Duplicate-start guard with PID liveness check; clean SIGINT/SIGTERM shutdown; ephemeral port with explicit --port override. New dep: marked (Markdown rendering). Co-Authored-By: Claude Opus 4.7 (1M context) --- package-lock.json | 13 +++ package.json | 1 + src/bin.ts | 85 ++++++++++++++ src/errors.ts | 7 ++ src/serve/index.ts | 259 ++++++++++++++++++++++++++++++++++++++++++ src/serve/inject.ts | 160 ++++++++++++++++++++++++++ src/serve/render.ts | 218 +++++++++++++++++++++++++++++++++++ src/serve/server.ts | 132 +++++++++++++++++++++ src/serve/state.ts | 47 ++++++++ src/serve/template.ts | 211 ++++++++++++++++++++++++++++++++++ src/serve/types.ts | 32 ++++++ src/serve/watcher.ts | 61 ++++++++++ 12 files changed, 1226 insertions(+) create mode 100644 src/serve/index.ts create mode 100644 src/serve/inject.ts create mode 100644 src/serve/render.ts create mode 100644 src/serve/server.ts create mode 100644 src/serve/state.ts create mode 100644 src/serve/template.ts create mode 100644 src/serve/types.ts create mode 100644 src/serve/watcher.ts diff --git a/package-lock.json b/package-lock.json index 1f527d6..b4f6a4c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@octokit/rest": "^21.0.2", "commander": "^12.1.0", "execa": "^9.5.2", + "marked": "^18.0.4", "smol-toml": "^1.3.1", "undici": "^7.2.0" }, @@ -1673,6 +1674,18 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/marked": { + "version": "18.0.4", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.4.tgz", + "integrity": "sha512-c/BTaKzg0G6ezQx97DAkYU7k0HM6ys0FqYeKBL6hlBByZwy+ycA1+f0vDdjMHKKeEjdgkx0GOv9Il6D+85cOqA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/mlly": { "version": "1.8.2", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", diff --git a/package.json b/package.json index cc054ef..04a8d05 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "@octokit/rest": "^21.0.2", "commander": "^12.1.0", "execa": "^9.5.2", + "marked": "^18.0.4", "smol-toml": "^1.3.1", "undici": "^7.2.0" }, diff --git a/src/bin.ts b/src/bin.ts index 917ae4f..9db8d45 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -32,6 +32,7 @@ import { listPatterns } from "./patterns/list.js"; import { initPatterns } from "./patterns/init.js"; import { ensureNoSilentSkip, installPattern } from "./patterns/install.js"; import { resolveSource } from "./patterns/sources.js"; +import { startServe, statusServe, stopServe } from "./serve/index.js"; const VERSION = "0.1.0"; @@ -383,9 +384,93 @@ async function run(): Promise { // --- patterns (sub-subcommands: list, init, add) --- registerPatternsCommands(program); + // --- serve --- + registerServeCommand(program); + await program.parseAsync(process.argv); } +// --------------------------------------------------------------- +// `htmlbin serve` — local preview server for HTML and Markdown files. +// Three modes on one command: +// serve start (runs in foreground until SIGINT / idle) +// serve --status print state of any running server in this cwd +// serve --stop stop the running server in this cwd +// Agent-first: --output json emits structured events per line; in agent +// contexts the browser doesn't auto-open and idle timeout drops to 5 min. +// --------------------------------------------------------------- + +interface ServeCmdOpts extends GlobalOpts { + port?: string; + host?: string; + open?: boolean; + reload?: boolean; + overlay?: boolean; + idleTimeout?: string; + status?: boolean; + stop?: boolean; +} + +function registerServeCommand(program: Command): void { + program + .command("serve") + .description("Run a local preview server for an HTML or Markdown file (or a directory)") + .argument("[path]", "file or directory to serve (omit when using --status or --stop)") + .option("--port ", "port to listen on (default: ephemeral)") + .option("--host ", "host to bind", "127.0.0.1") + .option("--no-open", "do not open the URL in a browser (default in agent contexts)") + .option("--no-reload", "disable live-reload on file change") + .option("--no-overlay", "disable the floating status pill on raw HTML pages") + .option("--idle-timeout ", "auto-shutdown after N idle minutes; 0 disables") + .option("--status", "print the state of the running server in this directory") + .option("--stop", "stop the running server in this directory") + .action(async (path: string | undefined, cmdOpts: ServeCmdOpts) => { + try { + const json = OUTPUT_MODE === "json"; + if (cmdOpts.status) { + await statusServe(json); + return; + } + if (cmdOpts.stop) { + await stopServe(json); + return; + } + if (!path) { + throw new CliError( + "invalid_arg", + "Missing path. Pass a file or directory, or use --status / --stop." + ); + } + const startArgs: Parameters[0] = { + path, + host: cmdOpts.host, + open: cmdOpts.open, + reload: cmdOpts.reload, + overlay: cmdOpts.overlay, + json, + agentContext: isAgentContext(), + }; + if (cmdOpts.port !== undefined) { + const n = Number.parseInt(cmdOpts.port, 10); + if (!Number.isFinite(n) || n < 0 || n > 65535) { + throw new CliError("invalid_arg", `--port must be 0..65535 (got: ${cmdOpts.port})`); + } + startArgs.port = n; + } + if (cmdOpts.idleTimeout !== undefined) { + const n = Number.parseInt(cmdOpts.idleTimeout, 10); + if (!Number.isFinite(n) || n < 0) { + throw new CliError("invalid_arg", `--idle-timeout must be >= 0 (got: ${cmdOpts.idleTimeout})`); + } + startArgs.idleTimeout = n; + } + await startServe(startArgs); + } catch (e) { + die(e); + } + }); +} + // --------------------------------------------------------------- // `htmlbin patterns` — install + manage local patterns the skill at // /.well-known/agent-skills/htmlbin/SKILL.md teaches agents to look for. diff --git a/src/errors.ts b/src/errors.ts index 68ec37a..8cba4ad 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -50,6 +50,9 @@ export type CliErrorCode = | "cloudflare_access_not_enabled" | "pr_required" | "network_error" + | "serve_already_running" + | "serve_not_running" + | "serve_port_in_use" | "unknown"; export class CliError extends Error { @@ -119,7 +122,11 @@ export function exitCodeFor(code: CliErrorCode): number { return 7; case "network_error": case "server_misconfigured": + case "serve_port_in_use": return 8; + case "serve_already_running": + case "serve_not_running": + return 7; default: return 1; } diff --git a/src/serve/index.ts b/src/serve/index.ts new file mode 100644 index 0000000..7aa61af --- /dev/null +++ b/src/serve/index.ts @@ -0,0 +1,259 @@ +// Orchestration for the three `htmlbin serve` modes: +// start → bind, watch, write state, run until SIGINT / idle timeout +// status → read state file (validate liveness), print +// stop → read state file, kill pid, clear state +// +// Agent-first defaults: +// - When agent context is detected, `open` flips off (no browser to open) +// and `idleTimeout` defaults to 5 min instead of 30. +// - With --output json, every lifecycle event is one JSON object per +// line on stdout: started, reload, client-connected, client- +// disconnected, idle-shutdown, stopped. + +import { spawn } from "node:child_process"; +import { stat } from "node:fs/promises"; +import { extname, resolve } from "node:path"; +import { CliError } from "../errors.js"; +import { + clearState, + isProcessAlive, + readState, + writeState, +} from "./state.js"; +import { startServer, type ServerHandle } from "./server.js"; +import { watchTarget } from "./watcher.js"; +import type { ServeMode, ServeOptions, ServeState } from "./types.js"; + +const RENDERABLE_EXTS = new Set([".html", ".htm", ".md"]); + +export interface StartArgs { + path: string; + host?: string; + port?: number; + open?: boolean; + reload?: boolean; + overlay?: boolean; + idleTimeout?: number; + json: boolean; + /** True when an agent env var was detected at process start. */ + agentContext: boolean; +} + +export async function startServe(args: StartArgs): Promise { + const target = resolve(process.cwd(), args.path); + const st = await statSafe(target); + if (!st) { + throw new CliError("file_not_found", `Path not found: ${args.path}`); + } + const mode: ServeMode = st.isDirectory() ? "dir" : "file"; + if (mode === "file" && !RENDERABLE_EXTS.has(extname(target).toLowerCase())) { + throw new CliError( + "invalid_arg", + `Unsupported file type: ${extname(target) || "(none)"}`, + { hint: "Pass an .html, .htm, or .md file — or a directory." } + ); + } + + // Refuse if a server is already running for this cwd. Stale state file + // (pid no longer alive) is fine — we'll overwrite it. + const existing = await readState(); + if (existing && isProcessAlive(existing.pid)) { + throw new CliError( + "serve_already_running", + `A serve process is already running (pid ${existing.pid}) for ${existing.serving}`, + { + hint: "Run `htmlbin serve --stop` first, or use a different working directory.", + details: { url: existing.url, pid: existing.pid }, + } + ); + } + + const opts: ServeOptions = { + target, + mode, + host: args.host ?? "127.0.0.1", + port: args.port ?? 0, + open: args.open ?? !args.agentContext, + reload: args.reload ?? true, + overlay: args.overlay ?? true, + idleTimeout: args.idleTimeout ?? (args.agentContext ? 5 : 30), + json: args.json, + }; + + let handle: ServerHandle; + try { + handle = await startServer(opts, (count) => { + emit(args.json, count >= 1 ? "client-connected" : "client-disconnected", { + clients: count, + }); + }); + } catch (e) { + if ((e as { code?: string }).code === "EADDRINUSE") { + throw new CliError( + "serve_port_in_use", + `Port ${args.port} is already in use.`, + { hint: "Omit --port to pick an ephemeral one." } + ); + } + throw e; + } + + const state: ServeState = { + pid: process.pid, + url: handle.url, + port: handle.port, + serving: target, + mode, + started_at: new Date().toISOString(), + }; + await writeState(state); + + emit(args.json, "started", { + url: handle.url, + port: handle.port, + serving: target, + mode, + pid: process.pid, + }); + + const watcher = await watchTarget(target, (changedPath) => { + handle.broadcastReload(changedPath); + emit(args.json, "reload", { path: changedPath, at: new Date().toISOString() }); + }); + + // Idle shutdown: poll every 30s; exit when no clients have been + // connected for `idleTimeout` minutes. 0 disables. + let idleTimer: NodeJS.Timeout | null = null; + if (opts.idleTimeout > 0) { + const idleMs = opts.idleTimeout * 60_000; + idleTimer = setInterval(() => { + if (handle.clientCount() === 0 && Date.now() - handle.lastActivity() > idleMs) { + emit(args.json, "idle-shutdown", { idle_minutes: opts.idleTimeout }); + shutdown(0).catch(() => process.exit(0)); + } + }, 30_000); + idleTimer.unref?.(); + } + + if (opts.open) openInBrowser(handle.url); + + const shutdown = async (exitCode: number): Promise => { + if (idleTimer) clearInterval(idleTimer); + watcher.close(); + await handle.close(); + await clearState(); + emit(args.json, "stopped", { code: exitCode }); + process.exit(exitCode); + }; + + process.on("SIGINT", () => { void shutdown(0); }); + process.on("SIGTERM", () => { void shutdown(0); }); +} + +export async function statusServe(json: boolean): Promise { + const state = await readState(); + if (!state) { + emit(json, "status", { running: false }); + return; + } + const alive = isProcessAlive(state.pid); + if (!alive) { + await clearState(); + emit(json, "status", { running: false, note: "stale state file removed" }); + return; + } + emit(json, "status", { + running: true, + url: state.url, + port: state.port, + serving: state.serving, + mode: state.mode, + pid: state.pid, + started_at: state.started_at, + }); +} + +export async function stopServe(json: boolean): Promise { + const state = await readState(); + if (!state) { + throw new CliError("serve_not_running", "No serve process is recorded for this directory."); + } + if (!isProcessAlive(state.pid)) { + await clearState(); + throw new CliError("serve_not_running", `Recorded pid ${state.pid} is not alive (stale state removed).`); + } + try { + process.kill(state.pid, "SIGTERM"); + } catch (e) { + throw new CliError("serve_not_running", `Could not signal pid ${state.pid}: ${(e as Error).message}`); + } + // Wait briefly for the target process to exit and clear its own state. + // If it doesn't (e.g. wedged), clear the state file ourselves. + for (let i = 0; i < 20; i++) { + await sleep(50); + if (!isProcessAlive(state.pid)) break; + } + await clearState(); + emit(json, "stopped", { pid: state.pid }); +} + +async function statSafe(p: string): Promise<{ isDirectory: () => boolean } | null> { + try { + return await stat(p); + } catch { + return null; + } +} + +function openInBrowser(url: string): void { + const cmd = + process.platform === "darwin" ? "open" : + process.platform === "win32" ? "cmd" : + "xdg-open"; + const args = process.platform === "win32" ? ["/c", "start", "", url] : [url]; + try { + spawn(cmd, args, { stdio: "ignore", detached: true }).unref(); + } catch { + // Swallow — opening the browser is a convenience, not load-bearing. + } +} + +function emit(json: boolean, event: string, fields: Record): void { + if (json) { + process.stdout.write(JSON.stringify({ event, ...fields }) + "\n"); + return; + } + process.stdout.write(humanFormat(event, fields) + "\n"); +} + +function humanFormat(event: string, f: Record): string { + switch (event) { + case "started": + return `serving ${f.serving} at ${f.url}\n(press Ctrl+C to stop; pid ${f.pid})`; + case "reload": + return `reload: ${f.path}`; + case "client-connected": + return `client connected (${f.clients} total)`; + case "client-disconnected": + return `client disconnected (${f.clients} remaining)`; + case "idle-shutdown": + return `idle for ${f.idle_minutes}m — shutting down`; + case "stopped": + return f.pid !== undefined ? `stopped pid ${f.pid}` : `stopped`; + case "status": + if (!f.running) return "no serve process running"; + return [ + `running pid ${f.pid}`, + `url: ${f.url}`, + `serving: ${f.serving}`, + `mode: ${f.mode}`, + `since: ${f.started_at}`, + ].join("\n"); + default: + return `${event} ${JSON.stringify(f)}`; + } +} + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} diff --git a/src/serve/inject.ts b/src/serve/inject.ts new file mode 100644 index 0000000..6ce9c5b --- /dev/null +++ b/src/serve/inject.ts @@ -0,0 +1,160 @@ +// Injected into raw .html files. Mirrors htmlbin.dev's .viewer-bar — the +// strip you see on every drop (htmlbin3/src/views/viewer.ts). Full-width, +// Geist sans for the title, mono for the meta column on the right. +// The `[local]` chip is the unambiguous "this isn't a published drop" +// signal, structured like the production .vchip version pill. + +interface InjectOpts { + reload: boolean; + /** When false, the bar is skipped entirely. SSE reload still works. */ + overlay: boolean; + /** Basename or relative path of the served file. */ + path: string; + /** "localhost:62821". */ + localAddr: string; +} + +// Scoped under .hb-bar. The `all: initial / unset` walls reset user styles +// so a page with aggressive global resets (e.g. * { margin: 0 } or a CSS +// framework) can't break our chrome. +const BAR_CSS = ` +.hb-bar, .hb-bar * { all: unset; box-sizing: border-box; } +.hb-bar { + display: flex; align-items: center; gap: 14px; + padding: 10px 18px; + border-bottom: 1px solid #E5E5E5; + background: #FAFAFA; + font-family: "Geist", -apple-system, "Inter", system-ui, sans-serif; + font-size: 13px; line-height: 1.4; + color: #0A0A0A; + /* Fixed (not sticky): user body may have max-width/centering that + would constrain the bar. Fixed escapes that flow entirely. */ + position: fixed; top: 0; left: 0; right: 0; width: 100%; + z-index: 2147483647; + flex-wrap: wrap; + -webkit-font-smoothing: antialiased; +} +.hb-bar a { cursor: pointer; } +.hb-bar .hb-wordmark { + color: #0A0A0A; + text-decoration: underline; text-decoration-color: #E5E5E5; + text-underline-offset: 3px; text-decoration-thickness: 1px; +} +.hb-bar .hb-wordmark:hover { + color: #D93025; text-decoration-color: #D93025; +} +.hb-bar .hb-sep { + color: #A3A3A3; + font-family: "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace; +} +.hb-bar .hb-title { + font-weight: 600; font-size: 14px; color: #0A0A0A; + flex: 0 1 auto; min-width: 0; max-width: 36vw; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + display: inline-block; +} +.hb-bar .hb-right { + margin-left: auto; flex: 0 0 auto; + display: inline-flex; align-items: center; gap: 14px; + font-family: "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace; + font-size: 12px; color: #737373; +} +.hb-bar .hb-chip { + display: inline-flex; align-items: center; + background: #FAFAFA; border: 1px solid #E5E5E5; + border-radius: 4px; padding: 3px 8px; + font: 500 11.5px/1 "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace; + color: #171717; + letter-spacing: 0.02em; +} +.hb-bar .hb-chip.hb-local-chip { + color: #D93025; border-color: #F4C7C3; background: #FCE8E6; +} +.hb-bar .hb-addr { color: #737373; } +.hb-bar .hb-status { + display: inline-flex; align-items: center; gap: 6px; + color: #737373; +} +.hb-bar .hb-dot { + width: 7px; height: 7px; border-radius: 50%; + background: #A3A3A3; display: inline-block; +} +.hb-bar[data-state="connected"] .hb-dot { background: #1F8F4A; } +.hb-bar[data-state="reconnecting"] .hb-dot { background: #B45309; } +.hb-bar .hb-close { + color: #A3A3A3; cursor: pointer; padding: 2px 4px; + font-size: 13px; line-height: 1; + display: inline-block; +} +.hb-bar .hb-close:hover { color: #D93025; } +@media (max-width: 720px) { + .hb-bar { gap: 10px; padding: 10px 14px; font-size: 12.5px; } + .hb-bar .hb-title { max-width: none; flex: 1 1 0; min-width: 0; font-size: 13.5px; } + .hb-bar .hb-right { width: 100%; margin-left: 0; gap: 12px; font-size: 11.5px; flex-wrap: wrap; } +} +@media (prefers-color-scheme: dark) { + .hb-bar { background: #16161A; color: #FAFAFA; border-bottom-color: #2A2A30; } + .hb-bar .hb-wordmark, .hb-bar .hb-title { color: #FAFAFA; } + .hb-bar .hb-chip { background: #1F1F25; border-color: #2A2A30; color: #FAFAFA; } +} +`.replace(/\s+/g, " ").trim(); + +export function buildClientScript(opts: InjectOpts): string { + if (!opts.reload && !opts.overlay) return ""; + + const showBar = opts.overlay ? "true" : "false"; + const enableReload = opts.reload ? "true" : "false"; + const pathLit = JSON.stringify(opts.path); + const addrLit = JSON.stringify(opts.localAddr); + + return ` +`; +} + +// Insert just before . Fallback: append at end when no body tag. +export function injectIntoHtml(html: string, script: string): string { + if (!script) return html; + const idx = html.toLowerCase().lastIndexOf(""); + if (idx === -1) return html + script; + return html.slice(0, idx) + script + html.slice(idx); +} diff --git a/src/serve/render.ts b/src/serve/render.ts new file mode 100644 index 0000000..b46a0e4 --- /dev/null +++ b/src/serve/render.ts @@ -0,0 +1,218 @@ +// Path → HTTP response. Three branches: +// `.html` → serve raw, inject SSE/pill script before +// `.md` → render via marked, wrap in our chrome template +// other → mime-type lookup and serve raw (only relevant in dir mode) +// +// Directory mode also renders an index page at "/" listing all .html/.md +// files under the root (recursive), sorted by mtime desc. + +import { readFile, readdir, stat } from "node:fs/promises"; +import { extname, join, relative, resolve, sep } from "node:path"; +import { marked } from "marked"; +import { buildClientScript, injectIntoHtml } from "./inject.js"; +import { renderPage, escapeHtml } from "./template.js"; +import type { ServeOptions } from "./types.js"; + +export interface RenderResult { + status: number; + headers: Record; + body: Buffer | string; +} + +const MIME: Record = { + ".html": "text/html; charset=utf-8", + ".htm": "text/html; charset=utf-8", + ".md": "text/html; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".js": "application/javascript; charset=utf-8", + ".mjs": "application/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".svg": "image/svg+xml", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".ico": "image/x-icon", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".ttf": "font/ttf", + ".otf": "font/otf", + ".txt": "text/plain; charset=utf-8", + ".pdf": "application/pdf", +}; + +// Refuse anything that resolves outside the served root — this is the only +// directory-traversal guard we need since we always resolve relative to +// `opts.target` before reading. +export function resolveSafe(rootAbs: string, relPath: string): string | null { + const decoded = decodeURIComponent(relPath.replace(/^\/+/, "")); + const abs = resolve(rootAbs, decoded); + if (abs !== rootAbs && !abs.startsWith(rootAbs + sep)) return null; + return abs; +} + +export async function renderForRequest( + urlPath: string, + opts: ServeOptions +): Promise { + if (opts.mode === "file") return renderSingleFile(urlPath, opts); + return renderDir(urlPath, opts); +} + +async function renderSingleFile(urlPath: string, opts: ServeOptions): Promise { + // In file mode we only serve the target. Any path is fine — same response. + return await renderFileAtPath(opts.target, opts); +} + +async function renderDir(urlPath: string, opts: ServeOptions): Promise { + if (urlPath === "/" || urlPath === "") return renderIndex(opts); + const safe = resolveSafe(opts.target, urlPath); + if (!safe) return notFound(opts); + try { + const st = await stat(safe); + if (st.isDirectory()) return renderIndex(opts, safe); + return await renderFileAtPath(safe, opts); + } catch { + return notFound(opts); + } +} + +async function renderFileAtPath(absPath: string, opts: ServeOptions): Promise { + const ext = extname(absPath).toLowerCase(); + const displayPath = pathForChrome(absPath, opts); + try { + if (ext === ".md") { + const raw = await readFile(absPath, "utf8"); + const rendered = await marked.parse(raw); + const page = renderPage({ + title: displayPath, + path: displayPath, + localAddr: opts.localAddr ?? "", + bodyHtml: rendered, + reload: opts.reload, + }); + return { status: 200, headers: ct(".md"), body: page }; + } + if (ext === ".html" || ext === ".htm") { + const raw = await readFile(absPath, "utf8"); + const script = buildClientScript({ + reload: opts.reload, + overlay: opts.overlay, + path: displayPath, + localAddr: opts.localAddr ?? "", + }); + const body = injectIntoHtml(raw, script); + return { status: 200, headers: ct(ext), body }; + } + // Raw asset + const buf = await readFile(absPath); + return { status: 200, headers: ct(ext), body: buf }; + } catch { + return notFound(opts); + } +} + +async function renderIndex(opts: ServeOptions, subDir?: string): Promise { + const root = subDir ?? opts.target; + const entries = await listRenderable(root); + entries.sort((a, b) => b.mtimeMs - a.mtimeMs); + const heading = (subDir && subDir !== opts.target) + ? relative(opts.target, subDir) + "/" + : basename(opts.target) + "/"; + const list = entries.length === 0 + ? `

No .html or .md files here yet — save one and it will appear.

` + : ``; + const page = renderPage({ + title: heading, + path: heading, + localAddr: opts.localAddr ?? "", + bodyHtml: list, + reload: opts.reload, + }); + return { status: 200, headers: ct(".html"), body: page }; +} + +interface IndexEntry { + urlPath: string; + label: string; + mtimeMs: number; +} + +async function listRenderable(rootAbs: string): Promise { + const out: IndexEntry[] = []; + await walk(rootAbs, rootAbs, out); + return out; +} + +async function walk(rootAbs: string, dirAbs: string, out: IndexEntry[]): Promise { + let names: string[]; + try { + names = await readdir(dirAbs); + } catch { + return; + } + for (const name of names) { + if (name.startsWith(".") || name === "node_modules") continue; + const abs = join(dirAbs, name); + let st; + try { + st = await stat(abs); + } catch { + continue; + } + if (st.isDirectory()) { + await walk(rootAbs, abs, out); + continue; + } + const ext = extname(name).toLowerCase(); + if (ext !== ".html" && ext !== ".htm" && ext !== ".md") continue; + const rel = relative(rootAbs, abs); + out.push({ + urlPath: "/" + rel.split(sep).map(encodeURIComponent).join("/"), + label: rel, + mtimeMs: st.mtimeMs, + }); + } +} + +function notFound(opts: ServeOptions): RenderResult { + const body = renderPage({ + title: "not found", + path: "404", + localAddr: opts.localAddr ?? "", + bodyHtml: `

The requested file isn't here yet — htmlbin serve is watching, save the file to render it.

`, + reload: opts.reload, + }); + return { status: 404, headers: ct(".html"), body }; +} + +function pathForChrome(absPath: string, opts: ServeOptions): string { + if (opts.mode === "file") return basename(absPath); + const rel = relative(opts.target, absPath); + return rel || basename(absPath); +} + +function ct(ext: string): Record { + return { + "Content-Type": MIME[ext] ?? "application/octet-stream", + "Cache-Control": "no-store", + }; +} + +function basename(p: string): string { + const idx = p.lastIndexOf(sep); + return idx === -1 ? p : p.slice(idx + 1); +} + +function formatMtime(ms: number): string { + const diff = Date.now() - ms; + const min = 60_000, hr = 60 * min, day = 24 * hr; + if (diff < min) return "just now"; + if (diff < hr) return `${Math.floor(diff / min)}m ago`; + if (diff < day) return `${Math.floor(diff / hr)}h ago`; + if (diff < 30 * day) return `${Math.floor(diff / day)}d ago`; + return new Date(ms).toISOString().slice(0, 10); +} diff --git a/src/serve/server.ts b/src/serve/server.ts new file mode 100644 index 0000000..f405e8e --- /dev/null +++ b/src/serve/server.ts @@ -0,0 +1,132 @@ +// HTTP server + SSE broadcast hub. Two routes: +// GET /__hb/sse → text/event-stream; one connection per browser tab +// GET / → delegated to render.ts +// +// SSE clients are tracked in a Set so the watcher (wired up in index.ts) +// can broadcast `reload` to all of them. A 25s ping keeps proxies from +// closing idle connections. + +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import { AddressInfo } from "node:net"; +import { renderForRequest } from "./render.js"; +import type { ServeOptions } from "./types.js"; + +export interface ServerHandle { + server: Server; + port: number; + url: string; + broadcastReload(path: string): void; + clientCount(): number; + /** ms timestamp of last SSE activity (connect/disconnect/broadcast). */ + lastActivity(): number; + close(): Promise; +} + +const PING_MS = 25_000; + +export async function startServer( + opts: ServeOptions, + onClientChange?: (count: number) => void, + onReload?: (path: string) => void +): Promise { + const clients = new Set(); + let activityAt = Date.now(); + + const server = createServer(async (req, res) => { + try { + const url = req.url ?? "/"; + if (url === "/__hb/sse") { + handleSse(req, res, clients); + activityAt = Date.now(); + onClientChange?.(clients.size); + return; + } + // Strip query string before path resolution. + const pathOnly = url.split("?")[0] ?? "/"; + const result = await renderForRequest(pathOnly, opts); + res.writeHead(result.status, result.headers); + res.end(result.body); + } catch (e) { + res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" }); + res.end(`htmlbin serve: ${(e as Error).message}`); + } + }); + + const pinger = setInterval(() => { + for (const c of clients) { + try { c.write(`:ping\n\n`); } catch { /* client gone; cleanup on next write */ } + } + }, PING_MS); + pinger.unref?.(); + + await new Promise((resolveListen, reject) => { + server.once("error", reject); + server.listen(opts.port, opts.host, () => { + server.off("error", reject); + resolveListen(); + }); + }); + + const addr = server.address() as AddressInfo; + const url = `http://${formatHost(opts.host)}:${addr.port}/`; + // Stamp the bound address back onto opts so renderForRequest can show + // "localhost:" in the modeline. Mutation is fine — opts is + // module-local to one server lifecycle. + opts.localAddr = `${formatHost(opts.host)}:${addr.port}`; + + return { + server, + port: addr.port, + url, + broadcastReload(path: string): void { + activityAt = Date.now(); + for (const c of clients) { + try { + c.write(`event: reload\ndata: ${JSON.stringify({ path })}\n\n`); + } catch { + clients.delete(c); + } + } + onReload?.(path); + }, + clientCount(): number { + return clients.size; + }, + lastActivity(): number { + return activityAt; + }, + async close(): Promise { + clearInterval(pinger); + for (const c of clients) { + try { c.end(); } catch { /* ignore */ } + } + clients.clear(); + await new Promise((r) => server.close(() => r())); + }, + }; +} + +function handleSse( + _req: IncomingMessage, + res: ServerResponse, + clients: Set +): void { + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-store", + Connection: "keep-alive", + "X-Accel-Buffering": "no", + }); + res.write(`event: open\ndata: ok\n\n`); + clients.add(res); + res.on("close", () => { + clients.delete(res); + }); +} + +// `localhost` reads nicer than `127.0.0.1` in printed URLs, but if the +// user bound to a real IP we keep it. +function formatHost(host: string): string { + if (host === "127.0.0.1" || host === "0.0.0.0") return "localhost"; + return host; +} diff --git a/src/serve/state.ts b/src/serve/state.ts new file mode 100644 index 0000000..2da56db --- /dev/null +++ b/src/serve/state.ts @@ -0,0 +1,47 @@ +// Per-CWD state file for `htmlbin serve`. Stored at `./.htmlbin/serve.json` +// so a subsequent `serve --status` / `serve --stop` (or another agent turn) +// can discover the running server without parsing logs. + +import { mkdir, readFile, writeFile, unlink } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import type { ServeState } from "./types.js"; + +export function stateFilePath(cwd = process.cwd()): string { + return resolve(cwd, ".htmlbin/serve.json"); +} + +export async function writeState(state: ServeState, cwd = process.cwd()): Promise { + const path = stateFilePath(cwd); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, JSON.stringify(state, null, 2) + "\n", "utf8"); +} + +export async function readState(cwd = process.cwd()): Promise { + try { + const raw = await readFile(stateFilePath(cwd), "utf8"); + return JSON.parse(raw) as ServeState; + } catch (e) { + if ((e as { code?: string }).code === "ENOENT") return null; + return null; + } +} + +export async function clearState(cwd = process.cwd()): Promise { + try { + await unlink(stateFilePath(cwd)); + } catch (e) { + if ((e as { code?: string }).code !== "ENOENT") throw e; + } +} + +// `kill(pid, 0)` is a permissionless liveness check — throws ESRCH if no +// such process. We treat any throw as "not alive" so a stale state file +// (e.g. previous run killed -9) doesn't block a fresh start. +export function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} diff --git a/src/serve/template.ts b/src/serve/template.ts new file mode 100644 index 0000000..afae773 --- /dev/null +++ b/src/serve/template.ts @@ -0,0 +1,211 @@ +// Page chrome for content we own: rendered Markdown, dir index, 404. +// Two layers, matching htmlbin.dev exactly: +// 1. Top: full-width .viewer-bar — same shape as a production drop page, +// with a [local] chip + localhost: as the local signal. +// 2. Body: 720px
column with Geist sans, the same prose color +// tokens as htmlbin3/src/styles.ts. + +const TOKENS = ` +:root { + --bg: #FFFFFF; --bg-2: #FAFAFA; --bg-3: #F5F5F5; + --ink: #0A0A0A; --ink-2: #171717; --ink-soft: #737373; --ink-softer: #A3A3A3; + --rule: #E5E5E5; --rule-soft: #F0F0F0; + --red: #D93025; --red-bg: #FCE8E6; --red-bg-stroke: #F4C7C3; + --green-dot: #1F8F4A; --amber-dot: #B45309; + --sans: "Geist", -apple-system, "Inter", system-ui, sans-serif; + --mono: "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace; +} +`.trim(); + +const BASE = ` +* { box-sizing: border-box; margin: 0; padding: 0; } +html, body { + background: var(--bg); color: var(--ink); + font-family: var(--sans); font-size: 16px; line-height: 1.6; + -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; +} +::selection { background: var(--red); color: #fff; } +a { color: var(--ink); text-decoration: underline; text-decoration-color: var(--rule); text-underline-offset: 3px; text-decoration-thickness: 1px; } +a:hover { color: var(--red); text-decoration-color: var(--red); } +`.trim(); + +// Lifted directly from htmlbin3/src/styles.ts .viewer-bar, with the same +// tokens. The only addition is .hb-local-chip (the red-on-red [local] pill) +// and the connection-status dot — both are local-only affordances. +const BAR = ` +header.hb-bar { + display: flex; align-items: center; gap: 14px; + padding: 10px 18px; + border-bottom: 1px solid var(--rule); + background: var(--bg-2); + flex-wrap: wrap; + font-size: 13px; + font-family: var(--sans); + position: sticky; top: 0; z-index: 50; +} +header.hb-bar .hb-sep { + color: var(--ink-softer); font-family: var(--mono); +} +header.hb-bar .hb-title { + font-weight: 600; font-size: 14px; color: var(--ink); + flex: 0 1 auto; min-width: 0; max-width: 36vw; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +header.hb-bar .hb-right { + margin-left: auto; flex: 0 0 auto; + display: flex; align-items: center; gap: 14px; + font-family: var(--mono); font-size: 12px; color: var(--ink-soft); +} +header.hb-bar .hb-right a { color: inherit; } +header.hb-bar .hb-right a:hover { color: var(--red); } +header.hb-bar .hb-chip { + display: inline-flex; align-items: center; + background: var(--bg-2); border: 1px solid var(--rule); + border-radius: 4px; padding: 3px 8px; + font: 500 11.5px/1 var(--mono); + color: var(--ink-2); + letter-spacing: 0.02em; +} +header.hb-bar .hb-chip.hb-local-chip { + color: var(--red); border-color: var(--red-bg-stroke); background: var(--red-bg); +} +header.hb-bar .hb-status { + display: inline-flex; align-items: center; gap: 6px; +} +header.hb-bar .hb-dot { + width: 7px; height: 7px; border-radius: 50%; + background: var(--ink-softer); display: inline-block; +} +header.hb-bar[data-state="connected"] .hb-dot { background: var(--green-dot); } +header.hb-bar[data-state="reconnecting"] .hb-dot { background: var(--amber-dot); } +@media (max-width: 720px) { + header.hb-bar { gap: 10px; padding: 10px 14px; } + header.hb-bar .hb-title { max-width: none; flex: 1 1 0; min-width: 0; font-size: 13.5px; } + header.hb-bar .hb-right { width: 100%; margin-left: 0; gap: 12px; font-size: 11.5px; flex-wrap: wrap; } +} +`.trim(); + +const MAIN = ` +main.hb-main { max-width: 720px; margin: 0 auto; padding: 28px 28px 96px; } +main.hb-main h1 { font-size: clamp(28px, 4vw, 38px); letter-spacing: -0.02em; line-height: 1.15; margin: 0 0 14px; } +main.hb-main h2 { font-size: 22px; line-height: 1.25; margin: 28px 0 12px; letter-spacing: -0.01em; } +main.hb-main h3 { font-size: 18px; line-height: 1.3; margin: 22px 0 10px; } +main.hb-main p { margin: 0 0 18px; max-width: 64ch; font-size: 17px; line-height: 1.65; color: var(--ink-2); } +main.hb-main p strong { color: var(--ink); font-weight: 600; } +main.hb-main p em { font-style: normal; color: var(--red); font-weight: 500; } +main.hb-main ul, main.hb-main ol { margin: 0 0 18px 24px; } +main.hb-main li { margin: 6px 0; max-width: 64ch; font-size: 17px; color: var(--ink-2); } +main.hb-main hr { border: 0; border-top: 1px solid var(--rule); margin: 28px 0; } +main.hb-main blockquote { + margin: 0 0 20px; padding: 4px 18px; + border-left: 3px solid var(--red); color: var(--ink-soft); + font-size: 17px; +} +code, .hb-mono { font-family: var(--mono); font-size: 0.86em; } +main.hb-main p code, main.hb-main li code { + background: var(--bg-2); border: 1px solid var(--rule); + padding: 1px 6px; border-radius: 4px; white-space: nowrap; + font-weight: 500; color: var(--ink-2); +} +main.hb-main pre { + background: #0A0A0A; color: #FAFAFA; + padding: 18px 22px; border-radius: 6px; overflow-x: auto; + margin: 0 0 24px; font: 13.5px/1.7 var(--mono); +} +main.hb-main pre code { background: none; border: 0; padding: 0; color: inherit; } +main.hb-main table { + border-collapse: collapse; margin: 0 0 22px; + font-size: 15px; width: 100%; +} +main.hb-main th, main.hb-main td { + text-align: left; padding: 10px 14px; + border-bottom: 1px solid var(--rule-soft); +} +main.hb-main th { + font: 500 11px/1 var(--mono); color: var(--ink-soft); + letter-spacing: 0.06em; text-transform: uppercase; +} +@media (max-width: 720px) { main.hb-main { padding: 22px 22px 80px; } } +ul.hb-index { list-style: none; padding: 0; margin: 0; } +ul.hb-index li { + padding: 10px 0; + border-bottom: 1px solid var(--rule-soft); + display: grid; grid-template-columns: 1fr auto; gap: 18px; align-items: baseline; +} +ul.hb-index li:last-child { border-bottom: 0; } +ul.hb-index a { + font-family: var(--mono); font-size: 13px; + text-decoration: none; color: var(--ink); +} +ul.hb-index a:hover { color: var(--red); } +ul.hb-index .hb-meta { + color: var(--ink-softer); font: 12px var(--mono); + font-variant-numeric: tabular-nums; white-space: nowrap; +} +p.hb-empty { color: var(--ink-soft); font-family: var(--mono); font-size: 13px; } +`.trim(); + +const CLIENT = ``; + +interface PageOpts { + /** . */ + title: string; + /** Shown in the bar as the file/path. */ + path: string; + /** "localhost:62821". */ + localAddr: string; + /** Rendered body HTML. */ + bodyHtml: string; + /** When false, omit the live-reload client (status dot stays "disconnected"). */ + reload: boolean; +} + +export function renderPage(opts: PageOpts): string { + const client = opts.reload ? CLIENT : ""; + return `<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width,initial-scale=1"> +<title>${escapeHtml(opts.title)} + + + +
+ htmlbin + / + ${escapeHtml(opts.path)} + + local + ${escapeHtml(opts.localAddr)} + disconnected + +
+
+${opts.bodyHtml} +
+${client} + + +`; +} + +export function escapeHtml(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} diff --git a/src/serve/types.ts b/src/serve/types.ts new file mode 100644 index 0000000..acb2a58 --- /dev/null +++ b/src/serve/types.ts @@ -0,0 +1,32 @@ +// Shared types for the `htmlbin serve` subsystem. + +export type ServeMode = "file" | "dir"; + +export interface ServeOptions { + /** Absolute path to the file or directory being served. */ + target: string; + mode: ServeMode; + host: string; + /** 0 means ephemeral. */ + port: number; + open: boolean; + reload: boolean; + overlay: boolean; + /** Minutes; 0 disables idle shutdown. */ + idleTimeout: number; + /** When true, emit one JSON object per event on stdout instead of human text. */ + json: boolean; + /** Populated after the server binds, e.g. "localhost:62821". Used by the + * injected chrome to show the local address in the modeline. */ + localAddr?: string; +} + +export interface ServeState { + pid: number; + url: string; + port: number; + /** Absolute path. */ + serving: string; + mode: ServeMode; + started_at: string; +} diff --git a/src/serve/watcher.ts b/src/serve/watcher.ts new file mode 100644 index 0000000..599538f --- /dev/null +++ b/src/serve/watcher.ts @@ -0,0 +1,61 @@ +// Thin wrapper over `fs.watch` with debouncing. Editors like vim emit +// rename+rename on save (write to tmp, rename over original), which yields +// two raw events ~1ms apart. We collapse them so SSE clients see one +// reload, not two. + +import { watch, type FSWatcher } from "node:fs"; +import { stat } from "node:fs/promises"; + +export interface Watcher { + close(): void; +} + +const DEBOUNCE_MS = 50; + +export async function watchTarget( + target: string, + onChange: (path: string) => void +): Promise { + const st = await stat(target); + const recursive = st.isDirectory(); + + let timer: NodeJS.Timeout | null = null; + let lastPath = target; + + const fsWatcher: FSWatcher = watch( + target, + { recursive, persistent: true }, + (_event, filename) => { + if (filename) { + const name = filename.toString(); + // Skip dotfiles and node_modules. The state file `.htmlbin/serve.json` + // is inside the watched directory in dir mode and would otherwise + // trigger phantom reloads at start/stop. + if (shouldIgnore(name)) return; + lastPath = name; + } + if (timer) clearTimeout(timer); + timer = setTimeout(() => { + timer = null; + onChange(lastPath); + }, DEBOUNCE_MS); + } + ); + + return { + close(): void { + if (timer) clearTimeout(timer); + fsWatcher.close(); + }, + }; +} + +function shouldIgnore(relPath: string): boolean { + // Split on both unix and windows separators so this works cross-platform. + const parts = relPath.split(/[\\/]/); + for (const part of parts) { + if (part.startsWith(".")) return true; + if (part === "node_modules") return true; + } + return false; +} From 6edb39fb59ebb54e06cca9c25bd3fe3c3b2defd3 Mon Sep 17 00:00:00 2001 From: Utkarsh Sengar Date: Sat, 23 May 2026 18:05:32 -0700 Subject: [PATCH 2/4] serve: drop close button, fix overlap, add publish-command popover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three follow-ups based on real-world testing of the injected viewer-bar: - Removed the × close button. Production drops have no close on the viewer-bar; `--no-overlay` already covers the "I want this off" case. - Fixed content hiding behind the bar. With `position: fixed`, the user's HTML starts at y=0 and the first ~40px sat under the bar. At mount we now add the bar's height to body's existing `padding-top` (preserving whatever was there) and keep it synced via ResizeObserver, so wrapped rows on narrow viewports still get the right offset. - Added a `publish →` link on the right side. Click opens a small popover with `htmlbin publish ` and a Copy command button. Uses the absolute path so the copied command works regardless of where it's pasted. No network calls from the server — bar stays passive. Only `.html`/`.htm` files get the publish affordance (Markdown isn't directly publishable). `render.ts` threads the absolute path through as `publishPath`; empty string hides the button. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/serve/inject.ts | 131 +++++++++++++++++++++++++++++++++++++++++--- src/serve/render.ts | 1 + 2 files changed, 124 insertions(+), 8 deletions(-) diff --git a/src/serve/inject.ts b/src/serve/inject.ts index 6ce9c5b..80b4276 100644 --- a/src/serve/inject.ts +++ b/src/serve/inject.ts @@ -8,8 +8,12 @@ interface InjectOpts { reload: boolean; /** When false, the bar is skipped entirely. SSE reload still works. */ overlay: boolean; - /** Basename or relative path of the served file. */ + /** Basename or relative path of the served file (shown in the bar). */ path: string; + /** Absolute path used in the "publish" popover so the copied command + * works regardless of where it's pasted. Empty string hides the + * publish affordance (e.g. for non-publishable files). */ + publishPath: string; /** "localhost:62821". */ localAddr: string; } @@ -81,12 +85,42 @@ const BAR_CSS = ` } .hb-bar[data-state="connected"] .hb-dot { background: #1F8F4A; } .hb-bar[data-state="reconnecting"] .hb-dot { background: #B45309; } -.hb-bar .hb-close { - color: #A3A3A3; cursor: pointer; padding: 2px 4px; - font-size: 13px; line-height: 1; - display: inline-block; +.hb-bar .hb-publish { + color: #737373; cursor: pointer; padding: 0; + text-decoration: underline; text-decoration-color: #E5E5E5; + text-underline-offset: 3px; text-decoration-thickness: 1px; + transition: color 0.12s, text-decoration-color 0.12s; +} +.hb-bar .hb-publish:hover { color: #D93025; text-decoration-color: #D93025; } +.hb-pop { + position: fixed; z-index: 2147483646; + background: #FFFFFF; border: 1px solid #E5E5E5; + border-radius: 6px; box-shadow: 0 8px 24px -8px rgba(0,0,0,0.16); + padding: 14px; min-width: 360px; max-width: 540px; + font-family: "Geist", -apple-system, "Inter", system-ui, sans-serif; +} +.hb-pop .hb-pop-label { + display: block; margin: 0 0 8px; + font: 500 10.5px/1 "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace; + color: #737373; letter-spacing: 0.08em; text-transform: uppercase; +} +.hb-pop .hb-pop-cmd { + display: block; padding: 10px 12px; margin: 0 0 10px; + background: #0A0A0A; color: #FAFAFA; border-radius: 4px; + font: 13px/1.4 "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace; + white-space: pre-wrap; word-break: break-all; } -.hb-bar .hb-close:hover { color: #D93025; } +.hb-pop .hb-pop-copy { + display: inline-flex; align-items: center; gap: 6px; + background: #0A0A0A; color: #FAFAFA; + border: 1px solid #0A0A0A; + font: 500 12px/1 "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace; + letter-spacing: 0.04em; text-transform: uppercase; + padding: 8px 14px; border-radius: 4px; cursor: pointer; + transition: background 0.12s, border-color 0.12s; +} +.hb-pop .hb-pop-copy:hover { background: #D93025; border-color: #D93025; } +.hb-pop .hb-pop-copy.ok { background: #1F8F4A; border-color: #1F8F4A; } @media (max-width: 720px) { .hb-bar { gap: 10px; padding: 10px 14px; font-size: 12.5px; } .hb-bar .hb-title { max-width: none; flex: 1 1 0; min-width: 0; font-size: 13.5px; } @@ -106,12 +140,14 @@ export function buildClientScript(opts: InjectOpts): string { const enableReload = opts.reload ? "true" : "false"; const pathLit = JSON.stringify(opts.path); const addrLit = JSON.stringify(opts.localAddr); + const publishLit = JSON.stringify(opts.publishPath); return ` `; + const params = new URLSearchParams(); + params.set("reload", opts.reload ? "1" : "0"); + params.set("overlay", opts.overlay ? "1" : "0"); + if (opts.path) params.set("path", opts.path); + if (opts.localAddr) params.set("addr", opts.localAddr); + if (opts.publishPath) params.set("publish", opts.publishPath); + return `` + + ``; } // Insert just before . Fallback: append at end when no body tag. -export function injectIntoHtml(html: string, script: string): string { - if (!script) return html; +export function injectIntoHtml(html: string, tags: string): string { + if (!tags) return html; const idx = html.toLowerCase().lastIndexOf(""); - if (idx === -1) return html + script; - return html.slice(0, idx) + script + html.slice(idx); + if (idx === -1) return html + tags; + return html.slice(0, idx) + tags + html.slice(idx); } diff --git a/src/serve/render.ts b/src/serve/render.ts index 665c219..3fbeeb0 100644 --- a/src/serve/render.ts +++ b/src/serve/render.ts @@ -6,7 +6,7 @@ // Directory mode also renders an index page at "/" listing all .html/.md // files under the root (recursive), sorted by mtime desc. -import { readFile, readdir, stat } from "node:fs/promises"; +import { lstat, readFile, readdir, realpath, stat } from "node:fs/promises"; import { extname, join, relative, resolve, sep } from "node:path"; import { marked } from "marked"; import { buildClientScript, injectIntoHtml } from "./inject.js"; @@ -69,6 +69,11 @@ async function renderDir(urlPath: string, opts: ServeOptions): Promise { + try { + const real = await realpath(absPath); + const realRoot = await realpath(rootAbs); + return real === realRoot || real.startsWith(realRoot + sep); + } catch { + return false; + } +} + async function renderFileAtPath(absPath: string, opts: ServeOptions): Promise { const ext = extname(absPath).toLowerCase(); const displayPath = pathForChrome(absPath, opts); @@ -160,10 +175,14 @@ async function walk(rootAbs: string, dirAbs: string, out: IndexEntry[]): Promise const abs = join(dirAbs, name); let st; try { - st = await stat(abs); + // lstat (not stat): we want to know if the entry itself is a + // symlink, not what it resolves to. Skip symlinks entirely so + // the index can't list files outside the served root. + st = await lstat(abs); } catch { continue; } + if (st.isSymbolicLink()) continue; if (st.isDirectory()) { await walk(rootAbs, abs, out); continue; diff --git a/src/serve/server.ts b/src/serve/server.ts index f405e8e..d313bae 100644 --- a/src/serve/server.ts +++ b/src/serve/server.ts @@ -9,6 +9,7 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; import { AddressInfo } from "node:net"; import { renderForRequest } from "./render.js"; +import { CLIENT_CSS, CLIENT_JS } from "./client.js"; import type { ServeOptions } from "./types.js"; export interface ServerHandle { @@ -43,6 +44,22 @@ export async function startServer( } // Strip query string before path resolution. const pathOnly = url.split("?")[0] ?? "/"; + if (pathOnly === "/__hb/client.css") { + res.writeHead(200, { + "Content-Type": "text/css; charset=utf-8", + "Cache-Control": "no-store", + }); + res.end(CLIENT_CSS); + return; + } + if (pathOnly === "/__hb/client.js") { + res.writeHead(200, { + "Content-Type": "application/javascript; charset=utf-8", + "Cache-Control": "no-store", + }); + res.end(CLIENT_JS); + return; + } const result = await renderForRequest(pathOnly, opts); res.writeHead(result.status, result.headers); res.end(result.body); From 25f83b2cc11f3897b15cdbbb009c3bba334a3b65 Mon Sep 17 00:00:00 2001 From: Utkarsh Sengar Date: Sun, 24 May 2026 06:20:35 -0700 Subject: [PATCH 4/4] cli: branded help banner for `htmlbin` and `htmlbin --help` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces commander's default root help with a sectioned layout (commands / flags / environment variables / try / learn-more) and a chunky ASCII wordmark across the top. Gating: - ASCII logo + ANSI colors only when stdout is a TTY and NO_COLOR is unset. Piped runs (agents, CI, redirected captures) get the same content as plain text, no banner. - Reassigns `program.helpInformation` on the root command only — avoiding configureHelp() which would propagate to subcommands. `htmlbin --help` still uses commander's default per-command formatter (the right shape there). - `htmlbin` with no subcommand now shows the help instead of commander's "missing required command" error. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/banner.ts | 107 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/bin.ts | 12 ++++++ 2 files changed, 119 insertions(+) create mode 100644 src/banner.ts diff --git a/src/banner.ts b/src/banner.ts new file mode 100644 index 0000000..8e847c3 --- /dev/null +++ b/src/banner.ts @@ -0,0 +1,107 @@ +// Help banner + Sentry-style top-level help formatter, used when the user +// runs `htmlbin` (no args) or `htmlbin --help`. The ASCII logo and ANSI +// colors are gated on TTY + NO_COLOR, so agents (whose stdout is piped) +// see a plain-text version. Sub-commands keep commander's default help. + +const LOGO = [ + "██╗ ██╗████████╗███╗ ███╗██╗ ██████╗ ██╗███╗ ██╗", + "██║ ██║╚══██╔══╝████╗ ████║██║ ██╔══██╗██║████╗ ██║", + "███████║ ██║ ██╔████╔██║██║ ██████╔╝██║██╔██╗ ██║", + "██╔══██║ ██║ ██║╚██╔╝██║██║ ██╔══██╗██║██║╚██╗██║", + "██║ ██║ ██║ ██║ ╚═╝ ██║███████╗██████╔╝██║██║ ╚████║", + "╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚══════╝╚═════╝ ╚═╝╚═╝ ╚═══╝", +] as const; + +// One row per command group, with the subcommand verbs pipe-separated. +const COMMANDS: ReadonlyArray = [ + ["$ htmlbin publish ", "Publish an HTML file and print the URL"], + ["$ htmlbin serve | --status | --stop", "Run a local preview with live reload"], + ["$ htmlbin list | url | delete ", "Browse and manage published drops"], + ["$ htmlbin patterns list | init | add ", "Install + manage local patterns"], + ["$ htmlbin login", "Sign in to htmlbin.dev (cloud backend)"], + ["$ htmlbin setup --to ", "One-time backend setup (gh-pages, cloudflare)"], +]; + +const FLAGS: ReadonlyArray = [ + ["--to ", "Backend: cloud | gh-pages | cloudflare"], + ["--output ", "text | json (auto-flips to json in agent contexts)"], + ["--debug", "Include upstream response bodies in errors"], + ["--help", "Show help for a command"], + ["--version", "Show version"], +]; + +const ENV_VARS: ReadonlyArray = [ + ["HTMLBIN_TOKEN", "Cloud API token (overrides ./.htmlbin/token)"], + ["HTMLBIN_BACKEND", "Default backend (overrides .htmlbin/config)"], + ["HTMLBIN_DEBUG", "Include upstream response bodies in error details"], + ["GITHUB_TOKEN", "Auth for the gh-pages backend"], + ["CLOUDFLARE_API_TOKEN", "Auth for the cloudflare backend"], + ["CLAUDE_CODE / CURSOR_AGENT / ...", "When set, --output flips to json by default"], + ["NO_COLOR", "Disable colored output (no-color.org)"], +]; + +const RED = "\x1b[31m"; +const PINK = "\x1b[38;5;204m"; +const DIM = "\x1b[2m"; +const BOLD = "\x1b[1m"; +const RESET = "\x1b[0m"; + +function useColor(): boolean { + if (process.env.NO_COLOR) return false; + if (process.env.FORCE_COLOR) return true; + return !!process.stdout.isTTY; +} + +function paint(s: string, code: string): string { + return useColor() ? code + s + RESET : s; +} + +function row(left: string, right: string, leftWidth: number): string { + return " " + left.padEnd(leftWidth) + paint(right, DIM); +} + +export function formatRootHelp(version: string): string { + const lines: string[] = []; + + // Banner — only when we have color. Otherwise the plain text help below + // stands alone (cleaner for agents and piped output). + if (useColor()) { + lines.push(""); + for (const l of LOGO) lines.push(paint(l, RED)); + lines.push(""); + } + + // Tagline — keep the red < > brackets that anchor the brand. + lines.push( + " The command-line interface for " + + paint("<", RED) + "htmlbin" + paint(">", RED) + + " — publish HTML, get a URL." + ); + lines.push(""); + + // Commands + const cmdW = Math.max(...COMMANDS.map(([c]) => c.length)) + 4; + for (const [c, d] of COMMANDS) lines.push(row(c, d, cmdW)); + lines.push(""); + + // Flags + lines.push(paint("Flags:", BOLD)); + const flagW = Math.max(...FLAGS.map(([f]) => f.length)) + 4; + for (const [f, d] of FLAGS) lines.push(row(f, d, flagW)); + lines.push(""); + + // Environment variables + lines.push(paint("Environment Variables:", BOLD)); + const envW = Math.max(...ENV_VARS.map(([e]) => e.length)) + 4; + for (const [e, d] of ENV_VARS) lines.push(row(e, d, envW)); + lines.push(""); + + // Try-this hint + Learn more footer + lines.push(" try: " + paint("htmlbin serve report.html", PINK)); + lines.push(""); + lines.push(" Learn more at " + paint("https://htmlbin.dev", RED) + + " " + paint("(v" + version + ")", DIM)); + lines.push(""); + + return lines.join("\n"); +} diff --git a/src/bin.ts b/src/bin.ts index 9db8d45..29d51e9 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -33,6 +33,7 @@ import { initPatterns } from "./patterns/init.js"; import { ensureNoSilentSkip, installPattern } from "./patterns/install.js"; import { resolveSource } from "./patterns/sources.js"; import { startServe, statusServe, stopServe } from "./serve/index.js"; +import { formatRootHelp } from "./banner.js"; const VERSION = "0.1.0"; @@ -195,6 +196,11 @@ async function run(): Promise { .name("htmlbin") .description("Publish HTML, get a URL. Cloud by default; pluggable backends for org-internal hosting.") .version(VERSION) + // Show help when invoked with no subcommand. Without this, commander + // exits with "missing required command" which isn't a useful landing. + .action(() => { + process.stdout.write(formatRootHelp(VERSION)); + }) .addOption( new Option("--to ", "backend to use: cloud | gh-pages | cloudflare").choices([ "cloud", @@ -213,6 +219,12 @@ async function run(): Promise { ) ); + // Replace just the ROOT command's helpInformation. Assigning to + // configureHelp would propagate to subcommands; reassigning the method + // directly on `program` keeps subcommand `--help` using commander's + // default formatter (which is the right shape for per-command help). + program.helpInformation = () => formatRootHelp(VERSION); + // Sync OUTPUT_MODE + User-Agent before each command runs so die() and // emit() see the resolved value, and outbound HTTP carries the detected // agent. Precedence: explicit --output > agent env detection > "text".