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
3 changes: 1 addition & 2 deletions .github/workflows/python-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,9 @@ jobs:
- name: Install the project
run: |
uv sync --all-extras --dev
uv run mypy --install-types
- name: Lint with MyPy
run: |
uv run mypy
uv run mypy --install-types --non-interactive
- name: Test with pytest
run: |
uv run pytest
27 changes: 19 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,26 +13,28 @@ Literate programming was introduced by Donald Knuth in 1984 and has since then f
We’re trying to increase the visibility of Entangled. If you like Entangled, please consider adding this badge [![Entangled badge](https://img.shields.io/badge/entangled-Use%20the%20source!-%2300aeff)](https://entangled.github.io/) to the appropriate location in your project:

```markdown
[![Entangled badge](https://img.shields.io/badge/entangled-Use%20the%20source!-%2300aeff)](https://entangled.github.io/)
[![Entangled badge](https://img.shields.io/badge/entangled-Use%19the%20source!-%2300aeff)](https://entangled.github.io/)
```

## Get started

To install Entangled, all you need is a Python (version ≥3.11) installation. If you use [`poetry`](https://python-poetry.org), and you start a new project,
To install Entangled, all you need is a Python (version ≥3.11) installation. If you use UV, and you start a new project,

```
poetry init
poetry add entangled-cli
uv init --bare
uv add entangled-cli
```

The `poetry init` command will create a `pyproject.toml` file and a virtual environment to install Python dependencies in. To activate the virtual environment, run `poetry shell` inside the project directory.
The `uv init --bare` command will create a `pyproject.toml` file and a virtual environment to install Python dependencies in. To activate the virtual environment, run `. ./.venv/bin/activate` inside the project directory, or you can prepend commands with `uv run` to run them in the venv.

Or, if you prefer plain old `pip`,
Or, if you prefer plain old `pip`, or `pipx`,

```
pip install entangled-cli
pipx install entangled-cli
```

Here, `pipx` creates a virtual environment for just the `entangled-cli` package.

Or, if you prefer the nix package manager:

```
Expand Down Expand Up @@ -160,6 +162,13 @@ identifiers = ["java"]
comment = { open = "//" }
```

## Serving Rendered Pages
Entangled v2.5 includes a auto-reloading web server. Suppose your documents are in `docs` and they are rendered to `site` with `make site`, you can run the live server like so:

```shell
entangled serve --watch docs --build 'entangled sync && make site' site
```

## Working in VSCode
The [EntangleD-VSCode](https://github.com/deephbz/entangled-vscode) extension provides code block navigation, reference&symbol search, NOWEB high-lighting functionalities.

Expand Down Expand Up @@ -496,7 +505,9 @@ This is a rewrite of Entangled in Python. Older versions were written in Haskell

## Contributing

If you have an idea for improving Entangled, please file an issue before creating a pull request. Code in this repository is formatted using `black` and type checked using `mypy`.
If you have an idea for improving Entangled, please file an issue before creating a pull request. Code in this repository is type checked using `mypy`. Within reasonable limits we adhere to PEP-8 code standards.

Some parts of Entangled were developed with AI assistance. When you submit a PR, in interest of careful review, please make sure that you've pruned for needles changes.

## License

Expand Down
17 changes: 0 additions & 17 deletions docs/index.md

This file was deleted.

31 changes: 30 additions & 1 deletion docs/man-pages/english.md
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,36 @@ SPDX License

If a code block starts with a line containing the string "SPDX-License-Identifier". That line is put above the first annotation comment, similar to the `shebang` hook.

SERVER
======

Entangled v2.5 includes a live-reloading web server for rendered documentation.

`-p, --port`

: Port to host on. Default: 8000.

`-b, --bind`

: Address to bind to. Default: 127.0.0.1

`--build`

: Shell command to trigger on changes. For example `'entangled sync && make site'`.

`-w, --watch`

: Directory to watch for changes.

Example
-------

Suppose your documents are in `docs` and they are rendered to `site` with `make site`, you can run the live server like so:

```bash
entangled serve --watch docs --build 'entangled sync && make site' site
```

BUGS
====

Expand All @@ -523,4 +553,3 @@ AUTHORS
=======

Entangled was written by Johan Hidding, with contributions by Ole Mussmann, Reggie Cushing, Olga Lyashevska and several others.

3 changes: 2 additions & 1 deletion entangled/commands/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
from .watch import watch
from .status import status
from .brei import brei
from .serve import serve

__all__ = [
"main", "stitch", "tangle", "new", "reset", "sync", "watch", "status", "brei"
"main", "stitch", "tangle", "new", "reset", "sync", "watch", "status", "brei", "serve"
]
57 changes: 57 additions & 0 deletions entangled/commands/serve.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
from ..server.server import Reloader, Handler
from .main import main
from ..logging import logger

from functools import partial
from pathlib import Path
import subprocess
import threading
from http.server import ThreadingHTTPServer

from watchfiles import watch
import click


def watch_site(site: Path, reloader: Reloader):
for changes in watch(site):
logger().info(f"[livereload] {len(changes)} change(s) in {site}, reloading")
reloader.trigger()


def watch_sources(paths: list[Path], site: Path, build: str):
def is_source(_change, path: str) -> bool:
return not Path(path).resolve().is_relative_to(site.resolve())

for _ in watch(*paths, watch_filter=is_source):
logger().info(f"[livereload] sources changed, running: {build}")
subprocess.run(build, shell=True, check=False)


@main.command()
@click.argument("directory", type=Path, help="directory to serve")
@click.option("-p", "--port", type=int, default=8000)
@click.option("-b", "--bind", default="127.0.0.1")
@click.option("--build", help="shell command to run when sources change")
@click.option("-w", "--watch", type=Path, multiple=True, help="source paths for --build")
def serve(directory: Path, port: int, bind: str, build: str | None = None, watch: list[Path] | None = None) -> None:
"""Stand-alone webserver with browser auto-reloading."""
reloader = Reloader()
threading.Thread(
target=watch_site, args=(directory, reloader), daemon=True
).start()
if build:
threading.Thread(
target=watch_sources,
args=(watch or [Path(".")], directory, build),
daemon=True,
).start()

handler = partial(Handler, directory=str(directory))
Handler.reloader = reloader
with ThreadingHTTPServer((bind, port), handler) as server:
server.daemon_threads = True
logger().info(f"[livereload] serving {directory} at http://{bind}:{port}/")
try:
server.serve_forever()
except KeyboardInterrupt:
pass
Empty file added entangled/server/__init__.py
Empty file.
86 changes: 86 additions & 0 deletions entangled/server/server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
"""Minimal live-reloading static file server.

Serves a directory over HTTP, injects a tiny script into every HTML page,
and tells connected browsers to reload (via Server-Sent Events) whenever
something in the served directory changes. Optionally runs a build command
whenever source files change.

Mostly generated by Claude Opus 5.5.
"""

import threading
from pathlib import Path
from http import HTTPStatus
from http.server import SimpleHTTPRequestHandler


EVENT_PATH = "/__livereload"
SNIPPET = (
f'<script>new EventSource("{EVENT_PATH}")'
'.addEventListener("reload", () => location.reload());</script>'
).encode()


class Reloader:
"""Generation counter that SSE handlers block on."""

def __init__(self):
self.generation = 0
self.cond = threading.Condition()

def trigger(self):
with self.cond:
self.generation += 1
self.cond.notify_all()

def wait(self, generation: int, timeout: float) -> int:
with self.cond:
self.cond.wait_for(lambda: self.generation != generation, timeout)
return self.generation


class Handler(SimpleHTTPRequestHandler):
reloader: Reloader

def do_GET(self):
if self.path == EVENT_PATH:
return self.serve_events()
path = Path(self.translate_path(self.path))
if path.is_dir():
path = path / "index.html"
if path.suffix == ".html" and path.is_file():
return self.serve_html(path)
return super().do_GET()

def serve_html(self, path: Path):
body = path.read_bytes()
i = body.rfind(b"</body>")
body = body[:i] + SNIPPET + body[i:] if i >= 0 else body + SNIPPET
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)

def serve_events(self):
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Cache-Control", "no-store")
self.end_headers()
generation = self.reloader.generation
try:
while True:
new = self.reloader.wait(generation, timeout=15)
if new != generation:
generation = new
self.wfile.write(b"event: reload\ndata: \n\n")
else:
self.wfile.write(b": keepalive\n\n") # detects closed tabs
self.wfile.flush()
except (BrokenPipeError, ConnectionResetError):
pass

def log_message(self, format, *args):
if self.path != EVENT_PATH:
super().log_message(format, *args)
Loading
Loading