diff --git a/README.md b/README.md
index e798b33..9670c81 100644
--- a/README.md
+++ b/README.md
@@ -43,7 +43,7 @@ deep-dive get a link here.
| `branch` | Prune stale/orphaned git branches (alias: `br`) | Usage below |
| `clean` | Reclaim dep/build directories under a project root (alias: `cln`) | Usage below |
| `docker` | Build/run/shell images tagged from git repo/branch/sha | Usage below |
-| `docs` | Local markdown reader: render + serve an indexed doc set over loopback HTTP | [docs](docs/commands/docs.md) |
+| `docs` | Native terminal docs explorer, with the loopback web reader retained as a fallback | [docs](docs/commands/docs.md) |
| `net` | Check cached reachability of the configured probe endpoint | Usage below |
| `proxy` | Apply config-defined profiles to the current shell through an explicit wrapper | [proxy](docs/commands/proxy.md) |
| `k8s` | Safely stream ordinary kubectl logs, plus bounded namespace/exec/inspect helpers | [k8s](docs/commands/k8s.md) |
@@ -201,9 +201,10 @@ forgectl docker build [context] -- --platform linux/arm64 # args after -- pass
forgectl docker run [-- args...] # run the built (or --tag) image
forgectl docker shell # open a shell in the built (or --tag) image
-# docs — local markdown reader: render + serve an indexed doc set over loopback HTTP
+# docs — rich HTML reader embedded in the current cmux workspace
+forgectl docs [dir|file ...] # serve + open a right-hand cmux browser pane (cwd by default)
forgectl docs serve [dir|file ...] # render + serve, loopback-only (DNS-rebinding-safe)
-forgectl docs serve --open # also open the system browser
+forgectl docs serve --open # serve + open a separate system-browser tab
forgectl docs open [path] # point the browser at a doc on the already-running reader
forgectl docs list [dir|file ...] # list the indexed docs, no server (--json for scripting)
@@ -301,6 +302,24 @@ forgectl y last 5 # print the 5 most recent zsh commands,
# acknowledgement only: forgectl does not scan or redact the history
```
+Inside cmux, the ordinary `docs` command creates a browser pane on the right of
+the invoking terminal without moving keyboard focus. The terminal owns the
+foreground loopback server, so leave it running while you read and press Ctrl-C
+there to close the server. Outside cmux, the same command opens the system
+browser instead. Use `docs serve` when another process should own presentation,
+or when you want remote or phone access through the existing address/token
+options.
+
+The reader opens directly on the document, with a compact preview toolbar and a
+document navigator that stays out of the reading path until opened. It renders
+sanitized Markdown, syntax highlighting, tables, Mermaid,
+inline SVG, and relative local PNG, JPEG, GIF, WebP, AVIF, and SVG images. The
+`Aa` control independently changes body, heading, and code fonts, text size,
+line height, and line length; those choices persist in that browser. Relative
+images are served only when an indexed document references them and still pass
+the configured-root containment checks. Remote images remain blocked so opening
+a local document does not notify a third party.
+
The cask doesn't stage an `fx` command — it's a shell alias you add yourself:
```sh
diff --git a/docs/plans/2026-08-29-embedded-docs-preview.md b/docs/plans/2026-08-29-embedded-docs-preview.md
new file mode 100644
index 0000000..be7aabc
--- /dev/null
+++ b/docs/plans/2026-08-29-embedded-docs-preview.md
@@ -0,0 +1,69 @@
+# Embedded docs preview
+
+## Goal
+
+Explore whether `forgectl docs` becomes a genuinely pleasant reading tool when
+its existing HTML reader is embedded in the caller's cmux workspace. Optimize
+this slice for learning: remove the Kitty/TUI prototype, make the HTML path the
+ordinary path, and address the reading gaps exposed by the live cmux proof.
+
+## Evidence and chosen approach
+
+- A loopback docs server was opened with `cmux new-pane --type browser` in the
+ caller's workspace. It rendered the existing sidebar, sanitized Markdown,
+ syntax highlighting, live reload, and Mermaid without taking focus.
+- `forgectl docs [dir|file ...]` will start the existing foreground loopback
+ server, open its URL in a right-hand cmux browser pane when
+ `CMUX_WORKSPACE_ID` is present, and otherwise fall back to the system browser.
+ The terminal remains the server owner, so Ctrl-C stops the preview.
+- `docs serve`, `docs open`, and `docs list` keep their explicit contracts.
+ `docs serve --open` remains the spelling for a separate system-browser tab.
+- Add an unobtrusive reading-settings control for body, heading, and code font
+ families plus text size, line height, and measure. Settings are browser-local
+ and persist with local storage, making this exploratory without expanding the
+ config schema.
+- Rewrite relative Markdown image URLs to a same-origin resource endpoint. The
+ endpoint will resolve files through the indexed root's existing containment
+ boundary, reject excluded/hidden paths and unsupported media types, and keep
+ remote images blocked by the current content-security policy.
+- Remove the terminal explorer, Kitty graphics, Glamour, and pure-Go Mermaid
+ code and dependencies. The completed native-reader plan remains in history as
+ the record of the explored approach and this plan records the deliberate
+ pivot.
+
+## Alternatives deferred
+
+- A background daemon would return the invoking terminal immediately, but it
+ introduces lifecycle and stale-process questions before the reading model is
+ proven.
+- Bundling proprietary or large font binaries would make typography identical
+ across machines, but system/local font stacks are sufficient to evaluate the
+ interaction first.
+- Opening a generic external browser inside forgectl would couple the command
+ to browser automation. This experiment uses cmux's supported CLI when it is
+ present and preserves the portable system-browser fallback.
+
+## Checklist
+
+- [x] Prove a loopback reader can open in the caller's cmux workspace without
+ stealing focus.
+- [x] Persist and commit the approved pivot before implementation.
+- [x] Replace the bare docs/TUI entry point with embedded-cmux preview startup.
+- [x] Add persisted reading typography and measure controls.
+- [x] Serve contained local Markdown images through the loopback reader.
+- [x] Remove terminal-reader code and dependencies.
+- [x] Update help and README; leave generated changelog prose to Release Please.
+- [x] Run fresh build, vet, tests, formatting, lint, and live cmux acceptance.
+- [x] Update and push the existing pull request, then monitor its checks.
+
+## Acceptance
+
+- From a cmux terminal, `forgectl docs [dir|file ...]` creates a readable
+ right-hand browser pane in that same workspace and leaves keyboard focus in
+ the invoking terminal; Ctrl-C stops its foreground server.
+- Markdown, syntax highlighting, tables, Mermaid, inline SVG, and contained
+ relative raster/SVG images render without a network dependency.
+- The reader offers visibly different body, heading, and code font choices and
+ persists the chosen typography, size, line height, and content width.
+- Outside cmux, the same command opens the system browser and explains the
+ server lifecycle; explicit `serve`, `open`, and `list` behavior remains green.
diff --git a/docs/plans/2026-08-29-native-docs-explorer.md b/docs/plans/2026-08-29-native-docs-explorer.md
new file mode 100644
index 0000000..1a4a23b
--- /dev/null
+++ b/docs/plans/2026-08-29-native-docs-explorer.md
@@ -0,0 +1,60 @@
+# Native docs explorer
+
+> Superseded by [Embedded docs preview](2026-08-29-embedded-docs-preview.md)
+> after live reading acceptance showed the rasterized terminal surface was not
+> competitive with the existing HTML renderer. Retained as the exploration
+> record, not the current implementation plan.
+
+## Goal
+
+Make `forgectl docs` a terminal-native, Artificer-styled document explorer while
+retaining the existing HTTP reader for remote, phone, and exact Mermaid.js
+fallback use. Local images, SVG, and Mermaid diagrams render in compatible
+terminals through the Kitty graphics protocol and degrade to readable text when
+graphics are unavailable.
+
+## Chosen approach
+
+- `forgectl docs [dir|file ...]` launches the TUI on an interactive terminal;
+ `forgectl docs browse` is the explicit equivalent. Existing `serve`, `open`,
+ and `list` behavior remains compatible.
+- Reuse the docs index, root resolution, watcher, and browser opener. Add an
+ adaptive Bubble Tea explorer with a filterable tree and scrollable Markdown
+ pane, rendered with Glamour and an Artificer stylesheet.
+- Use Charm's already-pinned Kitty encoder with Unicode virtual placements.
+ Add capability detection, stable IDs, resize retransmission, and cleanup.
+- Render local PNG, JPEG, static GIF, and SVG references only. Resolve every
+ path relative to its Markdown document and keep it inside the indexed root.
+- Render Mermaid with a pinned pure-Go renderer and rasterizer. Unsupported
+ syntax remains visible as source and points to the retained web reader.
+- External links require confirmation before opening the system browser;
+ relative Markdown links and anchors navigate inside the TUI.
+
+## Alternatives declined
+
+- Full HTTP-reader replacement: loses remote and phone access and removes the
+ exact Mermaid.js fallback.
+- Headless Chrome: matches Mermaid.js more closely but makes a browser a hidden
+ runtime dependency, contrary to the native-reader goal.
+- Text-only first release: does not deliver the requested graphics experience.
+
+## Checklist
+
+- [x] Create an isolated worktree and feature branch from fresh `origin/main`.
+- [x] Persist and commit the approved plan before implementation.
+- [x] Add terminal Markdown, resource, diagram, and Kitty graphics primitives.
+- [x] Add the adaptive docs TUI and wire the native-first CLI entry points.
+- [x] Cover fallback, containment, navigation, resize, reload, and cleanup.
+- [x] Update help and README; leave generated changelog prose to Release Please.
+- [x] Run fresh build, vet, tests, formatting, lint, and Ghostty acceptance.
+
+## Acceptance
+
+- Markdown, local raster images, SVG, and supported Mermaid diagrams render in
+ cmux/Ghostty; scrolling and resizing keep images attached to document rows.
+- Unsupported terminals, remote images, invalid diagrams, and decode failures
+ show deliberate readable fallbacks without raw graphics control sequences.
+- Live reload, internal links, history, filtering, and external-link
+ confirmation work without opening a separate browser for ordinary reading.
+- Existing `docs serve`, `docs open`, and `docs list` contracts and tests remain
+ green.
diff --git a/docs/plans/2026-08-29-reading-first-docs-preview.md b/docs/plans/2026-08-29-reading-first-docs-preview.md
new file mode 100644
index 0000000..8591d98
--- /dev/null
+++ b/docs/plans/2026-08-29-reading-first-docs-preview.md
@@ -0,0 +1,68 @@
+# Reading-first docs preview
+
+## Goal
+
+Make the embedded HTML reader feel like a native editor preview rather than a
+regular web application. The document must dominate the pane immediately,
+especially at the narrow widths created by a right-hand cmux split.
+
+## Chosen approach
+
+- Replace the persistent/stacking sidebar with an off-canvas document drawer
+ that is closed by default at every viewport width. A compact toolbar button
+ opens it; Escape, the scrim, or choosing a document closes it.
+- Reduce the app bar to editor-preview chrome: navigator button, current
+ document title, reading settings, and theme. Remove the oversized brand-first
+ presentation from the reading path.
+- Keep the article centered on a configurable reading measure with compact,
+ responsive gutters. The document begins immediately below the toolbar and
+ never waits below a stacked navigator.
+- Preserve the existing server, local-image authorization, Mermaid, live
+ reload, typography persistence, filtering, and explicit CLI contracts.
+- Add a small same-origin behavior asset for drawer state, focus return, Escape,
+ and scrim dismissal; keep the no-inline-script CSP invariant.
+
+## Alternatives declined
+
+- A permanently visible VS Code-style activity rail still consumes meaningful
+ width in the common half-screen cmux pane without helping the reading task.
+- A desktop-only persistent sidebar would reintroduce the dashboard feel and
+ make behavior jump as the split crosses one breakpoint.
+- Removing navigation entirely would make one-file previews pleasant but turn
+ indexed doc sets into a dead end.
+
+## Checklist
+
+- [x] Persist and commit the approved reading-first refinement.
+- [x] Replace the stacking sidebar with an accessible off-canvas drawer.
+- [x] Reduce the header and make the current document the primary label.
+- [x] Tune article spacing and responsive behavior for a half-screen cmux pane.
+- [x] Update tests and user-facing documentation.
+- [x] Run fresh formatting, JavaScript syntax, lint, vet, and full tests.
+- [ ] Complete live cmux visual acceptance. The content-first shell was
+ captured successfully; drawer open/filter/Escape/scrim behavior remains to
+ be exercised after the session was stopped.
+- [x] Push and document the stopping point in draft pull request #427.
+
+## Acceptance
+
+- Opening a document at the current cmux split width shows article content at
+ the top of the pane; the full navigator is not stacked above it.
+- The navigator opens as a drawer, focuses its filter, closes with Escape and
+ scrim click, and returns focus to its toggle.
+- The compact toolbar identifies the current document and keeps `Aa` and theme
+ controls available without visually competing with the article.
+- Existing document rendering, local images, Mermaid, appearance persistence,
+ and server security tests remain green.
+
+## Stopping point
+
+The reading-first shell is implemented and the automated verification is
+green. Live inspection confirmed that the article now starts directly beneath
+the compact toolbar with no stacked navigator. The remaining acceptance step
+is to exercise the navigator interactions in cmux and decide whether this
+visual direction is sufficiently native-feeling before taking the PR out of
+draft.
+
+Next action: rebuild the branch, run `forgectl docs .` inside cmux, then verify
+the navigator toggle, filter focus, Escape, scrim dismissal, and focus return.
diff --git a/go.mod b/go.mod
index b007b27..34e5f99 100644
--- a/go.mod
+++ b/go.mod
@@ -20,6 +20,7 @@ require (
github.com/yuin/goldmark v1.8.4
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
go.abhg.dev/goldmark/frontmatter v0.3.0
+ golang.org/x/net v0.55.0
golang.org/x/sys v0.46.0
golang.org/x/term v0.44.0
gopkg.in/yaml.v3 v3.0.1
@@ -64,7 +65,6 @@ require (
github.com/rogpeppe/go-internal v1.16.0 // indirect
github.com/sahilm/fuzzy v0.1.1 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
- golang.org/x/net v0.55.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/text v0.37.0 // indirect
)
diff --git a/internal/cli/docs.go b/internal/cli/docs.go
index aa3baaf..9575671 100644
--- a/internal/cli/docs.go
+++ b/internal/cli/docs.go
@@ -6,9 +6,8 @@ import (
"github.com/cameronsjo/forgectl/internal/module"
)
-// docsModule declares the local markdown reader extension (ADR-0005): owns
-// the [docs] config section. See forgectl#93 for the full design; this is
-// PR1's slice (render + index, no live reload).
+// docsModule declares the local markdown reader extension (ADR-0005) and owns
+// the [docs] config section. See forgectl#93 for the full design.
var docsModule = module.Manifest{
Name: "docs",
Tier: module.TierExtension,
@@ -21,14 +20,22 @@ var docsModule = module.Manifest{
// shape.
func newDocsCmd(deps module.Deps) *cobra.Command {
cmd := &cobra.Command{
- Use: "docs",
- Short: "Local markdown reader — render + serve an indexed doc set over loopback HTTP",
+ Use: "docs [dir|file ...]",
+ Short: "Read an indexed Markdown doc set in an embedded HTML preview",
+ Args: cobra.ArbitraryArgs,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if handled, err := docsHelpForNonTTY(cmd, args); handled {
+ return err
+ }
+ return runDocsPreview(cmd, deps, args)
+ },
Long: `docs is forgectl's local markdown reader (forgectl#93): pure-Go
-server-side rendering (goldmark+GFM, class-based chroma highlighting,
-bluemonday sanitization), Artificer-themed, served over loopback HTTP so it
-behaves the same whether you're at the machine or SSH'd in from the headless
-workbench — no terminal-specific rendering, no popping between windows.
+rendering with an Artificer-themed HTML preview as the ordinary path. Inside
+cmux, the preview opens as a right-hand browser pane in the caller's workspace
+without taking focus. Elsewhere it opens in the system browser. The invoking
+terminal owns the foreground loopback server; press Ctrl-C there to stop it.
+ forgectl docs [dir|file ...] serve + open the reading preview
forgectl docs serve [dir|file ...] render + serve an indexed doc set
forgectl docs serve --open also open the system browser
forgectl docs open [path] point the browser at a doc on the
@@ -36,10 +43,10 @@ workbench — no terminal-specific rendering, no popping between windows.
forgectl docs list [dir|file ...] list the indexed docs, no server
forgectl docs list --json machine-readable output for scripts
-Diagrams render in the page: a fenced code block tagged mermaid becomes a live
-diagram themed from the same Artificer tokens as the rest of the reader, and
-both those and inline SVG pan and zoom (drag to pan, modifier-scroll or
-click-then-scroll to zoom, double-click or 0 to reset).
+Mermaid.js renders fenced mermaid blocks, and Mermaid and inline SVG can pan and
+zoom (drag to pan, modifier-scroll or click-then-scroll to zoom, double-click or
+0 to reset). Reading settings in the app bar control body, heading, and code
+fonts plus text size, line height, and content width.
With no arguments, both verbs index cwd, ./docs (if present), and
$CADENCE_FIELD_REPORTS_DIR (if set), plus any extra roots configured in the
diff --git a/internal/cli/docs_preview.go b/internal/cli/docs_preview.go
new file mode 100644
index 0000000..fe3a322
--- /dev/null
+++ b/internal/cli/docs_preview.go
@@ -0,0 +1,41 @@
+package cli
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+ "golang.org/x/term"
+
+ docspkg "github.com/cameronsjo/forgectl/internal/docs"
+ "github.com/cameronsjo/forgectl/internal/module"
+)
+
+var docsStreamIsTerminal = func(stream any) bool {
+ fd, ok := stream.(interface{ Fd() uintptr })
+ return ok && term.IsTerminal(int(fd.Fd()))
+}
+
+func runDocsPreview(cmd *cobra.Command, deps module.Deps, args []string) error {
+ if !docsStreamIsTerminal(cmd.InOrStdin()) || !docsStreamIsTerminal(cmd.OutOrStdout()) {
+ return fmt.Errorf("docs preview requires an interactive terminal; use `forgectl docs list` for text output or `forgectl docs serve` for a server-only process")
+ }
+ roots, err := resolveDocsRoots(args, deps.Cfg.Docs)
+ if err != nil {
+ return err
+ }
+ idx, err := docspkg.NewIndex(roots)
+ if err != nil {
+ return err
+ }
+ return runDocsPreviewServer(cmd, deps, idx)
+}
+
+func docsHelpForNonTTY(cmd *cobra.Command, args []string) (bool, error) {
+ if docsStreamIsTerminal(cmd.InOrStdin()) && docsStreamIsTerminal(cmd.OutOrStdout()) {
+ return false, nil
+ }
+ if len(args) == 0 {
+ return true, cmd.Help()
+ }
+ return true, fmt.Errorf("docs preview requires an interactive terminal; use `forgectl docs list` for text output or `forgectl docs serve` for a server-only process")
+}
diff --git a/internal/cli/docs_preview_test.go b/internal/cli/docs_preview_test.go
new file mode 100644
index 0000000..9101dc3
--- /dev/null
+++ b/internal/cli/docs_preview_test.go
@@ -0,0 +1,56 @@
+package cli
+
+import (
+ "bytes"
+ "context"
+ "strings"
+ "testing"
+
+ "github.com/cameronsjo/forgectl/internal/config"
+ forgexec "github.com/cameronsjo/forgectl/internal/exec"
+ "github.com/cameronsjo/forgectl/internal/module"
+)
+
+func TestDocsCommand_NonTTYBareInvocationKeepsHelpBehavior(t *testing.T) {
+ previous := docsStreamIsTerminal
+ docsStreamIsTerminal = func(any) bool { return false }
+ t.Cleanup(func() { docsStreamIsTerminal = previous })
+
+ cmd := newDocsCmd(module.Deps{Cfg: config.Config{}, Runner: &forgexec.FakeRunner{}})
+ var out bytes.Buffer
+ cmd.SetOut(&out)
+ cmd.SetErr(&out)
+ cmd.SetIn(strings.NewReader(""))
+ cmd.SetArgs(nil)
+ if err := cmd.ExecuteContext(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(out.String(), "serve + open the reading preview") || !strings.Contains(out.String(), "docs serve") {
+ t.Fatalf("help = %q", out.String())
+ }
+}
+
+func TestDocsPreview_RejectsNonTTY(t *testing.T) {
+ previous := docsStreamIsTerminal
+ t.Cleanup(func() { docsStreamIsTerminal = previous })
+ deps := module.Deps{Cfg: config.Config{}, Runner: &forgexec.FakeRunner{}}
+ cmd := newDocsCmd(deps)
+ cmd.SetIn(strings.NewReader(""))
+ cmd.SetOut(new(bytes.Buffer))
+ docsStreamIsTerminal = func(any) bool { return false }
+ if err := runDocsPreview(cmd, deps, nil); err == nil || !strings.Contains(err.Error(), "interactive terminal") {
+ t.Fatalf("non-TTY error = %v", err)
+ }
+}
+
+func TestDocsCommand_RegistersPreviewAndServerEntrypoints(t *testing.T) {
+ cmd := newDocsCmd(module.Deps{Cfg: config.Config{}, Runner: &forgexec.FakeRunner{}})
+ for _, name := range []string{"serve", "open", "list"} {
+ if found, _, err := cmd.Find([]string{name}); err != nil || found.Name() != name {
+ t.Fatalf("Find(%q) = %v, %v", name, found, err)
+ }
+ }
+ if found, _, err := cmd.Find([]string{"browse"}); err == nil && found.Name() == "browse" {
+ t.Fatal("obsolete terminal browse command is still registered")
+ }
+}
diff --git a/internal/cli/docs_serve.go b/internal/cli/docs_serve.go
index dac261b..c029cec 100644
--- a/internal/cli/docs_serve.go
+++ b/internal/cli/docs_serve.go
@@ -7,6 +7,7 @@ import (
"io"
"net"
"net/http"
+ "os"
"os/signal"
"strings"
"sync"
@@ -229,6 +230,22 @@ func runDocsServe(cmd *cobra.Command, deps module.Deps, idx *docspkg.Index, addr
return runDocsServeWithRuntime(cmd, deps, idx, addrFlag, openFlag, tokenFile, productionDocsServeRuntime())
}
+func runDocsPreviewServer(cmd *cobra.Command, deps module.Deps, idx *docspkg.Index) error {
+ // The ordinary reading preview is always a private loopback process. A
+ // configured [docs].addr belongs to the explicit `docs serve` contract;
+ // inheriting a LAN bind here would require a bearer token and make the
+ // preview unable to open its own URL.
+ return runDocsServeWithRuntimeMode(cmd, deps, idx, httpsrv.LoopbackAddr, docsOpenEmbedded, "", productionDocsServeRuntime())
+}
+
+type docsOpenMode uint8
+
+const (
+ docsOpenNone docsOpenMode = iota
+ docsOpenSystem
+ docsOpenEmbedded
+)
+
// runDocsServeWithRuntime binds the listener, wires the security middleware
// chain (forgectl#93 security-chain item 1, plus the cross-site rejecter
// forgectl#178 adds) around the docs handler, publishes a generation-owned
@@ -262,6 +279,22 @@ func runDocsServeWithRuntime(
openFlag bool,
tokenFile string,
rt docsServeRuntime,
+) error {
+ mode := docsOpenNone
+ if openFlag {
+ mode = docsOpenSystem
+ }
+ return runDocsServeWithRuntimeMode(cmd, deps, idx, addrFlag, mode, tokenFile, rt)
+}
+
+func runDocsServeWithRuntimeMode(
+ cmd *cobra.Command,
+ deps module.Deps,
+ idx *docspkg.Index,
+ addrFlag string,
+ openMode docsOpenMode,
+ tokenFile string,
+ rt docsServeRuntime,
) error {
out := cmd.OutOrStdout()
errOut := cmd.ErrOrStderr()
@@ -449,7 +482,7 @@ func runDocsServeWithRuntime(
fmt.Fprintln(out, " live reload: on")
}
- if openFlag {
+ if openMode != docsOpenNone {
// Don't open a tab that is guaranteed to 401. A browser navigation cannot
// carry an Authorization header, so on a token-protected server --open
// would reliably produce an unauthorized page and leave the operator
@@ -458,6 +491,20 @@ func runDocsServeWithRuntime(
// verbs consistent rather than correct in one place only.
if token != "" {
fmt.Fprintln(errOut, "note: not opening a browser — this server requires a bearer token, which a browser navigation cannot supply")
+ } else if openMode == docsOpenEmbedded {
+ workspaceID := os.Getenv("CMUX_WORKSPACE_ID")
+ if workspaceID != "" {
+ if openErr := docspkg.OpenCMUXPreview(ctx, deps.Runner, workspaceID, url); openErr == nil {
+ if _, writeErr := fmt.Fprintln(out, " preview: embedded in cmux (server remains in this terminal)"); writeErr != nil {
+ warnDocsServe(errOut, "warning: failed to report embedded preview: %v", writeErr)
+ }
+ } else {
+ warnDocsServe(errOut, "warning: failed to open embedded cmux preview: %v", openErr)
+ openSystemBrowser(ctx, deps, url, out, errOut)
+ }
+ } else {
+ openSystemBrowser(ctx, deps, url, out, errOut)
+ }
} else if openErr := docspkg.OpenBrowser(ctx, deps.Runner, url); openErr != nil {
warnDocsServe(errOut, "warning: failed to open browser: %v", openErr)
}
@@ -510,6 +557,16 @@ func runDocsServeWithRuntime(
return result
}
+func openSystemBrowser(ctx context.Context, deps module.Deps, url string, out, errOut io.Writer) {
+ if openErr := docspkg.OpenBrowser(ctx, deps.Runner, url); openErr != nil {
+ warnDocsServe(errOut, "warning: failed to open system browser: %v", openErr)
+ return
+ }
+ if _, writeErr := fmt.Fprintln(out, " preview: system browser (server remains in this terminal)"); writeErr != nil {
+ warnDocsServe(errOut, "warning: failed to report system-browser preview: %v", writeErr)
+ }
+}
+
// abortDocsServeStartup unwinds a startup that failed after Serve began.
//
// closeServer rather than shutdown: there is no graceful drain to perform for a
diff --git a/internal/cli/init_cmd.go b/internal/cli/init_cmd.go
index fb35dd5..46a4e4f 100644
--- a/internal/cli/init_cmd.go
+++ b/internal/cli/init_cmd.go
@@ -172,7 +172,7 @@ const reviewScaffold = `
// comment names that port, but it appears nowhere else in the codebase; this
// scaffold corrects the drift rather than propagating it.
const docsScaffold = `
-# ── docs: local markdown reader (forgectl docs) ─────────────────────────────
+# ── docs: embedded HTML markdown reader (forgectl docs) ───────────────────────
[docs]
# roots = ["~/Projects/notes"] # extra root dirs indexed alongside cwd/./docs (example)
addr = "" # empty = 127.0.0.1 with a random port; set host:port to pin one
diff --git a/internal/config/config.go b/internal/config/config.go
index 7350634..793affe 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -73,7 +73,7 @@ const logKeepDays = 7
// host = "git.sjo.lol" # required when enabled
// login = "cameron" # optional; omitted → tea's own configured default login
// owners = ["cameron"] # tea --owner scope, independent of [review] owners
-// [docs] # forgectl docs — local markdown reader
+// [docs] # forgectl docs — embedded HTML markdown reader
// roots = ["~/Projects/notes"] # extra root dirs indexed alongside cwd/./docs
// addr = "127.0.0.1:4712" # --addr default when the flag is omitted
// [preflight] # forgectl preflight — plugin/catalog alignment
diff --git a/internal/docs/assets.go b/internal/docs/assets.go
index 82a2d82..7f21870 100644
--- a/internal/docs/assets.go
+++ b/internal/docs/assets.go
@@ -53,6 +53,20 @@ var navToggleJS []byte
//go:embed assets/sidenav-filter.js
var sidenavFilterJS []byte
+// readerCSS, readerShellJS, and readerSettingsJS are the docs reader's deliberately local
+// presentation layer. Artificer provides the design tokens and primitives;
+// these files provide the reading measure and browser-persisted typography
+// controls that are specific to this application.
+//
+//go:embed assets/reader.css
+var readerCSS []byte
+
+//go:embed assets/reader-shell.js
+var readerShellJS []byte
+
+//go:embed assets/reader-settings.js
+var readerSettingsJS []byte
+
// chromaArtificerCSS maps chroma's class-based token output onto the
// Artificer syntax roles (the .tok-* map in artificer.css), replacing the
// generated monokai sheet whose hardcoded palette ignored the theme. Served
@@ -90,7 +104,7 @@ var diagramCSS []byte
var shellTemplateSrc string
// shellTemplate is the one page template the server renders: the
-// page-shell chrome (appbar, sidenav, filter box) plus a content slot for
+// compact preview chrome (toolbar, navigation drawer, filter box) plus a content slot for
// either a rendered doc or the empty-state. Parsed once at package init —
// a malformed embedded template is a startup-time panic, not a per-request
// failure.
diff --git a/internal/docs/assets/reader-settings.js b/internal/docs/assets/reader-settings.js
new file mode 100644
index 0000000..fa3c183
--- /dev/null
+++ b/internal/docs/assets/reader-settings.js
@@ -0,0 +1,103 @@
+(function () {
+ 'use strict';
+
+ var storageKey = 'forgectl.docs.reader.v1';
+ var defaults = {
+ bodyFont: 'literary',
+ headingFont: 'humanist',
+ codeFont: 'jetbrains',
+ fontSize: '18',
+ lineHeight: '1.72',
+ measure: '72'
+ };
+ var families = {
+ bodyFont: {
+ literary: '"Iowan Old Style", "Palatino Linotype", Charter, Georgia, serif',
+ humanist: '"iA Writer Quattro", "Avenir Next", "Source Sans 3", system-ui, sans-serif',
+ system: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
+ mono: '"JetBrains Mono", "Berkeley Mono", ui-monospace, SFMono-Regular, Menlo, monospace'
+ },
+ headingFont: {
+ humanist: '"Avenir Next", Avenir, "Source Sans 3", system-ui, sans-serif',
+ literary: '"Iowan Old Style", "Palatino Linotype", Charter, Georgia, serif',
+ system: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
+ mono: '"JetBrains Mono", "Berkeley Mono", ui-monospace, SFMono-Regular, Menlo, monospace'
+ },
+ codeFont: {
+ jetbrains: '"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace',
+ berkeley: '"Berkeley Mono", "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace',
+ system: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace'
+ }
+ };
+ var properties = {
+ bodyFont: '--reader-body-font',
+ headingFont: '--reader-heading-font',
+ codeFont: '--reader-code-font',
+ fontSize: '--reader-font-size',
+ lineHeight: '--reader-line-height',
+ measure: '--reader-measure'
+ };
+
+ function load() {
+ try {
+ var saved = JSON.parse(localStorage.getItem(storageKey) || '{}');
+ return Object.assign({}, defaults, saved);
+ } catch (_) {
+ return Object.assign({}, defaults);
+ }
+ }
+
+ function save(settings) {
+ try {
+ localStorage.setItem(storageKey, JSON.stringify(settings));
+ } catch (_) {
+ // A locked-down browser may deny storage. The current page still works.
+ }
+ }
+
+ function cssValue(name, value) {
+ if (families[name]) return families[name][value] || families[name][defaults[name]];
+ if (name === 'fontSize') return value + 'px';
+ if (name === 'measure') return value + 'ch';
+ return value;
+ }
+
+ function renderValue(control) {
+ var output = document.querySelector('[data-reader-value="' + control.dataset.readerSetting + '"]');
+ if (!output) return;
+ var suffix = control.dataset.readerSetting === 'fontSize' ? 'px' :
+ control.dataset.readerSetting === 'measure' ? 'ch' : '';
+ output.textContent = control.value + suffix;
+ }
+
+ function apply(settings) {
+ Object.keys(properties).forEach(function (name) {
+ document.documentElement.style.setProperty(properties[name], cssValue(name, settings[name]));
+ var control = document.querySelector('[data-reader-setting="' + name + '"]');
+ if (control) {
+ control.value = settings[name];
+ renderValue(control);
+ }
+ });
+ }
+
+ var settings = load();
+ apply(settings);
+
+ document.querySelectorAll('[data-reader-setting]').forEach(function (control) {
+ control.addEventListener('input', function () {
+ settings[control.dataset.readerSetting] = control.value;
+ apply(settings);
+ save(settings);
+ });
+ });
+
+ var reset = document.querySelector('[data-reader-reset]');
+ if (reset) {
+ reset.addEventListener('click', function () {
+ settings = Object.assign({}, defaults);
+ apply(settings);
+ save(settings);
+ });
+ }
+})();
diff --git a/internal/docs/assets/reader-shell.js b/internal/docs/assets/reader-shell.js
new file mode 100644
index 0000000..3e2a06f
--- /dev/null
+++ b/internal/docs/assets/reader-shell.js
@@ -0,0 +1,50 @@
+(function () {
+ "use strict";
+
+ var shell = document.querySelector("[data-reader-shell]");
+ var drawer = document.querySelector("[data-docs-nav]");
+ var toggle = document.querySelector("[data-docs-nav-toggle]");
+ var filter = document.getElementById("doc-filter");
+
+ if (!shell || !drawer || !toggle) return;
+
+ function openDrawer() {
+ shell.setAttribute("data-nav-open", "");
+ toggle.setAttribute("aria-expanded", "true");
+ toggle.setAttribute("aria-label", "Close document navigator");
+ drawer.setAttribute("aria-hidden", "false");
+ if (filter) filter.focus();
+ }
+
+ function closeDrawer(returnFocus) {
+ shell.removeAttribute("data-nav-open");
+ toggle.setAttribute("aria-expanded", "false");
+ toggle.setAttribute("aria-label", "Open document navigator");
+ drawer.setAttribute("aria-hidden", "true");
+ if (returnFocus) toggle.focus();
+ }
+
+ toggle.addEventListener("click", function () {
+ if (shell.hasAttribute("data-nav-open")) {
+ closeDrawer(true);
+ } else {
+ openDrawer();
+ }
+ });
+
+ document.querySelectorAll("[data-docs-nav-close], [data-docs-nav-scrim]").forEach(function (control) {
+ control.addEventListener("click", function () {
+ closeDrawer(true);
+ });
+ });
+
+ drawer.addEventListener("click", function (event) {
+ if (event.target.closest("a")) closeDrawer(false);
+ });
+
+ document.addEventListener("keydown", function (event) {
+ if (event.key === "Escape" && shell.hasAttribute("data-nav-open")) {
+ closeDrawer(true);
+ }
+ });
+})();
diff --git a/internal/docs/assets/reader.css b/internal/docs/assets/reader.css
new file mode 100644
index 0000000..eb81891
--- /dev/null
+++ b/internal/docs/assets/reader.css
@@ -0,0 +1,271 @@
+:root {
+ --reader-body-font: "Iowan Old Style", "Palatino Linotype", Charter, Georgia, serif;
+ --reader-heading-font: "Avenir Next", Avenir, "Source Sans 3", system-ui, sans-serif;
+ --reader-code-font: "JetBrains Mono", "Berkeley Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
+ --reader-font-size: 18px;
+ --reader-line-height: 1.72;
+ --reader-measure: 72ch;
+ --reader-toolbar-height: 44px;
+}
+
+.docs-shell {
+ min-height: 100vh;
+ background: var(--bg);
+}
+
+.reader-toolbar {
+ position: sticky;
+ z-index: 40;
+ top: 0;
+ display: flex;
+ box-sizing: border-box;
+ height: var(--reader-toolbar-height);
+ align-items: center;
+ gap: var(--s-xs);
+ padding: 0 var(--s-sm);
+ border-bottom: 1px solid var(--border);
+ background: color-mix(in srgb, var(--bg) 94%, transparent);
+ backdrop-filter: blur(12px);
+}
+
+.reader-toolbar__nav-toggle {
+ display: inline-flex;
+ width: 32px;
+ min-width: 32px;
+ height: 32px;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
+}
+
+.reader-toolbar__nav-icon {
+ display: grid;
+ width: 15px;
+ gap: 3px;
+}
+
+.reader-toolbar__nav-icon i {
+ display: block;
+ height: 1px;
+ border-radius: 1px;
+ background: currentColor;
+}
+
+.reader-toolbar__title {
+ min-width: 0;
+ flex: 1;
+ overflow: hidden;
+ color: var(--fg-secondary);
+ font-family: var(--font-sans);
+ font-size: var(--t-label-sm-size);
+ font-weight: 600;
+ line-height: 1;
+ text-decoration: none;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.reader-toolbar__actions {
+ display: flex;
+ align-items: center;
+ gap: var(--s-xs);
+}
+
+.reader-shell__body {
+ min-height: calc(100vh - var(--reader-toolbar-height));
+}
+
+.reader-nav {
+ z-index: 35;
+ top: var(--reader-toolbar-height);
+ display: flex;
+ width: min(320px, calc(100vw - 40px));
+ flex-direction: column;
+ overflow: hidden;
+ border-right: 1px solid var(--border);
+ background: var(--bg-raised);
+ box-shadow: 12px 0 36px color-mix(in srgb, #000 32%, transparent);
+}
+
+.reader-nav__scrim {
+ z-index: 30;
+ inset: var(--reader-toolbar-height) 0 0;
+ width: auto;
+ height: auto;
+ padding: 0;
+ border: 0;
+ background: color-mix(in srgb, #000 42%, transparent);
+ cursor: default;
+}
+
+.reader-nav__header {
+ display: flex;
+ min-height: 44px;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 var(--s-sm) 0 var(--s-md);
+ border-bottom: 1px solid var(--border);
+ color: var(--fg-secondary);
+ font-family: var(--font-sans);
+ font-size: var(--t-label-xs-size);
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.reader-nav__close {
+ width: 30px;
+ min-width: 30px;
+ height: 30px;
+ padding: 0;
+ font-size: 20px;
+ line-height: 1;
+}
+
+.reader-nav__filter {
+ padding: var(--s-sm);
+}
+
+.reader-nav__list {
+ flex: 1;
+ overflow-y: auto;
+ border: 0;
+ border-radius: 0;
+}
+
+.docs-reader {
+ box-sizing: border-box;
+ min-width: 0;
+ min-height: calc(100vh - var(--reader-toolbar-height));
+ padding: clamp(28px, 4vw, 56px) clamp(20px, 6vw, 72px);
+}
+
+.reader-content {
+ box-sizing: border-box;
+ width: 100%;
+ max-width: var(--reader-measure);
+ margin-inline: auto;
+ font-family: var(--reader-body-font);
+ font-size: var(--reader-font-size);
+ line-height: var(--reader-line-height);
+ letter-spacing: 0;
+}
+
+.reader-content :where(p, li, blockquote, td, th, dd, dt, em, strong, a) {
+ font-family: inherit;
+}
+
+.reader-content :where(h1, h2, h3, h4, h5, h6) {
+ font-family: var(--reader-heading-font);
+ line-height: 1.18;
+ letter-spacing: -0.018em;
+ text-wrap: balance;
+}
+
+.reader-content :where(code, kbd, samp, pre, pre *) {
+ font-family: var(--reader-code-font);
+}
+
+.reader-content p,
+.reader-content li {
+ text-wrap: pretty;
+}
+
+.reader-content img {
+ display: block;
+ max-width: 100%;
+ height: auto;
+ margin: var(--s-xl) auto;
+ border-radius: var(--radius-sm);
+}
+
+.reader-settings {
+ position: relative;
+ font-family: var(--font-sans);
+}
+
+.reader-settings > summary {
+ list-style: none;
+}
+
+.reader-settings > summary::-webkit-details-marker {
+ display: none;
+}
+
+.reader-settings[open] > summary {
+ color: var(--accent);
+ background: var(--bg-raised);
+}
+
+.reader-settings__panel {
+ position: absolute;
+ top: calc(100% + var(--s-sm));
+ right: 0;
+ width: min(340px, calc(100vw - 24px));
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: var(--s-md);
+ padding: var(--s-lg);
+ z-index: 50;
+}
+
+.reader-settings__panel .field {
+ min-width: 0;
+}
+
+.reader-settings__panel .field--wide,
+.reader-settings__footer {
+ grid-column: 1 / -1;
+}
+
+.reader-settings__panel .select,
+.reader-settings__panel .input {
+ box-sizing: border-box;
+ font-size: var(--t-label-sm-size);
+ padding-block: 8px;
+}
+
+.reader-settings__range-line {
+ display: flex;
+ align-items: center;
+ gap: var(--s-sm);
+}
+
+.reader-settings__range-line input[type="range"] {
+ flex: 1;
+ accent-color: var(--accent-fill);
+}
+
+.reader-settings__value {
+ min-width: 4ch;
+ color: var(--fg-secondary);
+ font-family: var(--font-mono);
+ font-size: var(--t-label-xs-size);
+ text-align: right;
+}
+
+.reader-settings__footer {
+ display: flex;
+ justify-content: flex-end;
+ padding-top: var(--s-xs);
+ border-top: 1px solid var(--border);
+}
+
+@media (max-width: 800px) {
+ .docs-reader {
+ padding: 26px 20px 48px;
+ }
+
+ .reader-settings__panel {
+ position: fixed;
+ top: 64px;
+ right: 12px;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .reader-nav,
+ .reader-nav__scrim {
+ transition: none;
+ }
+}
diff --git a/internal/docs/browser.go b/internal/docs/browser.go
index 38731b9..cd81e9b 100644
--- a/internal/docs/browser.go
+++ b/internal/docs/browser.go
@@ -2,11 +2,17 @@ package docs
import (
"context"
+ "errors"
"runtime"
+ "strings"
"github.com/cameronsjo/forgectl/internal/exec"
)
+// ErrNoCMUXWorkspace means the caller did not provide the workspace identity
+// cmux requires to place a browser pane without guessing or changing focus.
+var ErrNoCMUXWorkspace = errors.New("cmux workspace is not available")
+
// OpenBrowser launches url in the system browser: `open` on macOS, `xdg-open`
// elsewhere. Mirrors internal/bench's openCommand/Open pattern (same
// GOOS-keyed opener, same delegation through exec.Runner) — the docs module
@@ -18,6 +24,26 @@ func OpenBrowser(ctx context.Context, runner exec.Runner, url string) error {
return runner.RunInteractive(ctx, openCommand(), url)
}
+// OpenCMUXPreview opens url in a new right-hand browser pane in workspaceID.
+// The explicit workspace prevents a concurrent cmux session from receiving the
+// pane, and --focus false leaves the invoking terminal in control of the
+// foreground docs server (where Ctrl-C owns shutdown).
+func OpenCMUXPreview(ctx context.Context, runner exec.Runner, workspaceID, url string) error {
+ workspaceID = strings.TrimSpace(workspaceID)
+ if workspaceID == "" {
+ return ErrNoCMUXWorkspace
+ }
+ _, err := runner.Run(ctx, "cmux", "new-pane",
+ "--workspace", workspaceID,
+ "--type", "browser",
+ "--direction", "right",
+ "--url", url,
+ "--focus", "false",
+ "--json",
+ )
+ return err
+}
+
func openCommand() string {
if runtime.GOOS == "darwin" {
return "open"
diff --git a/internal/docs/browser_test.go b/internal/docs/browser_test.go
new file mode 100644
index 0000000..b796ef5
--- /dev/null
+++ b/internal/docs/browser_test.go
@@ -0,0 +1,44 @@
+package docs
+
+import (
+ "context"
+ "errors"
+ "reflect"
+ "testing"
+
+ forgexec "github.com/cameronsjo/forgectl/internal/exec"
+)
+
+func TestOpenCMUXPreviewTargetsCallerWorkspaceWithoutTakingFocus(t *testing.T) {
+ runner := &forgexec.FakeRunner{}
+ err := OpenCMUXPreview(context.Background(), runner, " workspace:7 ", "http://127.0.0.1:4321/")
+ if err != nil {
+ t.Fatal(err)
+ }
+ call := runner.Last()
+ if call.Name != "cmux" {
+ t.Fatalf("command = %q, want cmux", call.Name)
+ }
+ want := []string{
+ "new-pane", "--workspace", "workspace:7", "--type", "browser",
+ "--direction", "right", "--url", "http://127.0.0.1:4321/",
+ "--focus", "false", "--json",
+ }
+ if !reflect.DeepEqual(call.Args, want) {
+ t.Fatalf("args = %#v, want %#v", call.Args, want)
+ }
+ if call.Interactive {
+ t.Fatal("cmux browser creation unexpectedly used the interactive runner")
+ }
+}
+
+func TestOpenCMUXPreviewRequiresWorkspace(t *testing.T) {
+ runner := &forgexec.FakeRunner{}
+ err := OpenCMUXPreview(context.Background(), runner, " ", "http://127.0.0.1:4321/")
+ if !errors.Is(err, ErrNoCMUXWorkspace) {
+ t.Fatalf("error = %v, want ErrNoCMUXWorkspace", err)
+ }
+ if len(runner.Calls) != 0 {
+ t.Fatalf("calls = %#v, want none", runner.Calls)
+ }
+}
diff --git a/internal/docs/index.go b/internal/docs/index.go
index 4eefb84..9694088 100644
--- a/internal/docs/index.go
+++ b/internal/docs/index.go
@@ -1,12 +1,11 @@
-// Package docs is the ops layer for `forgectl docs` (#93): a pure-Go,
-// server-side-rendered local markdown reader. It indexes a closed set of
-// root directories, renders markdown to sanitized HTML, and serves both over
-// loopback HTTP. It knows nothing of Cobra — that decoupling is the house
-// pattern (see internal/tmux, internal/net).
+// Package docs is the ops layer for `forgectl docs` (#93): a pure-Go local
+// markdown reader. It indexes a closed set of root directories and renders
+// their contents through the loopback HTTP reader. It knows nothing of Cobra —
+// that decoupling is the house pattern
+// (see internal/tmux, internal/net).
//
-// Current scope: render, index, and live reload (a filesystem Watcher rebuilds
-// the Index and notifies browsers over SSE). Mermaid and pan/zoom SVG are still
-// outstanding — forgectl#93 stages those separately.
+// A filesystem Watcher rebuilds the Index and a Broker notifies readers over
+// SSE. The HTML renderer sanitizes Markdown before it reaches the browser.
package docs
import (
diff --git a/internal/docs/media.go b/internal/docs/media.go
new file mode 100644
index 0000000..bb8ab15
--- /dev/null
+++ b/internal/docs/media.go
@@ -0,0 +1,234 @@
+package docs
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "log/slog"
+ "net/http"
+ "net/url"
+ "os"
+ "path"
+ "path/filepath"
+ "strings"
+
+ "golang.org/x/net/html"
+)
+
+var mediaTypes = map[string]string{
+ ".avif": "image/avif",
+ ".gif": "image/gif",
+ ".jpeg": "image/jpeg",
+ ".jpg": "image/jpeg",
+ ".png": "image/png",
+ ".svg": "image/svg+xml",
+ ".webp": "image/webp",
+}
+
+// AllowedMediaExt reports whether path is a browser-readable image type the
+// docs server is willing to expose. The response type comes from this same
+// closed table rather than content sniffing.
+func AllowedMediaExt(path string) bool {
+ _, ok := mediaTypes[strings.ToLower(filepath.Ext(path))]
+ return ok
+}
+
+func mediaType(path string) (string, bool) {
+ t, ok := mediaTypes[strings.ToLower(filepath.Ext(path))]
+ return t, ok
+}
+
+// RewriteLocalImageURLs points relative img sources at the reader's
+// same-origin media endpoint. The endpoint receives both the serving document
+// and the normalized target path so it can prove that the document actually
+// references the requested file before reading it.
+func RewriteLocalImageURLs(rendered, rootLabel, docRel string) (string, error) {
+ var out strings.Builder
+ tokens := html.NewTokenizer(strings.NewReader(rendered))
+ for {
+ tokenType := tokens.Next()
+ if tokenType == html.ErrorToken {
+ if err := tokens.Err(); err != nil && !errors.Is(err, io.EOF) {
+ return "", fmt.Errorf("parse rendered markdown: %w", err)
+ }
+ return out.String(), nil
+ }
+
+ // Raw aliases the tokenizer's scratch buffer; Token may normalize names
+ // in that same buffer (notably SVG viewBox/linearGradient). Copy before
+ // asking for the parsed token so unrelated markup stays byte-for-byte.
+ rawToken := append([]byte(nil), tokens.Raw()...)
+ token := tokens.Token()
+ rewritten := false
+ if (tokenType == html.StartTagToken || tokenType == html.SelfClosingTagToken) && token.Data == "img" {
+ for i := range token.Attr {
+ if token.Attr[i].Key != "src" {
+ continue
+ }
+ mediaRel, fragment, ok := relativeMediaPath(token.Attr[i].Val, docRel)
+ if !ok {
+ continue
+ }
+ query := url.Values{"doc": {docRel}, "path": {mediaRel}}
+ token.Attr[i].Val = "/media/" + url.PathEscape(rootLabel) + "?" + query.Encode()
+ if fragment != "" {
+ token.Attr[i].Val += "#" + url.PathEscape(fragment)
+ }
+ rewritten = true
+ }
+ }
+ if rewritten {
+ out.WriteString(token.String())
+ } else {
+ out.Write(rawToken)
+ }
+ }
+}
+
+// relativeMediaPath converts a relative URL from docRel's directory into a
+// root-relative path. Absolute, remote, data, and root-escaping references are
+// deliberately not rewritten; the existing CSP leaves remote images blocked.
+func relativeMediaPath(raw, docRel string) (mediaRel, fragment string, ok bool) {
+ u, err := url.Parse(strings.TrimSpace(raw))
+ if err != nil || u.Scheme != "" || u.Host != "" || u.Path == "" || strings.HasPrefix(u.Path, "/") {
+ return "", "", false
+ }
+ joined := path.Clean(path.Join(path.Dir(docRel), u.Path))
+ if joined == ".." || strings.HasPrefix(joined, "../") || !AllowedMediaExt(joined) {
+ return "", "", false
+ }
+ return joined, u.Fragment, true
+}
+
+// ResolveMedia applies the same canonical-root and excluded-directory
+// boundaries as document resolution. A single-file root may resolve a sibling
+// image only because handleMedia separately proves that the indexed document
+// explicitly references it; this does not widen which Markdown files it can
+// serve.
+func (idx *Index) ResolveMedia(rootLabel, relPath string) (string, error) {
+ for _, root := range idx.roots {
+ if root.Label != rootLabel {
+ continue
+ }
+ resolved, err := ResolveInRoot(root.Path, filepath.FromSlash(relPath))
+ if err != nil {
+ return "", err
+ }
+ if !AllowedMediaExt(resolved) {
+ return "", ErrDisallowedExt
+ }
+ rel, err := filepath.Rel(root.Path, resolved)
+ if err != nil {
+ return "", ErrOutsideRoot
+ }
+ segments := strings.Split(filepath.ToSlash(rel), "/")
+ for _, dir := range segments[:len(segments)-1] {
+ if excludedDir(dir) {
+ return "", ErrNotIndexed
+ }
+ }
+ return resolved, nil
+ }
+ return "", ErrRootNotFound
+}
+
+func handleMedia(store *Store) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ rootLabel := r.PathValue("root")
+ docRel := r.URL.Query().Get("doc")
+ requested := r.URL.Query().Get("path")
+ if docRel == "" || requested == "" {
+ http.NotFound(w, r)
+ return
+ }
+
+ idx := store.Current()
+ docPath, err := idx.Resolve(rootLabel, docRel)
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+ // docPath came from Index.Resolve: canonical root containment, extension,
+ // and exact index membership have all been checked.
+ source, err := os.ReadFile(docPath) //nolint:gosec // resolved indexed document path, not a raw request path
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+ rendered, err := Render(source)
+ if err != nil {
+ slog.Debug("docs: media source document could not be rendered.", "root", rootLabel, "doc", docRel, "error", err)
+ http.NotFound(w, r)
+ return
+ }
+ if !renderedReferencesMedia(rendered, docRel, requested) {
+ slog.Debug("docs: media request was not referenced by its document.", "root", rootLabel, "doc", docRel, "media", requested)
+ http.NotFound(w, r)
+ return
+ }
+
+ mediaPath, err := idx.ResolveMedia(rootLabel, requested)
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+ contentType, ok := mediaType(mediaPath)
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ // mediaPath came from ResolveMedia after reference authorization and the
+ // same canonical containment chain used for Markdown files.
+ file, err := os.Open(mediaPath) //nolint:gosec // resolved contained media path, not a raw request path
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+ defer func() {
+ if closeErr := file.Close(); closeErr != nil {
+ slog.Debug("docs: media file could not be closed.", "path", mediaPath, "error", closeErr)
+ }
+ }()
+ info, err := file.Stat()
+ if err != nil || !info.Mode().IsRegular() {
+ http.NotFound(w, r)
+ return
+ }
+
+ w.Header().Set("Content-Type", contentType)
+ w.Header().Set("Cache-Control", "no-cache")
+ if contentType == "image/svg+xml" {
+ // An SVG loaded through is inert in modern browsers, but this
+ // response can also be navigated to directly. Sandbox that document so
+ // an authored script cannot execute with the reader origin's authority.
+ w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox")
+ }
+ http.ServeContent(w, r, filepath.Base(mediaPath), info.ModTime(), file)
+ }
+}
+
+func renderedReferencesMedia(rendered, docRel, requested string) bool {
+ tokens := html.NewTokenizer(strings.NewReader(rendered))
+ for {
+ tokenType := tokens.Next()
+ if tokenType == html.ErrorToken {
+ return false
+ }
+ if tokenType != html.StartTagToken && tokenType != html.SelfClosingTagToken {
+ continue
+ }
+ token := tokens.Token()
+ if token.Data != "img" {
+ continue
+ }
+ for _, attr := range token.Attr {
+ if attr.Key != "src" {
+ continue
+ }
+ mediaRel, _, ok := relativeMediaPath(attr.Val, docRel)
+ if ok && mediaRel == requested {
+ return true
+ }
+ }
+ }
+}
diff --git a/internal/docs/media_test.go b/internal/docs/media_test.go
new file mode 100644
index 0000000..c3fb472
--- /dev/null
+++ b/internal/docs/media_test.go
@@ -0,0 +1,127 @@
+package docs
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestRewriteLocalImageURLs_RewritesOnlyContainedRelativeImages(t *testing.T) {
+ rendered := `` +
+ `
` +
+ `
` +
+ `` +
+ `
