Skip to content

Commit d9684af

Browse files
authored
Add bin/djot-watch live-preview CLI (#178)
* 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. * 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. * 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. * 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. * 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. * Drive-by: fix anon-class spacing in HeadingReferenceExtensionTest 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')`. * Reliable browser-side livereload: disable buffering + SSE preamble 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). * Add diagnostic console logging to livereload.js * Disable browser caching for HTML + livereload assets 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.
1 parent a690b48 commit d9684af

16 files changed

Lines changed: 1103 additions & 1 deletion

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ $html = $converter->convert('Hello *world*!');
3636
- **Extensions**: Built-in extensions for external links, TOC, heading permalinks, @mentions, autolinks, default attributes
3737
- **Extensible**: Custom inline/block patterns, render events
3838
- **File support**: Parse and convert files directly
39+
- **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)
3940

4041
## Example
4142

bin/djot-watch

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
#!/usr/bin/env php
2+
<?php
3+
4+
declare(strict_types=1);
5+
6+
$autoloadPaths = [
7+
__DIR__ . '/../vendor/autoload.php',
8+
__DIR__ . '/../../../autoload.php',
9+
];
10+
11+
$autoloaderFound = false;
12+
foreach ($autoloadPaths as $autoloadPath) {
13+
if (file_exists($autoloadPath)) {
14+
require $autoloadPath;
15+
$autoloaderFound = true;
16+
break;
17+
}
18+
}
19+
20+
if (!$autoloaderFound) {
21+
fwrite(STDERR, "Error: Could not find Composer autoloader.\n");
22+
exit(70);
23+
}
24+
25+
exit((new Djot\Watch\Watcher())->run($argv));

composer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
}
4949
},
5050
"bin": [
51-
"bin/djot"
51+
"bin/djot",
52+
"bin/djot-watch"
5253
]
5354
}

docs/reference/cli.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,3 +121,51 @@ php -S localhost:8000 -t public
121121
| 1 | General error |
122122
| 2 | File not found |
123123
| 3 | Invalid format |
124+
125+
## Live Preview: `djot-watch`
126+
127+
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).
128+
129+
### Usage
130+
131+
```bash
132+
./vendor/bin/djot-watch path/to/file.djot
133+
```
134+
135+
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.
136+
137+
Press `Ctrl+C` to stop.
138+
139+
### Flags
140+
141+
| Flag | Description |
142+
|------|-------------|
143+
| `-p`, `--port PORT` | HTTP port (default `8765`; auto-bumps up to `+10` if taken). |
144+
| `--host HOST` | Bind host (default `127.0.0.1`). |
145+
| `--no-open` | Do not launch the browser on startup. |
146+
| `--css FILE` | Path to a custom CSS file served at `/__assets/style.css`. |
147+
| `-v`, `--version` | Print version. |
148+
| `-h`, `--help` | Print help text. |
149+
150+
### Custom Styling
151+
152+
The watcher ships a minimal default stylesheet (system fonts, sensible spacing, dark-mode aware). Override with `--css`:
153+
154+
```bash
155+
./vendor/bin/djot-watch post.djot --css ./my-preview.css
156+
```
157+
158+
### Editor Integration
159+
160+
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.
161+
162+
### How It Works
163+
164+
`djot-watch` boots a long-lived PHP process that:
165+
166+
1. Renders your `.djot` file via the same `DjotConverter` used by `bin/djot`.
167+
2. Spawns `php -S` on the chosen port with a small router script.
168+
3. Polls the file for `(mtime, size)` changes every 250 ms; pushes a Server-Sent Events `reload` event when something changes.
169+
4. Injects a tiny JS client into the served HTML that reloads on the SSE event.
170+
171+
No daemon, no config file, no global state. Just the binary and your file.

src/Watch/FileWatcher.php

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Djot\Watch;
6+
7+
class FileWatcher
8+
{
9+
/**
10+
* Fingerprint per tracked path: (mtime, size, hash).
11+
*
12+
* PHP's filemtime() is second-resolution, so a same-second edit that
13+
* preserves file length (e.g. a typo fix replacing 3 chars with 3 chars)
14+
* would look identical via (mtime, size) alone. The content hash catches
15+
* those cases. xxhash32 if available (fast, ~3 GB/s), md5 fallback —
16+
* cryptographic strength is irrelevant; we only need collision avoidance
17+
* across the file's lifetime in this process.
18+
*
19+
* @var array<string, array{mtime: int, size: int, hash: string}>
20+
*/
21+
private array $fingerprints = [];
22+
23+
/**
24+
* @param list<string> $paths
25+
*/
26+
public function __construct(private readonly array $paths)
27+
{
28+
foreach ($this->paths as $path) {
29+
$this->fingerprints[$path] = $this->fingerprint($path);
30+
}
31+
}
32+
33+
public function poll(): bool
34+
{
35+
$changed = false;
36+
foreach ($this->paths as $path) {
37+
$current = $this->fingerprint($path);
38+
if ($current !== $this->fingerprints[$path]) {
39+
$this->fingerprints[$path] = $current;
40+
$changed = true;
41+
}
42+
}
43+
44+
return $changed;
45+
}
46+
47+
/**
48+
* @return array{mtime: int, size: int, hash: string}
49+
*/
50+
private function fingerprint(string $path): array
51+
{
52+
clearstatcache(true, $path);
53+
if (!is_file($path)) {
54+
return ['mtime' => 0, 'size' => 0, 'hash' => ''];
55+
}
56+
$algo = in_array('xxh32', hash_algos(), true) ? 'xxh32' : 'md5';
57+
$hash = @hash_file($algo, $path);
58+
59+
return [
60+
'mtime' => (int)filemtime($path),
61+
'size' => (int)filesize($path),
62+
'hash' => is_string($hash) ? $hash : '',
63+
];
64+
}
65+
}

