Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
25 changes: 25 additions & 0 deletions bin/djot-watch
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#!/usr/bin/env php
<?php

declare(strict_types=1);

$autoloadPaths = [
__DIR__ . '/../vendor/autoload.php',
__DIR__ . '/../../../autoload.php',
];

$autoloaderFound = false;
foreach ($autoloadPaths as $autoloadPath) {
if (file_exists($autoloadPath)) {
require $autoloadPath;
$autoloaderFound = true;
break;
}
}

if (!$autoloaderFound) {
fwrite(STDERR, "Error: Could not find Composer autoloader.\n");
exit(70);
}

exit((new Djot\Watch\Watcher())->run($argv));
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
}
},
"bin": [
"bin/djot"
"bin/djot",
"bin/djot-watch"
]
}
48 changes: 48 additions & 0 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
65 changes: 65 additions & 0 deletions src/Watch/FileWatcher.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?php

declare(strict_types=1);

namespace Djot\Watch;

class FileWatcher
{
/**
* Fingerprint per tracked path: (mtime, size, hash).
*
* 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<string, array{mtime: int, size: int, hash: string}>
*/
private array $fingerprints = [];

/**
* @param list<string> $paths
*/
public function __construct(private readonly array $paths)
{
foreach ($this->paths as $path) {
$this->fingerprints[$path] = $this->fingerprint($path);
}
}

public function poll(): bool
{
$changed = false;
foreach ($this->paths as $path) {
$current = $this->fingerprint($path);
if ($current !== $this->fingerprints[$path]) {
$this->fingerprints[$path] = $current;
$changed = true;
}
}

return $changed;
}

/**
* @return array{mtime: int, size: int, hash: string}
*/
private function fingerprint(string $path): array
{
clearstatcache(true, $path);
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 : '',
];
}
}
42 changes: 42 additions & 0 deletions src/Watch/Renderer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

declare(strict_types=1);

namespace Djot\Watch;

use Djot\DjotConverter;

class Renderer
{
private DjotConverter $converter;

public function __construct(?DjotConverter $converter = null)
{
$this->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 <<<HTML
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Djot Preview</title>
<link rel="stylesheet" href="/__assets/style.css">
</head>
<body>
{$body}
<script src="/__assets/livereload.js"></script>
</body>
</html>
HTML;
}
}
88 changes: 88 additions & 0 deletions src/Watch/Server.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<?php

declare(strict_types=1);

namespace Djot\Watch;

use RuntimeException;

class Server
{
/**
* @var resource|null
*/
private $process;

/**
* Start `php -S` with the given router. Picks the next free port if
* `$port` is taken, scanning up to 10 above.
*
* @param string $host
* @param int $port
* @param string $routerPath
* @param array<string, string> $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),
);

// 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 => ['file', '/dev/null', 'r'],
1 => ['file', '/dev/null', 'w'],
2 => ['file', '/dev/null', '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));
}
}
32 changes: 32 additions & 0 deletions src/Watch/SseChannel.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

declare(strict_types=1);

namespace Djot\Watch;

class SseChannel
{
public function __construct(private readonly string $statePath)
{
if (!file_exists($this->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);
}
}
Loading
Loading