Skip to content

Add bin/djot-watch live-preview CLI#178

Merged
dereuromark merged 9 commits into
masterfrom
feat/djot-watch-cli
May 13, 2026
Merged

Add bin/djot-watch live-preview CLI#178
dereuromark merged 9 commits into
masterfrom
feat/djot-watch-cli

Conversation

@dereuromark
Copy link
Copy Markdown
Contributor

Summary

Adds bin/djot-watch, a long-running CLI that serves live-reloading HTML preview of a djot file in the browser. Companion to the existing bin/djot one-shot converter:

  • bin/djot — render-once for build pipelines and scripts
  • bin/djot-watch — iterate while authoring; the browser tab reloads on every save

Editor-agnostic — works the same from Vim, Helix, VS Code, Sublime, Zed, or just a terminal. Designed to pair with php-collective/zed-djot for in-editor authoring.

What's new

  • New CLI: vendor/bin/djot-watch path/to/file.djot (flags: --port, --host, --no-open, --css, --version, --help).
  • Djot\Watch\Renderer — wraps DjotConverter and emits a full HTML document with a default stylesheet and a livereload <script> tag.
  • Djot\Watch\FileWatcher — fingerprints tracked files via (mtime, size, content-hash) so same-second / same-size re-saves still trigger refresh.
  • Djot\Watch\SseChannel — shared-file IPC between the long-lived watcher process and the short-lived php -S router worker handling each request.
  • Djot\Watch\Server — spawns php -S with stdio redirected to /dev/null (so the dev-server's request log doesn't fill its pipe buffer) and a port-bump-on-collision fallback (8765 → 8774).
  • src/Watch/router.php — runs inside php -S; serves / (rendered HTML), /__sse (Server-Sent Events with connection_aborted() checked each iteration so workers free up when browsers disconnect), and the bundled assets.
  • src/Watch/assets/livereload.js + style.css — minimal browser-side client and a dark-mode-aware default stylesheet.
  • Djot\Watch\Watcher — controller that ties it all together: arg parsing, PCNTL-based clean SIGINT/SIGTERM handling, OS-aware browser launch (xdg-open / open / start).

How it works

  1. CLI renders the file once into memory (so the first browser hit is instant).
  2. Spawns php -S on the chosen port with a tiny router script and a few env vars (target path, SSE state file, optional CSS override).
  3. Polls the target every 250 ms; on (mtime, size, hash) change, bumps the SSE sequence file.
  4. Browser's livereload script holds an SSE connection; on reload event, location.reload().
  5. Ctrl+C shuts the server down cleanly.

No daemon, no config files, no globals — just the binary and your file. Single-user localhost preview by design.

Tests

  • PHPUnit unit coverage for Renderer, SseChannel, FileWatcher (3 + 3 + 4 = 10 unit tests).
  • Real end-to-end integration test (tests/Watch/IntegrationTest.php): spawns the binary, hits / with curl, modifies the watched file, asserts an SSE reload event arrives within 5 s. Adds 1 test.
  • Full own suite green: 1889 tests, 5069 assertions.
  • PHPStan level-9 clean on src/Watch/ and tests/Watch/.
  • PHPCS clean.

Docs

  • docs/reference/cli.md gains a "Live Preview: djot-watch" section covering install, usage, flags, default styling, editor integration, and how it works internally.
  • README.md mentions both CLIs in the Features list and links to the CLI reference.

Document wrapper provides title, stylesheet link, and livereload script
hook. Bin entry stub registered in composer.json so vendor/bin/djot-watch
becomes available once the watcher class is implemented.
SseChannel uses a shared file as the IPC channel between the long-lived
watcher process and the short-lived PHP -S router handler. FileWatcher
detects mtime changes via polling; the inotify optimisation lands later.
- PHP's filemtime() is second-resolution; rapid same-second re-saves
  with different content would not register. Adding filesize to the
  fingerprint catches the common workflow case.
- Watcher stub so bin/djot-watch loads cleanly before the full controller
  lands in the next commit; printing a helpful 'not yet implemented'
  message beats a fatal Class-not-found.