src/Watch/Renderer.php

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Djot\Watch;
6+
7+
use Djot\DjotConverter;
8+
9+
class Renderer
10+
{
11+
private DjotConverter $converter;
12+
13+
public function __construct(?DjotConverter $converter = null)
14+
{
15+
$this->converter = $converter ?? new DjotConverter();
16+
}
17+
18+
public function render(string $djot): string
19+
{
20+
return $this->converter->convert($djot);
21+
}
22+
23+
public function renderDocument(string $djot, ?string $cssPath): string
24+
{
25+
$body = $this->render($djot);
26+
27+
return <<<HTML
28+
<!doctype html>
29+
<html lang="en">
30+
<head>
31+
<meta charset="utf-8">
32+
<title>Djot Preview</title>
33+
<link rel="stylesheet" href="/__assets/style.css">
34+
</head>
35+
<body>
36+
{$body}
37+
<script src="/__assets/livereload.js"></script>
38+
</body>
39+
</html>
40+
HTML;
41+
}
42+
}

src/Watch/Server.php

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Djot\Watch;
6+
7+
use RuntimeException;
8+
9+
class Server
10+
{
11+
/**
12+
* @var resource|null
13+
*/
14+
private $process;
15+
16+
/**
17+
* Start `php -S` with the given router. Picks the next free port if
18+
* `$port` is taken, scanning up to 10 above.
19+
*
20+
* @param string $host
21+
* @param int $port
22+
* @param string $routerPath
23+
* @param array<string, string> $env
24+
*
25+
* @throws \RuntimeException
26+
*
27+
* @return int the port actually used
28+
*/
29+
public function start(string $host, int $port, string $routerPath, array $env): int
30+
{
31+
$actualPort = $this->pickPort($host, $port);
32+
$docroot = dirname($routerPath);
33+
$cmd = sprintf(
34+
'exec %s -S %s:%d -t %s %s',
35+
escapeshellarg(PHP_BINARY),
36+
escapeshellarg($host),
37+
$actualPort,
38+
escapeshellarg($docroot),
39+
escapeshellarg($routerPath),
40+
);
41+
42+
// Discard php -S stdio. The dev server's request log otherwise fills
43+
// the pipe buffer (a few thousand requests) and the worker blocks on
44+
// write, making the server appear hung. We don't surface those logs
45+
// anywhere; if they're ever wanted, swap '/dev/null' for a log file.
46+
$descriptors = [
47+
0 => ['file', '/dev/null', 'r'],
48+
1 => ['file', '/dev/null', 'w'],
49+
2 => ['file', '/dev/null', 'w'],
50+
];
51+
52+
$envForProc = array_merge($_ENV, $env);
53+
$proc = proc_open($cmd, $descriptors, $pipes, null, $envForProc);
54+
if (!is_resource($proc)) {
55+
throw new RuntimeException('Failed to start php -S');
56+
}
57+
$this->process = $proc;
58+
59+
return $actualPort;
60+
}
61+
62+
public function stop(): void
63+
{
64+
if (!is_resource($this->process)) {
65+
return;
66+
}
67+
$status = proc_get_status($this->process);
68+
if ($status['running']) {
69+
proc_terminate($this->process, defined('SIGTERM') ? SIGTERM : 15);
70+
}
71+
proc_close($this->process);
72+
$this->process = null;
73+
}
74+
75+
private function pickPort(string $host, int $port): int
76+
{
77+
for ($p = $port; $p < $port + 10; $p++) {
78+
$sock = @stream_socket_server("tcp://{$host}:{$p}", $errno, $errstr);
79+
if ($sock !== false) {
80+
fclose($sock);
81+
82+
return $p;
83+
}
84+
}
85+
86+
throw new RuntimeException(sprintf('No free port found in range %d-%d', $port, $port + 9));
87+
}
88+
}

src/Watch/SseChannel.php

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Djot\Watch;
6+
7+
class SseChannel
8+
{
9+
public function __construct(private readonly string $statePath)
10+
{
11+
if (!file_exists($this->statePath)) {
12+
file_put_contents($this->statePath, '0');
13+
}
14+
}
15+
16+
public function current(): int
17+
{
18+
clearstatcache(true, $this->statePath);
19+
$raw = @file_get_contents($this->statePath);
20+
if ($raw === false) {
21+
return 0;
22+
}
23+
24+
return (int)trim($raw);
25+
}
26+
27+
public function bump(): void
28+
{
29+
$next = $this->current() + 1;
30+
file_put_contents($this->statePath, (string)$next, LOCK_EX);
31+
}
32+
}

0 commit comments

Comments
 (0)