feat: htmlbin serve — local preview before publish#5
Open
utsengar wants to merge 4 commits into
Open
Conversation
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 <path> 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) <noreply@anthropic.com>
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 <abs-path>` 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) <noreply@anthropic.com>
Four fixes from stress testing the injected viewer-bar against pages
that try to break it, and the static file server against escape attempts:
- Symlink escape (path safety). renderDir's lexical path check let a
symlink inside the served root point at arbitrary files (e.g. a
symlink to /etc/passwd inside the dir was served at 200). Now adds
a fs.realpath() check that re-verifies the resolved path is still
under realpath(rootAbs). The directory walker also switches from
stat to lstat and skips symlinks, so they don't appear in the index
and we never recurse into a symlinked subdir.
- Page CSP. Inline <style> and <script> blocks were rejected by pages
with Content-Security-Policy: script-src 'self' / style-src 'self'.
Moved the bar's CSS and JS into a new client.ts module served as
static assets at /__hb/client.css and /__hb/client.js, and changed
inject.ts to emit <link> + <script src> tags. Same-origin requests
satisfy 'self' policies. Per-page config (path, addr, publishPath,
reload, overlay) is passed via query string and read by the script
from its own document.currentScript.src.
- CSS reset escape. Pages with `* { all: revert !important }` or
similar aggressive resets could override the bar's positioning.
Added !important to all bar layout properties and the scoped
`all: unset` reset, so user !important rules can't beat us.
- Transform-as-containing-block trap. position:fixed resolves
relative to the nearest ancestor with transform / filter /
will-change / perspective, not the viewport. A page with
`body { transform: ... }` (very common in animation-heavy sites)
trapped the bar inside body's box, collapsing it back to the
user's max-width. Bar and popover now append to
document.documentElement instead of document.body, so they
escape body's containing block.
Stress-test results that still pass unchanged: 100 concurrent SSE
clients, 50-client reload broadcasts, 50ms debounce on rapid edits
(20 writes → 1 reload), atomic-save handling, SIGKILL-stale-state
recovery, port-collision error, status-from-other-cwd.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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 <cmd> --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) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds
htmlbin serve— a local preview server for HTML and Markdown so iteration happens locally before publishing. Same file in, same artifact out:htmlbin serve out.html→ review locally →htmlbin publish out.html. Also gives the CLI a branded help screen.Highlights
.htmlserved raw;.mdrendered viamarkedwith the same prose tokens as htmlbin.dev. Directory mode shows anmtime-sorted index.[local]chip + connection dot. Appended todocumentElement(notbody) andposition: fixed, so it survives user pages withmax-widthcontainers,transform, orfilter. Bodypadding-topis extended at mount so content sits below the bar, not behind it.publish →link in the bar. Click opens a small popover withhtmlbin publish <abs-path>and a Copy command button. No network calls from the server — bar stays passive. Shown only for.html/.htm./__hb/client.cssand/__hb/client.js, not inline, so pages withContent-Security-Policy: script-src 'self'(andstyle-src 'self') still load the bar.!important, so user CSS like* { all: revert !important }can't override it.fs.watchwith 50ms debounce and a dotfile/node_modulesfilter (so the state file at.htmlbin/serve.jsondoesn't fire phantom reloads).renderDirdoes afs.realpath()check to confirm the resolved path is still under the served root; the directory walker useslstatand skips symlinks.started,reload,client-connected,idle-shutdown,stopped) when--output json. Lets an agent tail the background process and verify "did my edit reach the browser?" without polling../.htmlbin/serve.jsonis the cross-invocation discovery primitive — a later turn can call--status/--stopwithout parsing logs.CLAUDE_CODE/CURSOR_AGENT/ etc. is set in the env, browser auto-open flips off and idle timeout drops from 30 → 5 min.SIGINT/SIGTERMshutdown; ephemeral port by default,--portto override.htmlbin --help. Sectioned layout (commands / flags / environment variables / try / learn-more) with an ASCII wordmark, gated on TTY +NO_COLORso piped output stays plain.htmlbinwith no subcommand now lands on this help instead of commander's "missing required command" error. Subcommands keep commander's default per-command help.New runtime dep:
marked.Files
Stress tests passed
../,%2E%2E%2F, double-encoded, null byte) → 404/etc/passwd→ 404 (after hardening)kill -9→ stale state file is cleared on next startserve_port_in_use(exit 8)serve --stoptwice → second returnsserve_not_running(exit 7)Test plan
htmlbin serve <file.html>— bar renders full-width with[local]chip; edit file → browser reloadsbody { padding })publish →link opens a popover withhtmlbin publish /abs/path+ Copy button; click outside or Esc dismisseshtmlbin serve <file.md>— Markdown renders with chrome at top; no publish buttonhtmlbin serve <dir>— index page lists.html/.mdsorted by mtimehtmlbin serve --status/--stop— round-trip with state file at.htmlbin/serve.jsonhtmlbin serve <path>while one is running → exit 7htmlbin serve --output json— one event per line on stdoutreloadevent (state-file write doesn't echo)htmlbin(no args) andhtmlbin --helpshow the branded banner in a TTY, plain text when pipedhtmlbin serve --help(and other subcommand--help) still uses commander's default🤖 Generated with Claude Code