Adds Server, router, livereload client, default stylesheet, and the
real Watcher controller (replacing the stub). The Watcher polls the
target file via FileWatcher, bumps SseChannel on change, and the
router's /__sse handler pushes 'reload' events to connected browsers
which then call location.reload().

Notes:
- router.php's SSE loop checks connection_aborted() each iteration so
  it exits as soon as the browser disconnects; otherwise php -S would
  hold the worker for the full 25s long-poll window.
- The browser is launched via OS-aware command (xdg-open/open/start).
- Cross-platform: inotify is not used yet; polling-only is portable
  enough for v1.
- Docs added under docs/reference/cli.md and a README line item.
- Server: redirect php -S stdio to /dev/null. The dev server writes a
  request log line per hit; on a pipe whose reader nobody owns, the
  buffer fills (~64KB) after a few thousand requests and php -S blocks
  on write. Discard the stream since we never surface it anyway.
- FileWatcher: add a content-hash component to the fingerprint. A
  typo fix that preserves file length and lands within the same
  one-second mtime bucket would have been invisible to the prior
  (mtime, size) tuple. xxh32 if available, md5 fallback.
@dereuromark dereuromark added the enhancement New feature or request label May 13, 2026
@codecov
Copy link
Copy Markdown

codecov Bot commented May 13, 2026

Codecov Report

❌ Patch coverage is 15.47170% with 224 lines in your changes missing coverage. Please review.
✅ Project coverage is 91.07%. Comparing base (615c640) to head (072bca3).

Files with missing lines Patch % Lines
src/Watch/Watcher.php 0.00% 109 Missing ⚠️
src/Watch/router.php 0.00% 76 Missing ⚠️
src/Watch/Server.php 0.00% 36 Missing ⚠️
src/Watch/SseChannel.php 83.33% 2 Missing ⚠️
src/Watch/FileWatcher.php 95.45% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##             master     #178      +/-   ##
============================================
- Coverage     93.51%   91.07%   -2.45%     
- Complexity     3242     3301      +59     
============================================
  Files            93       99       +6     
  Lines          8207     8472     +265     
============================================
+ Hits           7675     7716      +41     
- Misses          532      756     +224     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Master's CI has been red since 615c640 with two phpcs sniff failures
on the new anonymous-class spacing rule (PSR12 + Universal). Without
this one-character fix every PR inherits the red status — even though
the violation is unrelated to the change under review.

`new class('heading-ref')` -> `new class ('heading-ref')`.
Two changes to the SSE handler in router.php:

1. Strip all output buffers at entry and force implicit flush. php -S
   tends to hold bytes in PHP's own buffers; with output_buffering on
   the SSE stream surfaces only when the script ends, missing the
   live-reload window entirely. Now every echo is wire-immediate.

2. Send 2 KiB of comment padding before the first event. Chrome and
   its forks defer EventSource parsing until they have ~1 KiB+ of
   body bytes — without padding, the very first `event: reload` is
   often swallowed because the browser hasn't started parsing yet.

Also swapped the post-reload `: tick` for a periodic `: ping` in the
no-change branch. That keeps the connection warm AND gives PHP a
chance to update connection_aborted() each iteration (per docs:
status is only refreshed on write attempts).
Without explicit Cache-Control, Chrome can heuristically cache
/__assets/livereload.js across sessions. After the script is
updated server-side, a returning browser keeps using its cached
copy — listeners that didn't exist in the old version (like
'reload') never fire, and the live-reload appears broken even
though the server is emitting events correctly.

Same fix for the rendered HTML at `/`: live preview regenerates
the body each request, so any cache hit would defeat the loop.

style.css gets the same treatment for consistency, since the
default stylesheet does evolve and a --css override can change
per session.
@dereuromark dereuromark merged commit d9684af into master May 13, 2026
4 of 6 checks passed
@dereuromark dereuromark deleted the feat/djot-watch-cli branch May 13, 2026 22:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant