From fbd493bd2091a976aa78f9c47c90587905170731 Mon Sep 17 00:00:00 2001 From: mscherer Date: Wed, 13 May 2026 12:27:44 +0200 Subject: [PATCH 1/9] Add Renderer wrapping DjotConverter for the watch CLI 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. --- bin/djot-watch | 25 +++++++++++++++++++++ composer.json | 3 ++- src/Watch/Renderer.php | 42 ++++++++++++++++++++++++++++++++++++ tests/Watch/RendererTest.php | 36 +++++++++++++++++++++++++++++++ 4 files changed, 105 insertions(+), 1 deletion(-) create mode 100755 bin/djot-watch create mode 100644 src/Watch/Renderer.php create mode 100644 tests/Watch/RendererTest.php diff --git a/bin/djot-watch b/bin/djot-watch new file mode 100755 index 0000000..91903c6 --- /dev/null +++ b/bin/djot-watch @@ -0,0 +1,25 @@ +#!/usr/bin/env php +run($argv)); diff --git a/composer.json b/composer.json index 90ee498..9f26c94 100644 --- a/composer.json +++ b/composer.json @@ -48,6 +48,7 @@ } }, "bin": [ - "bin/djot" + "bin/djot", + "bin/djot-watch" ] } diff --git a/src/Watch/Renderer.php b/src/Watch/Renderer.php new file mode 100644 index 0000000..a6d84d6 --- /dev/null +++ b/src/Watch/Renderer.php @@ -0,0 +1,42 @@ +converter = $converter ?? new DjotConverter(); + } + + public function render(string $djot): string + { + return $this->converter->convert($djot); + } + + public function renderDocument(string $djot, ?string $cssPath): string + { + $body = $this->render($djot); + + return << + + + +Djot Preview + + + +{$body} + + + +HTML; + } +} diff --git a/tests/Watch/RendererTest.php b/tests/Watch/RendererTest.php new file mode 100644 index 0000000..4cda1e3 --- /dev/null +++ b/tests/Watch/RendererTest.php @@ -0,0 +1,36 @@ +render('# Hello'); + $this->assertStringContainsString('assertStringContainsString('Hello', $html); + } + + public function testRenderDocumentWrapsFragmentWithLayout(): void + { + $renderer = new Renderer(); + $html = $renderer->renderDocument('# Hello', cssPath: null); + $this->assertStringContainsString('', strtolower($html)); + $this->assertStringContainsString('/__assets/livereload.js', $html); + $this->assertStringContainsString('/__assets/style.css', $html); + $this->assertStringContainsString('renderDocument('paragraph', cssPath: null); + $this->assertStringContainsString('Djot Preview', $html); + } +} From 47ce69ae7736655e9d3cd9a9c046977f8bb6d1d7 Mon Sep 17 00:00:00 2001 From: mscherer Date: Wed, 13 May 2026 12:28:15 +0200 Subject: [PATCH 2/9] Add SseChannel and FileWatcher with unit tests 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. --- src/Watch/FileWatcher.php | 35 ++++++++++++++++++++ src/Watch/SseChannel.php | 32 ++++++++++++++++++ tests/Watch/FileWatcherTest.php | 57 +++++++++++++++++++++++++++++++++ tests/Watch/SseChannelTest.php | 50 +++++++++++++++++++++++++++++ 4 files changed, 174 insertions(+) create mode 100644 src/Watch/FileWatcher.php create mode 100644 src/Watch/SseChannel.php create mode 100644 tests/Watch/FileWatcherTest.php create mode 100644 tests/Watch/SseChannelTest.php diff --git a/src/Watch/FileWatcher.php b/src/Watch/FileWatcher.php new file mode 100644 index 0000000..15cd7c6 --- /dev/null +++ b/src/Watch/FileWatcher.php @@ -0,0 +1,35 @@ + */ + private array $mtimes = []; + + /** @param list $paths */ + public function __construct(private readonly array $paths) + { + foreach ($this->paths as $path) { + clearstatcache(true, $path); + $this->mtimes[$path] = file_exists($path) ? (int)filemtime($path) : 0; + } + } + + public function poll(): bool + { + $changed = false; + foreach ($this->paths as $path) { + clearstatcache(true, $path); + $current = file_exists($path) ? (int)filemtime($path) : 0; + if ($current !== $this->mtimes[$path]) { + $this->mtimes[$path] = $current; + $changed = true; + } + } + + return $changed; + } +} diff --git a/src/Watch/SseChannel.php b/src/Watch/SseChannel.php new file mode 100644 index 0000000..ab7d0a8 --- /dev/null +++ b/src/Watch/SseChannel.php @@ -0,0 +1,32 @@ +statePath)) { + file_put_contents($this->statePath, '0'); + } + } + + public function current(): int + { + clearstatcache(true, $this->statePath); + $raw = @file_get_contents($this->statePath); + if ($raw === false) { + return 0; + } + + return (int)trim($raw); + } + + public function bump(): void + { + $next = $this->current() + 1; + file_put_contents($this->statePath, (string)$next, LOCK_EX); + } +} diff --git a/tests/Watch/FileWatcherTest.php b/tests/Watch/FileWatcherTest.php new file mode 100644 index 0000000..a69a609 --- /dev/null +++ b/tests/Watch/FileWatcherTest.php @@ -0,0 +1,57 @@ +makeFile("initial\n"); + $watcher = new FileWatcher([$tmp]); + $this->assertFalse($watcher->poll()); + @unlink($tmp); + } + + public function testDetectsMtimeChange(): void + { + $tmp = $this->makeFile("initial\n"); + $watcher = new FileWatcher([$tmp]); + + // Force mtime forward past second-resolution boundary. + touch($tmp, time() + 2); + clearstatcache(true, $tmp); + + $this->assertTrue($watcher->poll(), 'change detected after touch'); + $this->assertFalse($watcher->poll(), 'no change on subsequent poll'); + + @unlink($tmp); + } + + public function testDetectsChangeOnAnyTrackedFile(): void + { + $a = $this->makeFile("A\n"); + $b = $this->makeFile("B\n"); + $watcher = new FileWatcher([$a, $b]); + + touch($b, time() + 2); + clearstatcache(true, $b); + $this->assertTrue($watcher->poll()); + + @unlink($a); + @unlink($b); + } + + private function makeFile(string $content): string + { + $path = tempnam(sys_get_temp_dir(), 'fw_test_'); + self::assertNotFalse($path); + file_put_contents($path, $content); + + return $path; + } +} diff --git a/tests/Watch/SseChannelTest.php b/tests/Watch/SseChannelTest.php new file mode 100644 index 0000000..9c5ca59 --- /dev/null +++ b/tests/Watch/SseChannelTest.php @@ -0,0 +1,50 @@ +path = $tmp; + } + + protected function tearDown(): void + { + @unlink($this->path); + } + + public function testInitialSequenceIsZero(): void + { + $channel = new SseChannel($this->path); + $this->assertSame(0, $channel->current()); + } + + public function testBumpIncrementsSequence(): void + { + $channel = new SseChannel($this->path); + $channel->bump(); + $this->assertSame(1, $channel->current()); + $channel->bump(); + $this->assertSame(2, $channel->current()); + } + + public function testCurrentIsReadableFromAnotherInstance(): void + { + $producer = new SseChannel($this->path); + $producer->bump(); + $producer->bump(); + + $consumer = new SseChannel($this->path); + $this->assertSame(2, $consumer->current()); + } +} From 34efcdfee961600152824b63a7f08930d069fb96 Mon Sep 17 00:00:00 2001 From: mscherer Date: Wed, 13 May 2026 12:29:51 +0200 Subject: [PATCH 3/9] Track (mtime, size) in FileWatcher; add Watcher stub - 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. --- src/Watch/FileWatcher.php | 35 +++++++++++++++++++++++++-------- src/Watch/Watcher.php | 17 ++++++++++++++++ tests/Watch/FileWatcherTest.php | 11 +++++++++++ 3 files changed, 55 insertions(+), 8 deletions(-) create mode 100644 src/Watch/Watcher.php diff --git a/src/Watch/FileWatcher.php b/src/Watch/FileWatcher.php index 15cd7c6..1e366cd 100644 --- a/src/Watch/FileWatcher.php +++ b/src/Watch/FileWatcher.php @@ -6,15 +6,21 @@ class FileWatcher { - /** @var array */ - private array $mtimes = []; + /** + * Fingerprint per tracked path. PHP's filemtime() is only second-resolution, + * so two saves within the same second look identical via mtime alone. + * Combining mtime with filesize catches the common case where the buffer's + * byte count differs after a quick re-save. + * + * @var array + */ + private array $fingerprints = []; /** @param list $paths */ public function __construct(private readonly array $paths) { foreach ($this->paths as $path) { - clearstatcache(true, $path); - $this->mtimes[$path] = file_exists($path) ? (int)filemtime($path) : 0; + $this->fingerprints[$path] = $this->fingerprint($path); } } @@ -22,14 +28,27 @@ public function poll(): bool { $changed = false; foreach ($this->paths as $path) { - clearstatcache(true, $path); - $current = file_exists($path) ? (int)filemtime($path) : 0; - if ($current !== $this->mtimes[$path]) { - $this->mtimes[$path] = $current; + $current = $this->fingerprint($path); + if ($current !== $this->fingerprints[$path]) { + $this->fingerprints[$path] = $current; $changed = true; } } return $changed; } + + /** @return array{mtime: int, size: int} */ + private function fingerprint(string $path): array + { + clearstatcache(true, $path); + if (!file_exists($path)) { + return ['mtime' => 0, 'size' => 0]; + } + + return [ + 'mtime' => (int)filemtime($path), + 'size' => (int)filesize($path), + ]; + } } diff --git a/src/Watch/Watcher.php b/src/Watch/Watcher.php new file mode 100644 index 0000000..ac5de00 --- /dev/null +++ b/src/Watch/Watcher.php @@ -0,0 +1,17 @@ + $argv */ + public function run(array $argv): int + { + unset($argv); + fwrite(STDERR, "djot-watch is not yet implemented in this build.\n"); + + return 70; + } +} diff --git a/tests/Watch/FileWatcherTest.php b/tests/Watch/FileWatcherTest.php index a69a609..786b484 100644 --- a/tests/Watch/FileWatcherTest.php +++ b/tests/Watch/FileWatcherTest.php @@ -46,6 +46,17 @@ public function testDetectsChangeOnAnyTrackedFile(): void @unlink($b); } + public function testDetectsSameSecondReplaceViaSizeChange(): void + { + $tmp = $this->makeFile("first\n"); + $watcher = new FileWatcher([$tmp]); + // Write different-length content without advancing mtime past one-second resolution. + file_put_contents($tmp, "longer second content\n"); + clearstatcache(true, $tmp); + $this->assertTrue($watcher->poll()); + @unlink($tmp); + } + private function makeFile(string $content): string { $path = tempnam(sys_get_temp_dir(), 'fw_test_'); From d701b212cb97835ffb9d54c04d56b4450feddb26 Mon Sep 17 00:00:00 2001 From: mscherer Date: Wed, 13 May 2026 12:54:54 +0200 Subject: [PATCH 4/9] Wire up the full djot-watch live-preview server 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. --- README.md | 1 + docs/reference/cli.md | 48 +++++++ src/Watch/FileWatcher.php | 8 +- src/Watch/Server.php | 84 ++++++++++++ src/Watch/Watcher.php | 223 +++++++++++++++++++++++++++++++- src/Watch/assets/livereload.js | 13 ++ src/Watch/assets/style.css | 92 +++++++++++++ src/Watch/router.php | 113 ++++++++++++++++ tests/Watch/IntegrationTest.php | 137 ++++++++++++++++++++ 9 files changed, 713 insertions(+), 6 deletions(-) create mode 100644 src/Watch/Server.php create mode 100644 src/Watch/assets/livereload.js create mode 100644 src/Watch/assets/style.css create mode 100644 src/Watch/router.php create mode 100644 tests/Watch/IntegrationTest.php diff --git a/README.md b/README.md index 85da8ba..9d06d78 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ $html = $converter->convert('Hello *world*!'); - **Extensions**: Built-in extensions for external links, TOC, heading permalinks, @mentions, autolinks, default attributes - **Extensible**: Custom inline/block patterns, render events - **File support**: Parse and convert files directly +- **CLI tools**: `bin/djot` (one-shot convert) and `bin/djot-watch` (live-reload preview server) — see [CLI reference](https://php-collective.github.io/djot-php/reference/cli) ## Example diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 77d3c3e..b03361c 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -121,3 +121,51 @@ php -S localhost:8000 -t public | 1 | General error | | 2 | File not found | | 3 | Invalid format | + +## Live Preview: `djot-watch` + +A companion long-running command that serves a live-reloading HTML preview of a `.djot` file in your browser. Useful while drafting in any editor (Vim, Helix, VS Code, Zed, Sublime — anything). + +### Usage + +```bash +./vendor/bin/djot-watch path/to/file.djot +``` + +This starts a local HTTP server on `http://127.0.0.1:8765/`, opens the URL in your default browser, and re-renders the file on every save. The browser tab refreshes automatically via Server-Sent Events. + +Press `Ctrl+C` to stop. + +### Flags + +| Flag | Description | +|------|-------------| +| `-p`, `--port PORT` | HTTP port (default `8765`; auto-bumps up to `+10` if taken). | +| `--host HOST` | Bind host (default `127.0.0.1`). | +| `--no-open` | Do not launch the browser on startup. | +| `--css FILE` | Path to a custom CSS file served at `/__assets/style.css`. | +| `-v`, `--version` | Print version. | +| `-h`, `--help` | Print help text. | + +### Custom Styling + +The watcher ships a minimal default stylesheet (system fonts, sensible spacing, dark-mode aware). Override with `--css`: + +```bash +./vendor/bin/djot-watch post.djot --css ./my-preview.css +``` + +### Editor Integration + +The watcher is editor-agnostic — it just watches the file you give it. Bind a key or task in your editor to run `./vendor/bin/djot-watch ${FILE}` so you can fire up the preview without leaving the editor. For Zed, see the [zed-djot extension README](https://github.com/php-collective/zed-djot) for a `tasks.json` snippet. + +### How It Works + +`djot-watch` boots a long-lived PHP process that: + +1. Renders your `.djot` file via the same `DjotConverter` used by `bin/djot`. +2. Spawns `php -S` on the chosen port with a small router script. +3. Polls the file for `(mtime, size)` changes every 250 ms; pushes a Server-Sent Events `reload` event when something changes. +4. Injects a tiny JS client into the served HTML that reloads on the SSE event. + +No daemon, no config file, no global state. Just the binary and your file. diff --git a/src/Watch/FileWatcher.php b/src/Watch/FileWatcher.php index 1e366cd..cdf0067 100644 --- a/src/Watch/FileWatcher.php +++ b/src/Watch/FileWatcher.php @@ -16,7 +16,9 @@ class FileWatcher */ private array $fingerprints = []; - /** @param list $paths */ + /** + * @param list $paths + */ public function __construct(private readonly array $paths) { foreach ($this->paths as $path) { @@ -38,7 +40,9 @@ public function poll(): bool return $changed; } - /** @return array{mtime: int, size: int} */ + /** + * @return array{mtime: int, size: int} + */ private function fingerprint(string $path): array { clearstatcache(true, $path); diff --git a/src/Watch/Server.php b/src/Watch/Server.php new file mode 100644 index 0000000..ea83776 --- /dev/null +++ b/src/Watch/Server.php @@ -0,0 +1,84 @@ + $env + * + * @throws \RuntimeException + * + * @return int the port actually used + */ + public function start(string $host, int $port, string $routerPath, array $env): int + { + $actualPort = $this->pickPort($host, $port); + $docroot = dirname($routerPath); + $cmd = sprintf( + 'exec %s -S %s:%d -t %s %s', + escapeshellarg(PHP_BINARY), + escapeshellarg($host), + $actualPort, + escapeshellarg($docroot), + escapeshellarg($routerPath), + ); + + $descriptors = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + + $envForProc = array_merge($_ENV, $env); + $proc = proc_open($cmd, $descriptors, $pipes, null, $envForProc); + if (!is_resource($proc)) { + throw new RuntimeException('Failed to start php -S'); + } + $this->process = $proc; + + return $actualPort; + } + + public function stop(): void + { + if (!is_resource($this->process)) { + return; + } + $status = proc_get_status($this->process); + if ($status['running']) { + proc_terminate($this->process, defined('SIGTERM') ? SIGTERM : 15); + } + proc_close($this->process); + $this->process = null; + } + + private function pickPort(string $host, int $port): int + { + for ($p = $port; $p < $port + 10; $p++) { + $sock = @stream_socket_server("tcp://{$host}:{$p}", $errno, $errstr); + if ($sock !== false) { + fclose($sock); + + return $p; + } + } + + throw new RuntimeException(sprintf('No free port found in range %d-%d', $port, $port + 9)); + } +} diff --git a/src/Watch/Watcher.php b/src/Watch/Watcher.php index ac5de00..a3c8dd6 100644 --- a/src/Watch/Watcher.php +++ b/src/Watch/Watcher.php @@ -4,14 +4,229 @@ namespace Djot\Watch; +use RuntimeException; + +/** + * Long-running CLI controller for the djot-watch live-preview server. + * + * Parses argv, spawns the HTTP server via `php -S`, polls the target file for + * mtime/size changes, and bumps a shared SSE channel each time so connected + * browser clients reload. + */ class Watcher { - /** @param list $argv */ + /** + * @var string + */ + public const DEFAULT_HOST = '127.0.0.1'; + + /** + * @var int + */ + public const DEFAULT_PORT = 8765; + + /** + * @var int + */ + private const POLL_INTERVAL_US = 250_000; + + /** + * @param list $argv + */ public function run(array $argv): int { - unset($argv); - fwrite(STDERR, "djot-watch is not yet implemented in this build.\n"); + try { + $opts = $this->parseArgs($argv); + } catch (RuntimeException $e) { + fwrite(STDERR, 'Error: ' . $e->getMessage() . "\n"); + + return 64; + } + + if ($opts['help']) { + $this->printHelp(); + + return 0; + } + if ($opts['version']) { + echo "djot-watch 0.1.0\n"; + + return 0; + } + if ($opts['target'] === null) { + fwrite(STDERR, "Error: no target file given. Try `djot-watch --help`.\n"); + + return 64; + } + if (!is_file($opts['target'])) { + fwrite(STDERR, "Error: target file does not exist: {$opts['target']}\n"); + + return 66; + } + + $statePath = (string)tempnam(sys_get_temp_dir(), 'djot_watch_'); + if ($statePath === '') { + fwrite(STDERR, "Error: could not create state file\n"); + + return 70; + } + + $channel = new SseChannel($statePath); + $fileWatcher = new FileWatcher([$opts['target']]); + $server = new Server(); + + $port = $server->start( + $opts['host'], + $opts['port'], + __DIR__ . '/router.php', + [ + 'DJOT_WATCH_TARGET' => $opts['target'], + 'DJOT_WATCH_STATE' => $statePath, + 'DJOT_WATCH_CSS' => $opts['css'] ?? '', + ], + ); + + $url = "http://{$opts['host']}:{$port}/"; + fwrite(STDOUT, "djot-watch serving {$opts['target']} at {$url}\n"); + fwrite(STDOUT, "Press Ctrl+C to stop.\n"); + + if (!$opts['no_open']) { + $this->openBrowser($url); + } + + $stopping = false; + if (function_exists('pcntl_async_signals')) { + pcntl_async_signals(true); + $handler = static function () use (&$stopping): void { + $stopping = true; + }; + pcntl_signal(SIGINT, $handler); + pcntl_signal(SIGTERM, $handler); + } + + try { + while (!$stopping) { + if ($fileWatcher->poll()) { + $channel->bump(); + fwrite(STDOUT, 'reload (' . $channel->current() . ")\n"); + } + usleep(self::POLL_INTERVAL_US); + } + } finally { + $server->stop(); + @unlink($statePath); + } + + return 0; + } + + /** + * @param list $argv + * + * @throws \RuntimeException + * + * @return array{ + * target: string|null, + * host: string, + * port: int, + * css: string|null, + * no_open: bool, + * help: bool, + * version: bool, + * } + */ + private function parseArgs(array $argv): array + { + $opts = [ + 'target' => null, + 'host' => self::DEFAULT_HOST, + 'port' => self::DEFAULT_PORT, + 'css' => null, + 'no_open' => false, + 'help' => false, + 'version' => false, + ]; + + $n = count($argv); + for ($i = 1; $i < $n; $i++) { + $arg = $argv[$i]; + switch ($arg) { + case '-h': + case '--help': + $opts['help'] = true; + + break; + case '-v': + case '--version': + $opts['version'] = true; + + break; + case '--no-open': + $opts['no_open'] = true; + + break; + case '-p': + case '--port': + if (!isset($argv[$i + 1])) { + throw new RuntimeException($arg . ' requires a value'); + } + $opts['port'] = (int)$argv[++$i]; + + break; + case '--host': + if (!isset($argv[$i + 1])) { + throw new RuntimeException($arg . ' requires a value'); + } + $opts['host'] = $argv[++$i]; + + break; + case '--css': + if (!isset($argv[$i + 1])) { + throw new RuntimeException($arg . ' requires a value'); + } + $opts['css'] = $argv[++$i]; + + break; + default: + if (str_starts_with($arg, '-')) { + throw new RuntimeException("Unknown option: {$arg}"); + } + if ($opts['target'] !== null) { + throw new RuntimeException("Only one target file supported (got '{$arg}' after '{$opts['target']}')"); + } + $opts['target'] = $arg; + } + } + + return $opts; + } + + private function openBrowser(string $url): void + { + $cmd = match (PHP_OS_FAMILY) { + 'Darwin' => 'open', + 'Windows' => 'start ""', + default => 'xdg-open', + }; + @exec(sprintf('%s %s >/dev/null 2>&1 &', $cmd, escapeshellarg($url))); + } + + private function printHelp(): void + { + echo << + +Options: + -p, --port PORT HTTP port (default 8765; auto-bumps up to +10) + --host HOST Bind host (default 127.0.0.1) + --no-open Do not open browser on startup + --css FILE Custom CSS file served at /__assets/style.css + -v, --version Print version + -h, --help Print this help - return 70; +HELP; } } diff --git a/src/Watch/assets/livereload.js b/src/Watch/assets/livereload.js new file mode 100644 index 0000000..43da52b --- /dev/null +++ b/src/Watch/assets/livereload.js @@ -0,0 +1,13 @@ +(function () { + function connect() { + var es = new EventSource('/__sse'); + es.addEventListener('reload', function () { + window.location.reload(); + }); + es.onerror = function () { + es.close(); + setTimeout(connect, 1000); + }; + } + connect(); +})(); diff --git a/src/Watch/assets/style.css b/src/Watch/assets/style.css new file mode 100644 index 0000000..45070e5 --- /dev/null +++ b/src/Watch/assets/style.css @@ -0,0 +1,92 @@ +:root { + color-scheme: light dark; + --fg: #1a1a1a; + --bg: #fdfdfd; + --muted: #666; + --link: #0366d6; + --code-bg: #f6f8fa; + --border: #d0d7de; +} + +@media (prefers-color-scheme: dark) { + :root { + --fg: #e6e6e6; + --bg: #1a1a1a; + --muted: #aaa; + --link: #58a6ff; + --code-bg: #161b22; + --border: #30363d; + } +} + +html, +body { + background: var(--bg); + color: var(--fg); +} + +body { + font: 16px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + max-width: 760px; + margin: 2rem auto; + padding: 0 1rem; +} + +h1, h2, h3, h4, h5, h6 { + line-height: 1.25; + margin-top: 1.5em; +} + +h1 { + border-bottom: 1px solid var(--border); + padding-bottom: .3em; +} + +a { + color: var(--link); +} + +pre, +code { + font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; +} + +pre { + background: var(--code-bg); + padding: 1em; + border-radius: 6px; + overflow-x: auto; +} + +code { + background: var(--code-bg); + padding: .15em .35em; + border-radius: 3px; + font-size: 90%; +} + +pre code { + background: none; + padding: 0; +} + +blockquote { + border-left: 4px solid var(--border); + margin: 0; + padding: 0 1em; + color: var(--muted); +} + +table { + border-collapse: collapse; +} + +th, td { + border: 1px solid var(--border); + padding: .4em .8em; +} + +hr { + border: 0; + border-top: 1px solid var(--border); +} diff --git a/src/Watch/router.php b/src/Watch/router.php new file mode 100644 index 0000000..ed1782d --- /dev/null +++ b/src/Watch/router.php @@ -0,0 +1,113 @@ +current(); + echo ": connected\n\n"; + @ob_flush(); + @flush(); + if (connection_aborted()) { + return true; + } + + // Hold the connection for ~25s, then the browser reconnects automatically. + $deadline = time() + 25; + while (time() < $deadline) { + if (connection_aborted()) { + return true; + } + $now = $channel->current(); + if ($now !== $last) { + echo "event: reload\ndata: {$now}\n\n"; + @ob_flush(); + @flush(); + $last = $now; + // Send a comment after the reload so write-detection of aborts + // fires on the next iteration if the client has disconnected. + echo ": tick\n\n"; + @ob_flush(); + @flush(); + } + usleep(100_000); + } + + return true; +} + +if ($path === '/' || $path === '/index.html') { + if (!is_file($target)) { + http_response_code(404); + echo "djot-watch: target file '{$target}' not found."; + + return true; + } + $djot = (string)file_get_contents($target); + $renderer = new Renderer(); + echo $renderer->renderDocument($djot, cssPath: $cssOverride !== '' ? $cssOverride : null); + + return true; +} + +http_response_code(404); +echo 'Not found'; + +return true; diff --git a/tests/Watch/IntegrationTest.php b/tests/Watch/IntegrationTest.php new file mode 100644 index 0000000..b14806c --- /dev/null +++ b/tests/Watch/IntegrationTest.php @@ -0,0 +1,137 @@ +markTestSkipped('pcntl extension not available'); + } + + $port = $this->findFreePort(); + $target = tempnam(sys_get_temp_dir(), 'djot_integ_') . '.djot'; + file_put_contents($target, "# Initial\n"); + + $bin = realpath(__DIR__ . '/../../bin/djot-watch'); + self::assertNotFalse($bin); + $cmd = sprintf( + '%s %s --port %d --no-open %s', + escapeshellarg(PHP_BINARY), + escapeshellarg($bin), + $port, + escapeshellarg($target), + ); + + // Redirect child stdio to /dev/null so its periodic stdout writes + // (e.g. "reload (N)") don't block on a full pipe buffer. + $descriptors = [ + 0 => ['file', '/dev/null', 'r'], + 1 => ['file', '/dev/null', 'w'], + 2 => ['file', '/dev/null', 'w'], + ]; + $proc = proc_open($cmd, $descriptors, $pipes); + self::assertIsResource($proc); + + try { + $url = "http://127.0.0.1:{$port}/"; + self::assertTrue($this->waitForServer($url), "Server did not respond at {$url}"); + + // Trigger a change: write different-length content and advance mtime. + touch($target, time() + 2); + file_put_contents($target, "# Updated content here\n"); + clearstatcache(true, $target); + + self::assertTrue( + $this->waitForReload("http://127.0.0.1:{$port}/__sse", 5), + 'No SSE reload event received within 5 seconds', + ); + } finally { + proc_terminate($proc, defined('SIGTERM') ? SIGTERM : 15); + // Give the watcher a moment to flush its finally block before reaping. + $deadline = microtime(true) + 2.0; + while (microtime(true) < $deadline) { + $status = proc_get_status($proc); + if (!$status['running']) { + break; + } + usleep(50_000); + } + proc_close($proc); + @unlink($target); + } + } + + private function findFreePort(): int + { + $sock = stream_socket_server('tcp://127.0.0.1:0', $errno, $errstr); + self::assertIsResource($sock); + $name = stream_socket_get_name($sock, false); + self::assertNotFalse($name); + fclose($sock); + $portStr = substr($name, (int)strrpos($name, ':') + 1); + + return (int)$portStr; + } + + private function waitForServer(string $url): bool + { + $ctx = stream_context_create(['http' => ['timeout' => 1, 'ignore_errors' => true]]); + for ($i = 0; $i < 50; $i++) { + usleep(100_000); + $body = @file_get_contents($url, false, $ctx); + if (is_string($body) && str_contains($body, ' ['timeout' => $timeoutSeconds]]); + $stream = @fopen($sseUrl, 'r', false, $ctx); + if (!is_resource($stream)) { + return false; + } + stream_set_timeout($stream, $timeoutSeconds); + + $deadline = microtime(true) + $timeoutSeconds; + $buffer = ''; + try { + while (microtime(true) < $deadline) { + $chunk = fread($stream, 256); + if ($chunk === false || $chunk === '') { + $info = stream_get_meta_data($stream); + if ($info['timed_out']) { + return false; + } + usleep(50_000); + + continue; + } + $buffer .= $chunk; + if (str_contains($buffer, 'event: reload')) { + return true; + } + } + + return false; + } finally { + fclose($stream); + } + } +} From d87b2aaa2c07199fa9017a733512cd647f003d12 Mon Sep 17 00:00:00 2001 From: mscherer Date: Wed, 13 May 2026 12:56:37 +0200 Subject: [PATCH 5/9] Address codex P1+P2 on the watcher - 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. --- src/Watch/FileWatcher.php | 23 +++++++++++++++-------- src/Watch/Server.php | 10 +++++++--- tests/Watch/FileWatcherTest.php | 13 +++++++++++++ 3 files changed, 35 insertions(+), 11 deletions(-) diff --git a/src/Watch/FileWatcher.php b/src/Watch/FileWatcher.php index cdf0067..f0fc035 100644 --- a/src/Watch/FileWatcher.php +++ b/src/Watch/FileWatcher.php @@ -7,12 +7,16 @@ class FileWatcher { /** - * Fingerprint per tracked path. PHP's filemtime() is only second-resolution, - * so two saves within the same second look identical via mtime alone. - * Combining mtime with filesize catches the common case where the buffer's - * byte count differs after a quick re-save. + * Fingerprint per tracked path: (mtime, size, hash). * - * @var array + * PHP's filemtime() is second-resolution, so a same-second edit that + * preserves file length (e.g. a typo fix replacing 3 chars with 3 chars) + * would look identical via (mtime, size) alone. The content hash catches + * those cases. xxhash32 if available (fast, ~3 GB/s), md5 fallback — + * cryptographic strength is irrelevant; we only need collision avoidance + * across the file's lifetime in this process. + * + * @var array */ private array $fingerprints = []; @@ -41,18 +45,21 @@ public function poll(): bool } /** - * @return array{mtime: int, size: int} + * @return array{mtime: int, size: int, hash: string} */ private function fingerprint(string $path): array { clearstatcache(true, $path); - if (!file_exists($path)) { - return ['mtime' => 0, 'size' => 0]; + if (!is_file($path)) { + return ['mtime' => 0, 'size' => 0, 'hash' => '']; } + $algo = in_array('xxh32', hash_algos(), true) ? 'xxh32' : 'md5'; + $hash = @hash_file($algo, $path); return [ 'mtime' => (int)filemtime($path), 'size' => (int)filesize($path), + 'hash' => is_string($hash) ? $hash : '', ]; } } diff --git a/src/Watch/Server.php b/src/Watch/Server.php index ea83776..84906c2 100644 --- a/src/Watch/Server.php +++ b/src/Watch/Server.php @@ -39,10 +39,14 @@ public function start(string $host, int $port, string $routerPath, array $env): escapeshellarg($routerPath), ); + // Discard php -S stdio. The dev server's request log otherwise fills + // the pipe buffer (a few thousand requests) and the worker blocks on + // write, making the server appear hung. We don't surface those logs + // anywhere; if they're ever wanted, swap '/dev/null' for a log file. $descriptors = [ - 0 => ['pipe', 'r'], - 1 => ['pipe', 'w'], - 2 => ['pipe', 'w'], + 0 => ['file', '/dev/null', 'r'], + 1 => ['file', '/dev/null', 'w'], + 2 => ['file', '/dev/null', 'w'], ]; $envForProc = array_merge($_ENV, $env); diff --git a/tests/Watch/FileWatcherTest.php b/tests/Watch/FileWatcherTest.php index 786b484..41735e6 100644 --- a/tests/Watch/FileWatcherTest.php +++ b/tests/Watch/FileWatcherTest.php @@ -57,6 +57,19 @@ public function testDetectsSameSecondReplaceViaSizeChange(): void @unlink($tmp); } + public function testDetectsSameSecondSameSizeReplaceViaContentHash(): void + { + // A typo fix that preserves file length and lands in the same + // one-second filemtime() bucket would be invisible to a (mtime, size) + // fingerprint. The content-hash component catches it. + $tmp = $this->makeFile("teh quick brown\n"); + $watcher = new FileWatcher([$tmp]); + file_put_contents($tmp, "the quick brown\n"); + clearstatcache(true, $tmp); + $this->assertTrue($watcher->poll()); + @unlink($tmp); + } + private function makeFile(string $content): string { $path = tempnam(sys_get_temp_dir(), 'fw_test_'); From 2ec4970e241dfe52b53882708d5fd09c979bfc16 Mon Sep 17 00:00:00 2001 From: mscherer Date: Wed, 13 May 2026 18:04:07 +0200 Subject: [PATCH 6/9] Drive-by: fix anon-class spacing in HeadingReferenceExtensionTest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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')`. --- tests/TestCase/Extension/HeadingReferenceExtensionTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/TestCase/Extension/HeadingReferenceExtensionTest.php b/tests/TestCase/Extension/HeadingReferenceExtensionTest.php index ef5ec00..15cfcc6 100644 --- a/tests/TestCase/Extension/HeadingReferenceExtensionTest.php +++ b/tests/TestCase/Extension/HeadingReferenceExtensionTest.php @@ -251,7 +251,7 @@ public function testHeadingWithNoTextIsIgnored(): void public function testUserAuthoredLinkWithMatchingPlaceholderIsNotRewritten(): void { - $extension = new class('heading-ref') extends HeadingReferenceExtension { + $extension = new class ('heading-ref') extends HeadingReferenceExtension { protected function generatePlaceholderPrefix(): string { return 'collision-placeholder-'; From c3e6d00c73af9b110f7c55ddfb990f3800c4fdf4 Mon Sep 17 00:00:00 2001 From: mscherer Date: Wed, 13 May 2026 19:54:25 +0200 Subject: [PATCH 7/9] Reliable browser-side livereload: disable buffering + SSE preamble MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- src/Watch/router.php | 35 ++++++++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/src/Watch/router.php b/src/Watch/router.php index ed1782d..123cefb 100644 --- a/src/Watch/router.php +++ b/src/Watch/router.php @@ -53,6 +53,18 @@ } if ($path === '/__sse') { + // Tear down any output buffering and disable PHP's auto-buffers so + // each echo flushes to the wire immediately. Without this, php -S + // tends to hold bytes until the script ends and the browser sees the + // SSE stream as a single delayed blob rather than a live event feed. + while (ob_get_level() > 0) { + @ob_end_clean(); + } + @ini_set('zlib.output_compression', '0'); + @ini_set('output_buffering', '0'); + @ini_set('implicit_flush', '1'); + ob_implicit_flush(true); + header('Content-Type: text/event-stream'); header('Cache-Control: no-cache'); header('X-Accel-Buffering: no'); @@ -62,9 +74,14 @@ $channel = new SseChannel($statePath); $last = $channel->current(); + + // 2 KiB of comment padding upfront. Some browsers (notably Chrome and + // its forks) defer EventSource parsing until they have ~1 KiB+ of + // body bytes; without this the first real event takes that long to + // surface, and a short-lived `event: reload` can be missed entirely. + echo ': ' . str_repeat('x', 2048) . "\n\n"; echo ": connected\n\n"; - @ob_flush(); - @flush(); + flush(); if (connection_aborted()) { return true; } @@ -78,14 +95,14 @@ $now = $channel->current(); if ($now !== $last) { echo "event: reload\ndata: {$now}\n\n"; - @ob_flush(); - @flush(); + flush(); $last = $now; - // Send a comment after the reload so write-detection of aborts - // fires on the next iteration if the client has disconnected. - echo ": tick\n\n"; - @ob_flush(); - @flush(); + } else { + // Periodic comment keeps the connection warm AND gives PHP a + // chance to notice client disconnects (connection_aborted() is + // only updated on write attempts). + echo ": ping\n\n"; + flush(); } usleep(100_000); } From 95029969ce8e9176e2e32bb422fda2a0b8523e83 Mon Sep 17 00:00:00 2001 From: mscherer Date: Wed, 13 May 2026 20:03:17 +0200 Subject: [PATCH 8/9] Add diagnostic console logging to livereload.js --- src/Watch/assets/livereload.js | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/Watch/assets/livereload.js b/src/Watch/assets/livereload.js index 43da52b..c65bc78 100644 --- a/src/Watch/assets/livereload.js +++ b/src/Watch/assets/livereload.js @@ -1,10 +1,27 @@ (function () { + var label = '[djot-watch]'; function connect() { + console.log(label, 'opening EventSource /__sse'); var es = new EventSource('/__sse'); - es.addEventListener('reload', function () { + es.addEventListener('open', function () { + console.log(label, 'connected, readyState=' + es.readyState); + }); + // Named server event from router.php. + es.addEventListener('reload', function (ev) { + console.log(label, 'reload event received', ev.data); window.location.reload(); }); - es.onerror = function () { + // Fallback: also reload on any message event (defensive — if the + // server ever emits a bare `data:` line without `event: reload`, + // most browsers route it to 'message'). + es.addEventListener('message', function (ev) { + console.log(label, 'message event', ev.data); + if (ev.data && ev.data.indexOf('reload') !== -1) { + window.location.reload(); + } + }); + es.onerror = function (ev) { + console.log(label, 'error, readyState=' + es.readyState + ', reconnecting in 1s'); es.close(); setTimeout(connect, 1000); }; From 072bca3e627f61c107dc5a2a4cd659412c6e761b Mon Sep 17 00:00:00 2001 From: mscherer Date: Wed, 13 May 2026 22:03:18 +0200 Subject: [PATCH 9/9] Disable browser caching for HTML + livereload assets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/Watch/router.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/Watch/router.php b/src/Watch/router.php index 123cefb..a38912c 100644 --- a/src/Watch/router.php +++ b/src/Watch/router.php @@ -36,6 +36,12 @@ if ($path === '/__assets/livereload.js') { header('Content-Type: application/javascript; charset=utf-8'); + // Never cache. Live-preview iterates fast and a stale livereload + // client (missing a new event listener, for example) leaves the user + // wondering why nothing reloads. + header('Cache-Control: no-cache, no-store, must-revalidate'); + header('Pragma: no-cache'); + header('Expires: 0'); readfile($assetsDir . '/livereload.js'); return true; @@ -43,6 +49,7 @@ if ($path === '/__assets/style.css') { header('Content-Type: text/css; charset=utf-8'); + header('Cache-Control: no-cache, no-store, must-revalidate'); if ($cssOverride !== '' && is_file($cssOverride)) { readfile($cssOverride); } else { @@ -111,6 +118,11 @@ } if ($path === '/' || $path === '/index.html') { + // The HTML is regenerated from the current djot every request; caching + // would defeat the live-preview loop. + header('Cache-Control: no-cache, no-store, must-revalidate'); + header('Pragma: no-cache'); + header('Expires: 0'); if (!is_file($target)) { http_response_code(404); echo "djot-watch: target file '{$target}' not found.";