From aa0f22e8d72ccc0b78060c273a2212b6b638e245 Mon Sep 17 00:00:00 2001 From: Cameron Sjo <4084915+cameronsjo@users.noreply.github.com> Date: Sat, 29 Aug 2026 18:10:14 -0500 Subject: [PATCH 01/21] docs(plan): native docs explorer --- docs/plans/2026-08-29-native-docs-explorer.md | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 docs/plans/2026-08-29-native-docs-explorer.md 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..3d22ed9 --- /dev/null +++ b/docs/plans/2026-08-29-native-docs-explorer.md @@ -0,0 +1,55 @@ +# Native docs explorer + +## 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. +- [ ] Add terminal Markdown, resource, diagram, and Kitty graphics primitives. +- [ ] Add the adaptive docs TUI and wire the native-first CLI entry points. +- [ ] Cover fallback, containment, navigation, resize, reload, and cleanup. +- [ ] Update help, README, and changelog. +- [ ] 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. From b60d52d7b9f32913317ecc6637ca24e75d01b53e Mon Sep 17 00:00:00 2001 From: Cameron Sjo <4084915+cameronsjo@users.noreply.github.com> Date: Sat, 29 Aug 2026 18:23:34 -0500 Subject: [PATCH 02/21] feat(docs): add native terminal reader --- go.mod | 15 +- go.sum | 41 ++- internal/cli/docs.go | 25 +- internal/cli/docs_browse.go | 60 ++++ internal/cli/docs_browse_test.go | 60 ++++ internal/docs/kitty_graphics.go | 174 +++++++++++ internal/docs/kitty_graphics_test.go | 68 +++++ internal/docs/terminal_render.go | 363 ++++++++++++++++++++++ internal/docs/terminal_render_test.go | 111 +++++++ internal/docstui/tui.go | 419 ++++++++++++++++++++++++++ internal/docstui/tui_test.go | 91 ++++++ 11 files changed, 1409 insertions(+), 18 deletions(-) create mode 100644 internal/cli/docs_browse.go create mode 100644 internal/cli/docs_browse_test.go create mode 100644 internal/docs/kitty_graphics.go create mode 100644 internal/docs/kitty_graphics_test.go create mode 100644 internal/docs/terminal_render.go create mode 100644 internal/docs/terminal_render_test.go create mode 100644 internal/docstui/tui.go create mode 100644 internal/docstui/tui_test.go diff --git a/go.mod b/go.mod index 0b8f347..ccb0831 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/cameronsjo/forgectl go 1.26.0 require ( + charm.land/glamour/v2 v2.0.1 github.com/BurntSushi/toml v1.6.0 github.com/alecthomas/chroma/v2 v2.27.0 github.com/charmbracelet/bubbles v1.0.0 @@ -16,22 +17,25 @@ require ( github.com/microcosm-cc/bluemonday v1.0.27 github.com/muesli/termenv v0.16.0 github.com/spf13/cobra v1.10.2 + github.com/spf13/pflag v1.0.9 github.com/yuin/goldmark v1.8.4 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc + github.com/zkrebbekx/go-mermaid v0.1.3 golang.org/x/sys v0.46.0 golang.org/x/term v0.44.0 ) require ( - charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410 // indirect + charm.land/lipgloss/v2 v2.0.4 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/catppuccin/go v0.3.0 // indirect - github.com/charmbracelet/colorprofile v0.4.1 // indirect - github.com/charmbracelet/ultraviolet v0.0.0-20251106190538-99ea45596692 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 // indirect + github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect @@ -58,8 +62,11 @@ require ( github.com/muesli/roff v0.1.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/sahilm/fuzzy v0.1.1 // indirect - github.com/spf13/pflag v1.0.9 // indirect + github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect + github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yuin/goldmark-emoji v1.0.5 // indirect + golang.org/x/image v0.0.0-20211028202545-6944b10bf410 // 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/go.sum b/go.sum index 8dde925..4b9e38f 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ -charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410 h1:D9PbaszZYpB4nj+d6HTWr1onlmlyuGVNfL9gAi8iB3k= -charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410/go.mod h1:1qZyvvVCenJO2M1ac2mX0yyiIZJoZmDM4DG4s0udJkU= +charm.land/glamour/v2 v2.0.1 h1:xl+r00A4aJWU0z8fgwKd9fQQ4rsphqGUzuEiXZP5n+c= +charm.land/glamour/v2 v2.0.1/go.mod h1:jo9z8XqVKPeEFMVdvCRLGk++RyJ3CdUwgNr7EvXLw3k= +charm.land/lipgloss/v2 v2.0.4 h1:lcPeVtcp23SNra7lHy8iYE4UC2aIipVQ47sbGyyxR5Q= +charm.land/lipgloss/v2 v2.0.4/go.mod h1:0653x8epbZSzdDfO/XPS1a/uYPOBeSsCssOpJOqDzik= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= @@ -16,8 +18,8 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= -github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= @@ -26,16 +28,16 @@ github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5f github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= -github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= -github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= github.com/charmbracelet/fang v1.0.0 h1:jESBY40agJOlLYnnv9jE0mLqDGTxEk0hkOnx7YGyRlQ= github.com/charmbracelet/fang v1.0.0/go.mod h1:P5/DNb9DddQ0Z0dbc0P3ol4/ix5Po7Ofr2KMBfAqoCo= github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw= github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/ultraviolet v0.0.0-20251106190538-99ea45596692 h1:r/3jQZ1LjWW6ybp8HHfhrKrwHIWiJhUuY7wwYIWZulQ= -github.com/charmbracelet/ultraviolet v0.0.0-20251106190538-99ea45596692/go.mod h1:Y8B4DzWeTb0ama8l3+KyopZtkE8fZjwRQ3aEAPEXHE0= +github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 h1:OqDqxQZliC7C8adA7KjelW3OjtAxREfeHkNcd66wpeI= +github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318/go.mod h1:Y6kE2GzHfkyQQVCSL9r2hwokSrIlHGzZG+71+wDYSZI= github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= @@ -48,6 +50,8 @@ github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 h1:I github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= @@ -78,6 +82,8 @@ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= +github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= +github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= @@ -92,6 +98,8 @@ github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= @@ -127,10 +135,18 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY= +github.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec= +github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY= +github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE= +github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q= +github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ= +github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -139,13 +155,20 @@ github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= github.com/yuin/goldmark v1.8.4 h1:oat/nd3U6NeQqFEL3xpEJq7d7c86NI+DbSNGAs4xnjA= github.com/yuin/goldmark v1.8.4/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk= +github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ= github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I= +github.com/zkrebbekx/go-mermaid v0.1.3 h1:FwZoPevbDlUQsEcJZ16icR83vbH0TXP74FjkDluJyYs= +github.com/zkrebbekx/go-mermaid v0.1.3/go.mod h1:QyaHQJfxlwRAosq8Nh245XMHj+XcvuL3oCK40qC0xp8= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/image v0.0.0-20211028202545-6944b10bf410 h1:hTftEOvwiOq2+O8k2D5/Q7COC7k5Qcrgc2TFURJYnvQ= +golang.org/x/image v0.0.0-20211028202545-6944b10bf410/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM= golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= @@ -156,8 +179,10 @@ golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/cli/docs.go b/internal/cli/docs.go index aa3baaf..6893725 100644 --- a/internal/cli/docs.go +++ b/internal/cli/docs.go @@ -20,15 +20,26 @@ var docsModule = module.Manifest{ // are attached as subcommands, mirroring newBenchCmd's parent/subcommand // shape. func newDocsCmd(deps module.Deps) *cobra.Command { + var graphics string cmd := &cobra.Command{ - Use: "docs", - Short: "Local markdown reader — render + serve an indexed doc set over loopback HTTP", + Use: "docs [dir|file ...]", + Short: "Browse an indexed Markdown doc set in the terminal or over loopback HTTP", + Args: cobra.ArbitraryArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if handled, err := docsHelpForNonTTY(cmd, args); handled { + return err + } + return runDocsBrowse(cmd, deps, args, graphics) + }, 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 terminal explorer as the ordinary path. +Local images, SVG, and supported Mermaid diagrams render through the Kitty +graphics protocol in compatible terminals such as Ghostty and fall back to +readable text elsewhere. The loopback HTTP reader remains available for remote +and phone access and for exact Mermaid.js rendering. + forgectl docs [dir|file ...] browse in the terminal + forgectl docs browse [dir|file ...] explicit spelling of the same reader 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 @@ -58,9 +69,11 @@ Protected servers cannot be opened directly with --open because browser navigation cannot attach an Authorization header.`, } cmd.AddCommand( + newDocsBrowseCmd(deps), newDocsServeCmd(deps), newDocsOpenCmd(deps), newDocsListCmd(deps), ) + cmd.Flags().StringVar(&graphics, "graphics", "auto", "image mode for terminal browsing: auto, kitty, or off") return cmd } diff --git a/internal/cli/docs_browse.go b/internal/cli/docs_browse.go new file mode 100644 index 0000000..fbfc656 --- /dev/null +++ b/internal/cli/docs_browse.go @@ -0,0 +1,60 @@ +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" + "golang.org/x/term" + + "github.com/cameronsjo/forgectl/internal/docs" + "github.com/cameronsjo/forgectl/internal/docstui" + "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 newDocsBrowseCmd(deps module.Deps) *cobra.Command { + var graphics string + cmd := &cobra.Command{ + Use: "browse [dir|file ...]", + Short: "Browse rendered docs in the terminal", + Args: cobra.ArbitraryArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runDocsBrowse(cmd, deps, args, graphics) + }, + } + cmd.Flags().StringVar(&graphics, "graphics", "auto", "image mode: auto, kitty, or off") + return cmd +} + +func runDocsBrowse(cmd *cobra.Command, deps module.Deps, args []string, graphics string) error { + if !docsStreamIsTerminal(cmd.InOrStdin()) || !docsStreamIsTerminal(cmd.OutOrStdout()) { + return fmt.Errorf("docs browse requires an interactive terminal; use `forgectl docs list` for text output or `forgectl docs serve` for the web reader") + } + mode, err := docs.ParseGraphicsMode(graphics) + if err != nil { + return err + } + roots, err := resolveDocsRoots(args, deps.Cfg.Docs) + if err != nil { + return err + } + idx, err := docs.NewIndex(roots) + if err != nil { + return err + } + return docstui.Run(cmd.Context(), idx, deps.Runner, mode, cmd.InOrStdin(), cmd.OutOrStdout()) +} + +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 browsing requires an interactive terminal; use `forgectl docs list` for text output or `forgectl docs serve` for the web reader") +} diff --git a/internal/cli/docs_browse_test.go b/internal/cli/docs_browse_test.go new file mode 100644 index 0000000..d9dc1c2 --- /dev/null +++ b/internal/cli/docs_browse_test.go @@ -0,0 +1,60 @@ +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(), "browse") || !strings.Contains(out.String(), "serve") { + t.Fatalf("help = %q", out.String()) + } +} + +func TestDocsBrowse_RejectsNonTTYAndInvalidGraphics(t *testing.T) { + previous := docsStreamIsTerminal + t.Cleanup(func() { docsStreamIsTerminal = previous }) + deps := module.Deps{Cfg: config.Config{}, Runner: &forgexec.FakeRunner{}} + cmd := newDocsBrowseCmd(deps) + cmd.SetIn(strings.NewReader("")) + cmd.SetOut(new(bytes.Buffer)) + docsStreamIsTerminal = func(any) bool { return false } + if err := runDocsBrowse(cmd, deps, nil, "auto"); err == nil || !strings.Contains(err.Error(), "interactive terminal") { + t.Fatalf("non-TTY error = %v", err) + } + docsStreamIsTerminal = func(any) bool { return true } + if err := runDocsBrowse(cmd, deps, nil, "sixel"); err == nil || !strings.Contains(err.Error(), "invalid graphics mode") { + t.Fatalf("invalid graphics error = %v", err) + } +} + +func TestDocsCommand_RegistersNativeAndLegacyEntrypoints(t *testing.T) { + cmd := newDocsCmd(module.Deps{Cfg: config.Config{}, Runner: &forgexec.FakeRunner{}}) + for _, name := range []string{"browse", "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 cmd.Flag("graphics") == nil { + t.Fatal("bare docs command lacks --graphics") + } +} diff --git a/internal/docs/kitty_graphics.go b/internal/docs/kitty_graphics.go new file mode 100644 index 0000000..c10284e --- /dev/null +++ b/internal/docs/kitty_graphics.go @@ -0,0 +1,174 @@ +package docs + +import ( + "bytes" + "fmt" + "hash/fnv" + "image" + "os" + "strings" + + "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/ansi/kitty" +) + +const ( + maxGraphicColumns = 96 + maxGraphicRows = 48 +) + +// GraphicsMode controls terminal image output. Auto enables Kitty graphics in +// terminals known to implement the protocol; Kitty forces it for terminals +// whose environment cannot advertise the outer emulator; Off is always text. +type GraphicsMode string + +const ( + GraphicsAuto GraphicsMode = "auto" + GraphicsKitty GraphicsMode = "kitty" + GraphicsOff GraphicsMode = "off" +) + +func ParseGraphicsMode(value string) (GraphicsMode, error) { + mode := GraphicsMode(value) + switch mode { + case GraphicsAuto, GraphicsKitty, GraphicsOff: + return mode, nil + default: + return "", fmt.Errorf("invalid graphics mode %q (want auto, kitty, or off)", value) + } +} + +// KittyGraphicsEnabled is deliberately conservative. Unknown terminals get +// readable fallbacks; --graphics=kitty is the explicit escape hatch. +func KittyGraphicsEnabled(mode GraphicsMode, getenv func(string) string) bool { + if mode == GraphicsOff { + return false + } + if mode == GraphicsKitty { + return true + } + term := strings.ToLower(getenv("TERM")) + program := strings.ToLower(getenv("TERM_PROGRAM")) + return strings.Contains(term, "kitty") || strings.Contains(term, "ghostty") || + strings.Contains(term, "wezterm") || strings.Contains(term, "konsole") || + strings.Contains(program, "kitty") || strings.Contains(program, "ghostty") || + strings.Contains(program, "wezterm") || strings.Contains(program, "konsole") +} + +// numberToDiacritic is the protocol-defined row/column table generated by +// Kitty from Unicode 17. The reader caps graphics below this table's size. +var numberToDiacritic = [...]rune{ + 0x305, 0x30d, 0x30e, 0x310, 0x312, 0x33d, 0x33e, 0x33f, + 0x346, 0x34a, 0x34b, 0x34c, 0x350, 0x351, 0x352, 0x357, + 0x35b, 0x363, 0x364, 0x365, 0x366, 0x367, 0x368, 0x369, + 0x36a, 0x36b, 0x36c, 0x36d, 0x36e, 0x36f, 0x483, 0x484, + 0x485, 0x486, 0x487, 0x592, 0x593, 0x594, 0x595, 0x597, + 0x598, 0x599, 0x59c, 0x59d, 0x59e, 0x59f, 0x5a0, 0x5a1, + 0x5a8, 0x5a9, 0x5ab, 0x5ac, 0x5af, 0x5c4, 0x610, 0x611, + 0x612, 0x613, 0x614, 0x615, 0x616, 0x617, 0x657, 0x658, + 0x659, 0x65a, 0x65b, 0x65d, 0x65e, 0x6d6, 0x6d7, 0x6d8, + 0x6d9, 0x6da, 0x6db, 0x6dc, 0x6df, 0x6e0, 0x6e1, 0x6e2, + 0x6e4, 0x6e7, 0x6e8, 0x6eb, 0x6ec, 0x730, 0x732, 0x733, + 0x735, 0x736, 0x73a, 0x73d, 0x73f, 0x740, 0x741, 0x743, +} + +// KittyImageBlock transmits img once and returns placeholder rows that a TUI +// can scroll like ordinary text. The returned IDs are deterministic for a +// given image and size, so redraws replace instead of accumulating placements. +func KittyImageBlock(img image.Image, columns int) (string, uint32, error) { + if columns < 1 { + columns = 1 + } + if columns > maxGraphicColumns { + columns = maxGraphicColumns + } + bounds := img.Bounds() + if bounds.Dx() < 1 || bounds.Dy() < 1 { + return "", 0, fmt.Errorf("image has no pixels") + } + // Terminal cells are approximately twice as tall as they are wide. + rows := (columns*bounds.Dy() + (bounds.Dx() * 2) - 1) / (bounds.Dx() * 2) + if rows < 1 { + rows = 1 + } + if rows > maxGraphicRows { + rows = maxGraphicRows + } + + h := fnv.New32a() + _, _ = fmt.Fprintf(h, "%dx%d:%dx%d", bounds.Dx(), bounds.Dy(), columns, rows) + var pixel [4]byte + for y := bounds.Min.Y; y < bounds.Max.Y; y++ { + for x := bounds.Min.X; x < bounds.Max.X; x++ { + r, g, b, a := img.At(x, y).RGBA() + pixel = [4]byte{byte(r >> 8), byte(g >> 8), byte(b >> 8), byte(a >> 8)} + _, _ = h.Write(pixel[:]) + } + } + id := h.Sum32() + // The reader caps placeholders at 96 cells, so keep the most-significant + // byte within the same checked diacritic table. + id &= 0x5fffffff + if id>>24 == 0 { + id |= 1 << 24 + } + + var tx bytes.Buffer + opts := &kitty.Options{ + Action: kitty.TransmitAndPut, + Quite: 2, + ID: int(id), + Format: kitty.PNG, + Chunk: true, + Columns: columns, + Rows: rows, + VirtualPlacement: true, + DoNotMoveCursor: true, + } + if os.Getenv("TMUX") != "" { + opts.ChunkFormatter = tmuxPassthrough + } + if err := kitty.EncodeGraphics(&tx, img, opts); err != nil { + return "", 0, err + } + + r := int(id & 0xff) + g := int((id >> 8) & 0xff) + b := int((id >> 16) & 0xff) + most := int((id >> 24) & 0xff) + var out strings.Builder + out.WriteString(tx.String()) + out.WriteString(fmt.Sprintf("\x1b[38;2;%d;%d;%dm", r, g, b)) + for row := 0; row < rows; row++ { + for col := 0; col < columns; col++ { + out.WriteRune(kitty.Placeholder) + out.WriteRune(numberToDiacritic[row]) + out.WriteRune(numberToDiacritic[col]) + out.WriteRune(numberToDiacritic[most]) + } + out.WriteString("\x1b[39m") + if row != rows-1 { + out.WriteByte('\n') + out.WriteString(fmt.Sprintf("\x1b[38;2;%d;%d;%dm", r, g, b)) + } + } + out.WriteString("\x1b[39m") + return out.String(), id, nil +} + +func tmuxPassthrough(sequence string) string { + return "\x1bPtmux;" + strings.ReplaceAll(sequence, "\x1b", "\x1b\x1b") + "\x1b\\" +} + +// KittyCleanupSequence removes image data owned by the reader on teardown. +func KittyCleanupSequence(ids []uint32) string { + var out strings.Builder + for _, id := range ids { + seq := ansi.KittyGraphics(nil, "a=d", "d=I", fmt.Sprintf("i=%d", id)) + if os.Getenv("TMUX") != "" { + seq = tmuxPassthrough(seq) + } + out.WriteString(seq) + } + return out.String() +} diff --git a/internal/docs/kitty_graphics_test.go b/internal/docs/kitty_graphics_test.go new file mode 100644 index 0000000..a986488 --- /dev/null +++ b/internal/docs/kitty_graphics_test.go @@ -0,0 +1,68 @@ +package docs + +import ( + "image" + "image/color" + "strings" + "testing" + "unicode/utf8" + + "github.com/charmbracelet/x/ansi/kitty" +) + +func TestParseGraphicsMode(t *testing.T) { + for _, value := range []string{"auto", "kitty", "off"} { + if got, err := ParseGraphicsMode(value); err != nil || string(got) != value { + t.Fatalf("ParseGraphicsMode(%q) = %q, %v", value, got, err) + } + } + if _, err := ParseGraphicsMode("sixel"); err == nil { + t.Fatal("ParseGraphicsMode(sixel) succeeded") + } +} + +func TestKittyGraphicsEnabled(t *testing.T) { + env := map[string]string{"TERM": "xterm-256color", "TERM_PROGRAM": "ghostty"} + getenv := func(key string) string { return env[key] } + if !KittyGraphicsEnabled(GraphicsAuto, getenv) { + t.Fatal("auto did not detect Ghostty") + } + if KittyGraphicsEnabled(GraphicsOff, getenv) { + t.Fatal("off enabled graphics") + } + if !KittyGraphicsEnabled(GraphicsKitty, func(string) string { return "" }) { + t.Fatal("forced kitty did not enable graphics") + } +} + +func TestKittyImageBlock_UsesPlaceholdersAndContentIdentity(t *testing.T) { + red := image.NewRGBA(image.Rect(0, 0, 4, 2)) + blue := image.NewRGBA(image.Rect(0, 0, 4, 2)) + for y := 0; y < 2; y++ { + for x := 0; x < 4; x++ { + red.Set(x, y, color.RGBA{R: 255, A: 255}) + blue.Set(x, y, color.RGBA{B: 255, A: 255}) + } + } + block, redID, err := KittyImageBlock(red, 8) + if err != nil { + t.Fatal(err) + } + _, blueID, err := KittyImageBlock(blue, 8) + if err != nil { + t.Fatal(err) + } + if redID == blueID { + t.Fatal("same-sized images received the same ID") + } + if !strings.Contains(block, "\x1b_G") || !strings.ContainsRune(block, kitty.Placeholder) { + t.Fatalf("block lacks transmission or placeholder: %q", block) + } + if !utf8.ValidString(block) { + t.Fatal("block is not valid UTF-8") + } + cleanup := KittyCleanupSequence([]uint32{redID}) + if !strings.Contains(cleanup, "a=d") || !strings.Contains(cleanup, "d=I") { + t.Fatalf("cleanup = %q", cleanup) + } +} diff --git a/internal/docs/terminal_render.go b/internal/docs/terminal_render.go new file mode 100644 index 0000000..89815b7 --- /dev/null +++ b/internal/docs/terminal_render.go @@ -0,0 +1,363 @@ +package docs + +import ( + "bytes" + "fmt" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "io" + "net/url" + "os" + "path/filepath" + "regexp" + "strings" + "unicode" + + "charm.land/glamour/v2" + "charm.land/glamour/v2/styles" + mermaid "github.com/zkrebbekx/go-mermaid" + "github.com/zkrebbekx/go-mermaid/raster" + + "github.com/cameronsjo/forgectl/internal/termsafe" +) + +const ( + maxLocalImageBytes = 32 << 20 + maxDecodedPixels = 64 << 20 + maxDiagramBytes = 1 << 20 +) + +type TerminalLink struct { + Text string + Target string +} + +type TerminalPage struct { + Content string + Links []TerminalLink + ImageIDs []uint32 +} + +type terminalSegment struct { + kind string + source string + target string + alt string +} + +var ( + standaloneImageRE = regexp.MustCompile(`^\s*!\[([^]]*)\]\(([^) ]+)(?:\s+"[^"]*")?\)\s*$`) + linkRE = regexp.MustCompile(`(^|[^!])\[([^]]+)\]\(([^) ]+)(?:\s+"[^"]*")?\)`) +) + +// RenderTerminal renders a document for a cell-based viewport. Block images, +// SVG, and Mermaid are kept as distinct segments so Kitty placeholders remain +// attached to the document rows Bubble Tea scrolls. +func RenderTerminal(source []byte, doc Doc, root Root, width int, graphics bool) (TerminalPage, error) { + if width < 20 { + width = 20 + } + safeSource := safeMarkdownSource(string(source)) + segments := splitTerminalSegments(safeSource) + page := TerminalPage{Links: terminalLinks(safeSource)} + var rendered strings.Builder + + for _, segment := range segments { + if segment.kind == "text" { + text, err := renderTerminalMarkdown(segment.source, width) + if err != nil { + return TerminalPage{}, err + } + rendered.WriteString(text) + continue + } + + fallback := mediaFallback(segment) + if !graphics { + rendered.WriteString(fallback) + continue + } + img, err := renderTerminalMedia(segment, doc, root) + if err != nil { + rendered.WriteString(fallbackWithError(fallback, err)) + continue + } + block, id, err := KittyImageBlock(img, width-4) + if err != nil { + rendered.WriteString(fallbackWithError(fallback, err)) + continue + } + rendered.WriteString("\n") + rendered.WriteString(block) + rendered.WriteString("\n\n") + page.ImageIDs = append(page.ImageIDs, id) + } + page.Content = rendered.String() + return page, nil +} + +// safeMarkdownSource preserves Markdown's structural newlines and tabs while +// visibly quoting terminal controls and bidi formatting. Glamour intentionally +// emits ANSI of its own, so sanitization must happen before rendering rather +// than stripping the completed output. +func safeMarkdownSource(value string) string { + var out strings.Builder + for _, r := range value { + if r == '\n' || r == '\t' || (!termsafe.IsUnsafeTerminalRune(r) && (unicode.IsGraphic(r) || r == ' ')) { + out.WriteRune(r) + continue + } + out.WriteString(termsafe.SafeLine(string(r))) + } + return out.String() +} + +func renderTerminalMarkdown(source string, width int) (string, error) { + style := styles.DarkStyleConfig + style.Document.Color = stringPtr("#c5c8c6") + style.Heading.Color = stringPtr("#B0B9F9") + style.H1.Color = stringPtr("#f0c674") + style.H1.BackgroundColor = nil + style.Link.Color = stringPtr("#8abeb7") + style.LinkText.Color = stringPtr("#8abeb7") + style.Code.Color = stringPtr("#b5bd68") + style.BlockQuote.Color = stringPtr("#8abeb7") + + renderer, err := glamour.NewTermRenderer( + glamour.WithStyles(style), + glamour.WithWordWrap(width), + ) + if err != nil { + return "", fmt.Errorf("terminal markdown renderer: %w", err) + } + out, err := renderer.Render(source) + if err != nil { + return "", fmt.Errorf("render terminal markdown: %w", err) + } + return out, nil +} + +func stringPtr(value string) *string { return &value } + +func splitTerminalSegments(source string) []terminalSegment { + lines := strings.SplitAfter(source, "\n") + var segments []terminalSegment + var text strings.Builder + flush := func() { + if text.Len() == 0 { + return + } + segments = append(segments, terminalSegment{kind: "text", source: text.String()}) + text.Reset() + } + + for i := 0; i < len(lines); i++ { + line := strings.TrimSuffix(lines[i], "\n") + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "```mermaid") { + flush() + var diagram strings.Builder + for i++; i < len(lines); i++ { + candidate := strings.TrimSuffix(lines[i], "\n") + if strings.TrimSpace(candidate) == "```" { + break + } + diagram.WriteString(lines[i]) + } + segments = append(segments, terminalSegment{kind: "mermaid", source: diagram.String()}) + continue + } + if match := standaloneImageRE.FindStringSubmatch(line); match != nil { + flush() + segments = append(segments, terminalSegment{kind: "image", alt: match[1], target: match[2]}) + continue + } + if strings.Contains(trimmed, "") && i+1 < len(lines) { + i++ + svg.WriteString(lines[i]) + } + segments = append(segments, terminalSegment{kind: "svg", source: svg.String()}) + continue + } + text.WriteString(lines[i]) + } + flush() + return segments +} + +func terminalLinks(source string) []TerminalLink { + matches := linkRE.FindAllStringSubmatch(source, -1) + links := make([]TerminalLink, 0, len(matches)) + for _, match := range matches { + links = append(links, TerminalLink{Text: match[2], Target: match[3]}) + } + return links +} + +func mediaFallback(segment terminalSegment) string { + switch segment.kind { + case "image": + label := segment.alt + if label == "" { + label = filepath.Base(segment.target) + } + return fmt.Sprintf("\n [image: %s — %s]\n\n", termsafe.SafeLine(label), termsafe.SafeLine(segment.target)) + case "mermaid": + return "\n [Mermaid diagram — graphics unavailable]\n\n```mermaid\n" + safeMultiline(segment.source) + "```\n\n" + case "svg": + return "\n [inline SVG — graphics unavailable]\n\n" + default: + return "\n [media unavailable]\n\n" + } +} + +func fallbackWithError(fallback string, err error) string { + return fallback + fmt.Sprintf(" [rendering note: %s; use `forgectl docs serve --open` for the web reader]\n\n", termsafe.SafeLine(err.Error())) +} + +func safeMultiline(value string) string { + lines := strings.SplitAfter(value, "\n") + var out strings.Builder + for _, line := range lines { + if strings.HasSuffix(line, "\n") { + out.WriteString(termsafe.SafeLine(strings.TrimSuffix(line, "\n"))) + out.WriteByte('\n') + } else { + out.WriteString(termsafe.SafeLine(line)) + } + } + return out.String() +} + +func renderTerminalMedia(segment terminalSegment, doc Doc, root Root) (image.Image, error) { + switch segment.kind { + case "mermaid": + if len(segment.source) > maxDiagramBytes { + return nil, fmt.Errorf("Mermaid source exceeds 1 MiB") + } + pngBytes, err := raster.PNG(segment.source, 1, + mermaid.WithCustomTheme("artificer", mermaid.Palette{ + Background: "#1d1f21", NodeFill: "#282a2e", NodeStroke: "#B0B9F9", + Text: "#c5c8c6", Edge: "#8abeb7", + }), + ) + if err != nil { + return nil, fmt.Errorf("Mermaid is unsupported or invalid: %w", err) + } + img, _, err := image.Decode(bytes.NewReader(pngBytes)) + return img, err + case "svg": + if len(segment.source) > maxDiagramBytes { + return nil, fmt.Errorf("inline SVG exceeds 1 MiB") + } + pngBytes, err := raster.RasterizeSVG([]byte(segment.source), 1) + if err != nil { + return nil, err + } + img, _, err := image.Decode(bytes.NewReader(pngBytes)) + return img, err + case "image": + path, err := resolveTerminalResource(segment.target, doc, root) + if err != nil { + return nil, err + } + if strings.EqualFold(filepath.Ext(path), ".svg") { + raw, err := readBounded(path, maxLocalImageBytes) + if err != nil { + return nil, err + } + pngBytes, err := raster.RasterizeSVG(raw, 1) + if err != nil { + return nil, err + } + img, _, err := image.Decode(bytes.NewReader(pngBytes)) + return img, err + } + return decodeBoundedImage(path) + default: + return nil, fmt.Errorf("unsupported media kind %q", segment.kind) + } +} + +func resolveTerminalResource(target string, doc Doc, root Root) (string, error) { + u, err := url.Parse(target) + if err != nil { + return "", fmt.Errorf("invalid image target") + } + if u.Scheme != "" || u.Host != "" || strings.HasPrefix(target, "//") { + return "", fmt.Errorf("remote images are disabled") + } + if u.Path == "" || filepath.IsAbs(filepath.FromSlash(u.Path)) { + return "", fmt.Errorf("image path must be relative") + } + candidate := filepath.Join(filepath.Dir(doc.AbsPath), filepath.FromSlash(u.Path)) + resolved, err := filepath.EvalSymlinks(candidate) + if err != nil { + return "", fmt.Errorf("resolve image: %w", err) + } + resolved = filepath.Clean(resolved) + if !withinRoot(root.Path, resolved) { + return "", fmt.Errorf("image escapes docs root") + } + if root.OnlyFile != "" { + return "", fmt.Errorf("single-file roots do not grant sibling image access") + } + switch strings.ToLower(filepath.Ext(resolved)) { + case ".png", ".jpg", ".jpeg", ".gif", ".svg": + default: + return "", fmt.Errorf("unsupported local image format") + } + info, err := os.Stat(resolved) + if err != nil || !info.Mode().IsRegular() { + return "", fmt.Errorf("image is not a regular file") + } + if info.Size() > maxLocalImageBytes { + return "", fmt.Errorf("image exceeds 32 MiB") + } + return resolved, nil +} + +func decodeBoundedImage(path string) (image.Image, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + config, _, err := image.DecodeConfig(io.LimitReader(f, maxLocalImageBytes+1)) + if err != nil { + return nil, fmt.Errorf("decode image metadata: %w", err) + } + if config.Width < 1 || config.Height < 1 || int64(config.Width)*int64(config.Height) > maxDecodedPixels { + return nil, fmt.Errorf("decoded image exceeds 64 megapixels") + } + if _, err := f.Seek(0, io.SeekStart); err != nil { + return nil, err + } + img, _, err := image.Decode(io.LimitReader(f, maxLocalImageBytes+1)) + if err != nil { + return nil, fmt.Errorf("decode image: %w", err) + } + return img, nil +} + +func readBounded(path string, limit int64) ([]byte, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + raw, err := io.ReadAll(io.LimitReader(f, limit+1)) + if err != nil { + return nil, err + } + if int64(len(raw)) > limit { + return nil, fmt.Errorf("image exceeds 32 MiB") + } + return raw, nil +} diff --git a/internal/docs/terminal_render_test.go b/internal/docs/terminal_render_test.go new file mode 100644 index 0000000..d3b359c --- /dev/null +++ b/internal/docs/terminal_render_test.go @@ -0,0 +1,111 @@ +package docs + +import ( + "image" + "image/color" + "image/png" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/charmbracelet/x/ansi/kitty" +) + +func TestRenderTerminal_TextLinksAndFallbacks(t *testing.T) { + dir := t.TempDir() + docPath := filepath.Join(dir, "README.md") + source := []byte("# Hello\n\n[Next](next.md)\n\n![remote](https://example.com/x.png)\n") + if err := os.WriteFile(docPath, source, 0o600); err != nil { + t.Fatal(err) + } + doc := Doc{RootLabel: "root", RelPath: "README.md", AbsPath: docPath, Title: "Hello"} + page, err := RenderTerminal(source, doc, Root{Label: "root", Path: dir}, 60, false) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(page.Content, "Hello") || !strings.Contains(page.Content, "image: remote") { + t.Fatalf("content = %q", page.Content) + } + if len(page.Links) != 1 || page.Links[0].Target != "next.md" { + t.Fatalf("links = %+v", page.Links) + } + if len(page.ImageIDs) != 0 || strings.ContainsRune(page.Content, kitty.Placeholder) { + t.Fatal("graphics-off page emitted graphics") + } +} + +func TestRenderTerminal_LocalImageAndMermaid(t *testing.T) { + dir := t.TempDir() + docPath := filepath.Join(dir, "README.md") + dir, err := filepath.EvalSymlinks(dir) + if err != nil { + t.Fatal(err) + } + docPath = filepath.Join(dir, "README.md") + imagePath := filepath.Join(dir, "pixel.png") + img := image.NewRGBA(image.Rect(0, 0, 8, 4)) + img.Set(0, 0, color.RGBA{R: 255, A: 255}) + f, err := os.Create(imagePath) + if err != nil { + t.Fatal(err) + } + if err := png.Encode(f, img); err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + source := []byte("![pixel](pixel.png)\n\n```mermaid\ngraph LR\nA --> B\n```\n") + if err := os.WriteFile(docPath, source, 0o600); err != nil { + t.Fatal(err) + } + doc := Doc{RootLabel: "root", RelPath: "README.md", AbsPath: docPath} + page, err := RenderTerminal(source, doc, Root{Label: "root", Path: dir}, 60, true) + if err != nil { + t.Fatal(err) + } + if len(page.ImageIDs) != 2 { + t.Fatalf("image IDs = %v, content = %q", page.ImageIDs, page.Content) + } + if strings.Count(page.Content, string(kitty.Placeholder)) == 0 { + t.Fatal("rendered media has no placeholders") + } +} + +func TestRenderTerminal_RejectsEscapingAndRemoteImages(t *testing.T) { + dir := t.TempDir() + docPath := filepath.Join(dir, "README.md") + doc := Doc{RootLabel: "root", RelPath: "README.md", AbsPath: docPath} + root := Root{Label: "root", Path: dir} + for _, target := range []string{"../outside.png", "https://example.com/x.png", "/tmp/x.png"} { + _, err := resolveTerminalResource(target, doc, root) + if err == nil { + t.Fatalf("resolveTerminalResource(%q) succeeded", target) + } + } +} + +func TestRenderTerminal_FallbackNeutralizesControls(t *testing.T) { + segment := terminalSegment{kind: "mermaid", source: "graph LR\nA[\x1b[2J]-->B\n"} + fallback := mediaFallback(segment) + if strings.ContainsRune(fallback, '\x1b') || !strings.Contains(fallback, `\x1b`) { + t.Fatalf("fallback did not visibly neutralize control: %q", fallback) + } +} + +func TestRenderTerminal_MarkdownNeutralizesControls(t *testing.T) { + dir := t.TempDir() + docPath := filepath.Join(dir, "README.md") + doc := Doc{RootLabel: "root", RelPath: "README.md", AbsPath: docPath} + page, err := RenderTerminal([]byte("# heading\n\ntext \x1b[2J end\n"), doc, Root{Label: "root", Path: dir}, 60, false) + if err != nil { + t.Fatal(err) + } + // Glamour's own ANSI is expected. Removing CSI prefixes leaves any raw + // source escape visible to this assertion. + stripped := strings.ReplaceAll(page.Content, "\x1b[", "") + if strings.ContainsRune(stripped, '\x1b') || !strings.Contains(page.Content, `\x1b`) { + t.Fatalf("content did not visibly neutralize source control: %q", page.Content) + } +} diff --git a/internal/docstui/tui.go b/internal/docstui/tui.go new file mode 100644 index 0000000..2d4ccaf --- /dev/null +++ b/internal/docstui/tui.go @@ -0,0 +1,419 @@ +// Package docstui provides the interactive terminal reader for forgectl docs. +package docstui + +import ( + "context" + "fmt" + "io" + "net/url" + "os" + "path/filepath" + "strings" + + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" + + "github.com/cameronsjo/forgectl/internal/docs" + forgexec "github.com/cameronsjo/forgectl/internal/exec" + "github.com/cameronsjo/forgectl/internal/termsafe" +) + +const narrowWidth = 80 + +var ( + accentStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#B0B9F9")).Bold(true) + mutedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")) + errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#cc6666")) + okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#b5bd68")) +) + +type docItem struct{ doc docs.Doc } + +func (i docItem) Title() string { return termsafe.SafeLine(i.doc.Title) } +func (i docItem) Description() string { + return termsafe.SafeLine(i.doc.RootLabel + "/" + i.doc.RelPath) +} +func (i docItem) FilterValue() string { + return i.doc.Title + " " + i.doc.RootLabel + "/" + i.doc.RelPath +} + +type linkItem struct{ link docs.TerminalLink } + +func (i linkItem) Title() string { return termsafe.SafeLine(i.link.Text) } +func (i linkItem) Description() string { return termsafe.SafeLine(i.link.Target) } +func (i linkItem) FilterValue() string { return i.link.Text + " " + i.link.Target } + +type reloadMsg struct{} +type openedMsg struct{ err error } + +type location struct { + doc docs.Doc + offset int +} + +type model struct { + ctx context.Context + store *docs.Store + reloadC <-chan string + runner forgexec.Runner + graphics bool + + width, height int + focus int + docsList list.Model + linksList list.Model + reader viewport.Model + linkMode bool + pending *docs.TerminalLink + status string + + current docs.Doc + history []location + currentIDs []uint32 + allIDs []uint32 +} + +// Run owns the alternate-screen reader until the user quits or ctx is +// cancelled. The watcher and broker are the same proven reload path as the web +// reader; only the subscriber is a Bubble Tea command instead of SSE. +func Run(ctx context.Context, idx *docs.Index, runner forgexec.Runner, mode docs.GraphicsMode, in io.Reader, out io.Writer) error { + store := docs.NewStore(idx) + broker := docs.NewBroker() + reloadC, unsubscribe := broker.Subscribe() + defer unsubscribe() + defer broker.Close() + + watcher, err := docs.NewWatcher(store, broker) + if err != nil { + return fmt.Errorf("start docs live reload: %w", err) + } + defer watcher.Close() + watchCtx, stopWatch := context.WithCancel(ctx) + defer stopWatch() + go watcher.Run(watchCtx) + + m := newModel(ctx, store, reloadC, runner, docs.KittyGraphicsEnabled(mode, os.Getenv)) + p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithContext(ctx), tea.WithInput(in), tea.WithOutput(out)) + final, err := p.Run() + if fm, ok := final.(model); ok && len(fm.allIDs) > 0 { + _, _ = io.WriteString(out, docs.KittyCleanupSequence(fm.allIDs)) + } + if err != nil { + return fmt.Errorf("run docs reader: %w", err) + } + return nil +} + +func newModel(ctx context.Context, store *docs.Store, reloadC <-chan string, runner forgexec.Runner, graphics bool) model { + docsList := list.New(docItems(store.Current()), list.NewDefaultDelegate(), 0, 0) + docsList.Title = "Documents" + docsList.SetShowHelp(false) + linksList := list.New(nil, list.NewDefaultDelegate(), 0, 0) + linksList.Title = "Links" + linksList.SetShowHelp(false) + m := model{ + ctx: ctx, store: store, reloadC: reloadC, runner: runner, graphics: graphics, + docsList: docsList, linksList: linksList, reader: viewport.New(0, 0), + } + if item, ok := docsList.SelectedItem().(docItem); ok { + m.load(item.doc, "", false) + } + return m +} + +func docItems(idx *docs.Index) []list.Item { + listed := idx.List() + items := make([]list.Item, 0, len(listed)) + for _, doc := range listed { + items = append(items, docItem{doc: doc}) + } + return items +} + +func (m model) Init() tea.Cmd { return waitReload(m.reloadC) } + +func waitReload(ch <-chan string) tea.Cmd { + return func() tea.Msg { + if _, ok := <-ch; !ok { + return nil + } + return reloadMsg{} + } +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + m.applySize() + if m.current.AbsPath != "" { + m.load(m.current, "", false) + } + return m, nil + case reloadMsg: + m.reload() + return m, waitReload(m.reloadC) + case openedMsg: + if msg.err != nil { + m.status = errorStyle.Render(termsafe.SafeLine("open link: " + msg.err.Error())) + } else { + m.status = okStyle.Render("opened external link") + } + return m, nil + case tea.KeyMsg: + if m.pending != nil { + return m.confirmExternal(msg) + } + if m.linkMode { + return m.updateLinks(msg) + } + switch msg.String() { + case "ctrl+c", "q": + return m, tea.Quit + case "tab": + m.focus = (m.focus + 1) % 2 + return m, nil + case "l": + m.openLinks() + return m, nil + case "b": + m.goBack() + return m, nil + case "?": + m.status = "tab panes · / filter · enter open · l links · b back · q quit" + return m, nil + case "enter": + if m.focus == 0 { + if item, ok := m.docsList.SelectedItem().(docItem); ok { + m.load(item.doc, "", true) + if m.width < narrowWidth { + m.focus = 1 + } + } + return m, nil + } + } + } + + var cmd tea.Cmd + if m.focus == 0 { + m.docsList, cmd = m.docsList.Update(msg) + } else { + m.reader, cmd = m.reader.Update(msg) + } + return m, cmd +} + +func (m *model) applySize() { + bodyHeight := max(1, m.height-4) + if m.width < narrowWidth { + m.docsList.SetSize(max(20, m.width-2), bodyHeight) + m.reader.Width = max(20, m.width-2) + m.reader.Height = bodyHeight + return + } + left := max(28, m.width/3) + m.docsList.SetSize(left-2, bodyHeight) + m.reader.Width = max(20, m.width-left-3) + m.reader.Height = bodyHeight +} + +func (m *model) load(doc docs.Doc, anchor string, push bool) { + if push && m.current.AbsPath != "" && m.current.AbsPath != doc.AbsPath { + m.history = append(m.history, location{doc: m.current, offset: m.reader.YOffset}) + } + raw, err := os.ReadFile(doc.AbsPath) + if err != nil { + m.status = errorStyle.Render(termsafe.SafeLine(err.Error())) + return + } + root, ok := rootFor(m.store.Current(), doc.RootLabel) + if !ok { + m.status = errorStyle.Render("document root disappeared") + return + } + page, err := docs.RenderTerminal(raw, doc, root, max(20, m.reader.Width), m.graphics) + if err != nil { + m.status = errorStyle.Render(termsafe.SafeLine(err.Error())) + return + } + if len(m.currentIDs) > 0 { + page.Content = docs.KittyCleanupSequence(m.currentIDs) + page.Content + } + m.current = doc + m.currentIDs = page.ImageIDs + m.allIDs = append(m.allIDs, page.ImageIDs...) + m.reader.SetContent(page.Content) + m.reader.GotoTop() + if anchor != "" { + m.reader.SetYOffset(findAnchorLine(page.Content, anchor)) + } + items := make([]list.Item, 0, len(page.Links)) + for _, link := range page.Links { + items = append(items, linkItem{link: link}) + } + m.linksList.SetItems(items) + m.status = "" +} + +func rootFor(idx *docs.Index, label string) (docs.Root, bool) { + for _, root := range idx.Roots() { + if root.Label == label { + return root, true + } + } + return docs.Root{}, false +} + +func (m *model) reload() { + idx := m.store.Current() + selected := m.current.AbsPath + m.docsList.SetItems(docItems(idx)) + if selected == "" { + return + } + if doc, ok := idx.FindByAbsPath(selected); ok { + offset := m.reader.YOffset + m.load(doc, "", false) + m.reader.SetYOffset(offset) + m.status = okStyle.Render("reloaded") + return + } + if item, ok := m.docsList.SelectedItem().(docItem); ok { + m.load(item.doc, "", false) + m.status = errorStyle.Render("current document was removed") + } +} + +func (m *model) openLinks() { + if len(m.linksList.Items()) == 0 { + m.status = mutedStyle.Render("this document has no links") + return + } + m.linkMode = true + m.linksList.SetSize(max(20, m.width-4), max(6, m.height-4)) +} + +func (m model) updateLinks(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "q", "esc": + m.linkMode = false + return m, nil + case "enter": + item, ok := m.linksList.SelectedItem().(linkItem) + if !ok { + return m, nil + } + m.linkMode = false + return m.follow(item.link) + } + var cmd tea.Cmd + m.linksList, cmd = m.linksList.Update(msg) + return m, cmd +} + +func (m model) follow(link docs.TerminalLink) (tea.Model, tea.Cmd) { + u, err := url.Parse(link.Target) + if err != nil { + m.status = errorStyle.Render("invalid link") + return m, nil + } + if u.Scheme == "http" || u.Scheme == "https" { + m.pending = &link + m.status = fmt.Sprintf("Open %s in the system browser? y/n", termsafe.SafeLine(link.Target)) + return m, nil + } + if u.Scheme != "" || u.Host != "" { + m.status = errorStyle.Render("unsupported link scheme") + return m, nil + } + if u.Path == "" { + m.load(m.current, u.Fragment, false) + return m, nil + } + candidate := filepath.Join(filepath.Dir(m.current.AbsPath), filepath.FromSlash(u.Path)) + resolved, err := filepath.EvalSymlinks(candidate) + if err != nil { + m.status = errorStyle.Render("linked document is unavailable") + return m, nil + } + doc, ok := m.store.Current().FindByAbsPath(filepath.Clean(resolved)) + if !ok { + m.status = errorStyle.Render("linked document is outside the index") + return m, nil + } + m.load(doc, u.Fragment, true) + return m, nil +} + +func (m model) confirmExternal(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "y", "Y": + target := m.pending.Target + m.pending = nil + m.status = "opening external link…" + return m, func() tea.Msg { + return openedMsg{err: docs.OpenBrowser(m.ctx, m.runner, target)} + } + case "n", "N", "esc": + m.pending = nil + m.status = mutedStyle.Render("link not opened") + } + return m, nil +} + +func (m *model) goBack() { + if len(m.history) == 0 { + m.status = mutedStyle.Render("no previous document") + return + } + last := m.history[len(m.history)-1] + m.history = m.history[:len(m.history)-1] + m.load(last.doc, "", false) + m.reader.SetYOffset(last.offset) +} + +func findAnchorLine(content, anchor string) int { + want := strings.ReplaceAll(strings.ToLower(anchor), "-", " ") + for lineNo, line := range strings.Split(ansi.Strip(content), "\n") { + normalized := strings.ToLower(strings.TrimSpace(line)) + if normalized == want || strings.Contains(normalized, want) { + return lineNo + } + } + return 0 +} + +func (m model) View() string { + header := accentStyle.Render("◆ forgectl docs") + if m.current.Title != "" { + header += mutedStyle.Render(" · " + termsafe.SafeLine(m.current.Title)) + } + footer := mutedStyle.Render("tab panes · / filter · enter open · l links · b back · ? help · q quit") + if m.status != "" { + footer = m.status + "\n" + footer + } + if m.pending != nil { + footer = m.status + } + var body string + if m.linkMode { + body = m.linksList.View() + } else if m.width < narrowWidth { + if m.focus == 0 { + body = m.docsList.View() + } else { + body = m.reader.View() + } + } else { + body = lipgloss.JoinHorizontal(lipgloss.Top, + lipgloss.NewStyle().Width(m.docsList.Width()).Render(m.docsList.View()), + mutedStyle.Render("│ "), + m.reader.View(), + ) + } + return lipgloss.JoinVertical(lipgloss.Left, header, body, footer) +} diff --git a/internal/docstui/tui_test.go b/internal/docstui/tui_test.go new file mode 100644 index 0000000..c193f39 --- /dev/null +++ b/internal/docstui/tui_test.go @@ -0,0 +1,91 @@ +package docstui + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/cameronsjo/forgectl/internal/docs" + forgexec "github.com/cameronsjo/forgectl/internal/exec" +) + +func testModel(t *testing.T) model { + t.Helper() + dir := t.TempDir() + for name, body := range map[string]string{ + "README.md": "# Home\n\n[Next](next.md)\n\n[Site](https://example.com)\n", + "next.md": "# Next\n\nBody\n", + } { + if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o600); err != nil { + t.Fatal(err) + } + } + idx, err := docs.NewIndex([]string{dir}) + if err != nil { + t.Fatal(err) + } + reloadC := make(chan string) + m := newModel(context.Background(), docs.NewStore(idx), reloadC, &forgexec.FakeRunner{}, false) + m.width, m.height = 100, 30 + m.applySize() + // Select Home regardless of recency ordering. + for _, doc := range idx.List() { + if doc.Title == "Home" { + m.load(doc, "", false) + } + } + return m +} + +func TestModel_AdaptiveLayoutAndNavigation(t *testing.T) { + m := testModel(t) + if !strings.Contains(m.View(), "Home") { + t.Fatalf("wide view lacks current document: %q", m.View()) + } + updated, _ := m.Update(tea.WindowSizeMsg{Width: 60, Height: 20}) + m = updated.(model) + if m.width >= narrowWidth || m.reader.Width != 58 { + t.Fatalf("narrow sizing: width=%d reader=%d", m.width, m.reader.Width) + } + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyTab}) + m = updated.(model) + if m.focus != 1 { + t.Fatalf("focus = %d, want reader", m.focus) + } +} + +func TestModel_InternalLinkHistoryAndExternalConfirmation(t *testing.T) { + m := testModel(t) + var internal, external docs.TerminalLink + for _, item := range m.linksList.Items() { + link := item.(linkItem).link + if strings.HasPrefix(link.Target, "http") { + external = link + } else { + internal = link + } + } + updated, _ := m.follow(internal) + m = updated.(model) + if m.current.Title != "Next" || len(m.history) != 1 { + t.Fatalf("internal navigation: current=%q history=%d", m.current.Title, len(m.history)) + } + m.goBack() + if m.current.Title != "Home" { + t.Fatalf("back returned to %q", m.current.Title) + } + updated, _ = m.follow(external) + m = updated.(model) + if m.pending == nil || !strings.Contains(m.status, "system browser") { + t.Fatalf("external link did not require confirmation: pending=%v status=%q", m.pending, m.status) + } + updated, _ = m.confirmExternal(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}}) + m = updated.(model) + if m.pending != nil { + t.Fatal("declined external link remained pending") + } +} From bb82b9d938aadd3e6085ddfa2c06bff5615272f4 Mon Sep 17 00:00:00 2001 From: Cameron Sjo <4084915+cameronsjo@users.noreply.github.com> Date: Sat, 29 Aug 2026 18:28:09 -0500 Subject: [PATCH 03/21] docs: explain native terminal reader --- README.md | 23 +++++++++++++++++++++-- internal/cli/docs.go | 14 +++++++------- internal/cli/init_cmd.go | 2 +- internal/config/config.go | 2 +- internal/docs/index.go | 16 ++++++++-------- 5 files changed, 38 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index ba37ed7..ec95eed 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 | Usage below | +| `docs` | Native terminal docs explorer, with the loopback web reader retained as a fallback | Usage below | | `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](#proxy--current-shell-profiles) | | `k8s` | Safely stream ordinary kubectl logs, plus bounded namespace/exec/inspect helpers | [k8s](#k8s--bounded-terminal-safe-log-streaming) | @@ -201,11 +201,16 @@ 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 — native terminal explorer; no separate browser for ordinary reading +forgectl docs [dir|file ...] # browse with the adaptive terminal UI (cwd by default) +forgectl docs browse [dir|file ...] # explicit spelling of the same terminal reader +forgectl docs --graphics off # keep the TUI but render media as readable text fallbacks forgectl docs serve [dir|file ...] # render + serve, loopback-only (DNS-rebinding-safe) forgectl docs serve --open # also open the system browser forgectl docs list [dir|file ...] # list the indexed docs, no server (--json for scripting) +# terminal docs keys: tab panes · / filter · enter open · l links · b back · q quit + # net — check cached reachability of the configured probe endpoint forgectl net # show the cached (or freshly probed) answer forgectl net --refresh # force a new probe, bypassing the cache @@ -300,6 +305,20 @@ forgectl y last 5 # print the 5 most recent zsh commands, # acknowledgement only: forgectl does not scan or redact the history ``` +The docs terminal reader uses a two-pane document list and reader when space +allows, then collapses to one pane in a narrow terminal. Relative Markdown +links stay inside the reader; opening an external HTTP link always asks first. + +"Kitty graphics" is a terminal escape-sequence protocol for placing images in +terminal cells. It does not require the Kitty app: Ghostty and several other +terminal emulators implement the same protocol. `--graphics auto` (the default) +enables it only for a recognized terminal, `--graphics kitty` forces it, and +`--graphics off` disables image escape sequences. The reader supports local +PNG, JPEG, static GIF, SVG, and the Mermaid syntax handled by its pure-Go +renderer; remote images, invalid diagrams, and unsupported Mermaid features +remain visible as deliberate text fallbacks. Use `docs serve` when you need +remote or phone access, or the browser reader's Mermaid.js compatibility. + The cask doesn't stage an `fx` command — it's a shell alias you add yourself: ```sh diff --git a/internal/cli/docs.go b/internal/cli/docs.go index 6893725..63ce03d 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, @@ -47,10 +46,11 @@ and phone access and for exact Mermaid.js rendering. 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). +In the terminal, a fenced code block tagged mermaid is rendered by a pure-Go +renderer when supported and otherwise remains visible as source. In the web +reader, Mermaid.js renders those 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). 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/init_cmd.go b/internal/cli/init_cmd.go index fb35dd5..a02a55b 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: terminal + web 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..78da46f 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 — terminal + web 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/index.go b/internal/docs/index.go index 4eefb84..4430387 100644 --- a/internal/docs/index.go +++ b/internal/docs/index.go @@ -1,12 +1,12 @@ -// 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 for both a terminal explorer and the retained 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 both reader +// frontends. The terminal path uses local-only media resolution and Kitty +// graphics; the web path renders sanitized HTML and notifies browsers over SSE. package docs import ( From a5798b83e159bbeb5a1ee9f22fa2b34b2af541a6 Mon Sep 17 00:00:00 2001 From: Cameron Sjo <4084915+cameronsjo@users.noreply.github.com> Date: Sat, 29 Aug 2026 18:35:42 -0500 Subject: [PATCH 04/21] fix(docs): preserve Kitty image payloads --- docs/plans/2026-08-29-native-docs-explorer.md | 8 ++--- internal/docs/kitty_graphics.go | 31 +++++++++++-------- internal/docs/kitty_graphics_test.go | 15 +++++---- internal/docs/terminal_render.go | 20 ++++++++---- internal/docs/terminal_render_test.go | 8 +++-- internal/docstui/tui.go | 31 ++++++++++--------- internal/docstui/tui_test.go | 24 ++++++++++---- 7 files changed, 85 insertions(+), 52 deletions(-) diff --git a/docs/plans/2026-08-29-native-docs-explorer.md b/docs/plans/2026-08-29-native-docs-explorer.md index 3d22ed9..8535ebd 100644 --- a/docs/plans/2026-08-29-native-docs-explorer.md +++ b/docs/plans/2026-08-29-native-docs-explorer.md @@ -37,10 +37,10 @@ graphics are unavailable. - [x] Create an isolated worktree and feature branch from fresh `origin/main`. - [x] Persist and commit the approved plan before implementation. -- [ ] Add terminal Markdown, resource, diagram, and Kitty graphics primitives. -- [ ] Add the adaptive docs TUI and wire the native-first CLI entry points. -- [ ] Cover fallback, containment, navigation, resize, reload, and cleanup. -- [ ] Update help, README, and changelog. +- [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, README, and changelog. - [ ] Run fresh build, vet, tests, formatting, lint, and Ghostty acceptance. ## Acceptance diff --git a/internal/docs/kitty_graphics.go b/internal/docs/kitty_graphics.go index c10284e..c3948a5 100644 --- a/internal/docs/kitty_graphics.go +++ b/internal/docs/kitty_graphics.go @@ -2,6 +2,7 @@ package docs import ( "bytes" + "encoding/binary" "fmt" "hash/fnv" "image" @@ -72,10 +73,12 @@ var numberToDiacritic = [...]rune{ 0x735, 0x736, 0x73a, 0x73d, 0x73f, 0x740, 0x741, 0x743, } -// KittyImageBlock transmits img once and returns placeholder rows that a TUI -// can scroll like ordinary text. The returned IDs are deterministic for a -// given image and size, so redraws replace instead of accumulating placements. -func KittyImageBlock(img image.Image, columns int) (string, uint32, error) { +// KittyImageBlock encodes img for transmission and returns separate placeholder +// rows that a TUI can scroll like ordinary text. Keeping the protocol bytes out +// of layout composition is important: width-aware renderers may deliberately +// discard an APC payload while measuring it. The returned IDs are deterministic +// for a given image and size, so redraws replace rather than accumulate. +func KittyImageBlock(img image.Image, columns int) (transmission, placeholders string, id uint32, err error) { if columns < 1 { columns = 1 } @@ -84,7 +87,7 @@ func KittyImageBlock(img image.Image, columns int) (string, uint32, error) { } bounds := img.Bounds() if bounds.Dx() < 1 || bounds.Dy() < 1 { - return "", 0, fmt.Errorf("image has no pixels") + return "", "", 0, fmt.Errorf("image has no pixels") } // Terminal cells are approximately twice as tall as they are wide. rows := (columns*bounds.Dy() + (bounds.Dx() * 2) - 1) / (bounds.Dx() * 2) @@ -97,15 +100,18 @@ func KittyImageBlock(img image.Image, columns int) (string, uint32, error) { h := fnv.New32a() _, _ = fmt.Fprintf(h, "%dx%d:%dx%d", bounds.Dx(), bounds.Dy(), columns, rows) - var pixel [4]byte + var pixel [16]byte for y := bounds.Min.Y; y < bounds.Max.Y; y++ { for x := bounds.Min.X; x < bounds.Max.X; x++ { r, g, b, a := img.At(x, y).RGBA() - pixel = [4]byte{byte(r >> 8), byte(g >> 8), byte(b >> 8), byte(a >> 8)} + binary.BigEndian.PutUint32(pixel[0:4], r) + binary.BigEndian.PutUint32(pixel[4:8], g) + binary.BigEndian.PutUint32(pixel[8:12], b) + binary.BigEndian.PutUint32(pixel[12:16], a) _, _ = h.Write(pixel[:]) } } - id := h.Sum32() + id = h.Sum32() // The reader caps placeholders at 96 cells, so keep the most-significant // byte within the same checked diacritic table. id &= 0x5fffffff @@ -129,7 +135,7 @@ func KittyImageBlock(img image.Image, columns int) (string, uint32, error) { opts.ChunkFormatter = tmuxPassthrough } if err := kitty.EncodeGraphics(&tx, img, opts); err != nil { - return "", 0, err + return "", "", 0, err } r := int(id & 0xff) @@ -137,8 +143,7 @@ func KittyImageBlock(img image.Image, columns int) (string, uint32, error) { b := int((id >> 16) & 0xff) most := int((id >> 24) & 0xff) var out strings.Builder - out.WriteString(tx.String()) - out.WriteString(fmt.Sprintf("\x1b[38;2;%d;%d;%dm", r, g, b)) + _, _ = fmt.Fprintf(&out, "\x1b[38;2;%d;%d;%dm", r, g, b) for row := 0; row < rows; row++ { for col := 0; col < columns; col++ { out.WriteRune(kitty.Placeholder) @@ -149,11 +154,11 @@ func KittyImageBlock(img image.Image, columns int) (string, uint32, error) { out.WriteString("\x1b[39m") if row != rows-1 { out.WriteByte('\n') - out.WriteString(fmt.Sprintf("\x1b[38;2;%d;%d;%dm", r, g, b)) + _, _ = fmt.Fprintf(&out, "\x1b[38;2;%d;%d;%dm", r, g, b) } } out.WriteString("\x1b[39m") - return out.String(), id, nil + return tx.String(), out.String(), id, nil } func tmuxPassthrough(sequence string) string { diff --git a/internal/docs/kitty_graphics_test.go b/internal/docs/kitty_graphics_test.go index a986488..dabed06 100644 --- a/internal/docs/kitty_graphics_test.go +++ b/internal/docs/kitty_graphics_test.go @@ -44,22 +44,25 @@ func TestKittyImageBlock_UsesPlaceholdersAndContentIdentity(t *testing.T) { blue.Set(x, y, color.RGBA{B: 255, A: 255}) } } - block, redID, err := KittyImageBlock(red, 8) + transmission, placeholders, redID, err := KittyImageBlock(red, 8) if err != nil { t.Fatal(err) } - _, blueID, err := KittyImageBlock(blue, 8) + _, _, blueID, err := KittyImageBlock(blue, 8) if err != nil { t.Fatal(err) } if redID == blueID { t.Fatal("same-sized images received the same ID") } - if !strings.Contains(block, "\x1b_G") || !strings.ContainsRune(block, kitty.Placeholder) { - t.Fatalf("block lacks transmission or placeholder: %q", block) + if !strings.Contains(transmission, "\x1b_G") || !strings.Contains(transmission, ";") || strings.ContainsRune(transmission, kitty.Placeholder) { + t.Fatalf("transmission = %q", transmission) } - if !utf8.ValidString(block) { - t.Fatal("block is not valid UTF-8") + if !strings.ContainsRune(placeholders, kitty.Placeholder) || strings.Contains(placeholders, "\x1b_G") { + t.Fatalf("placeholders = %q", placeholders) + } + if !utf8.ValidString(transmission) || !utf8.ValidString(placeholders) { + t.Fatal("Kitty output is not valid UTF-8") } cleanup := KittyCleanupSequence([]uint32{redID}) if !strings.Contains(cleanup, "a=d") || !strings.Contains(cleanup, "d=I") { diff --git a/internal/docs/terminal_render.go b/internal/docs/terminal_render.go index 89815b7..8c845d8 100644 --- a/internal/docs/terminal_render.go +++ b/internal/docs/terminal_render.go @@ -36,6 +36,7 @@ type TerminalLink struct { type TerminalPage struct { Content string + Graphics string Links []TerminalLink ImageIDs []uint32 } @@ -84,13 +85,14 @@ func RenderTerminal(source []byte, doc Doc, root Root, width int, graphics bool) rendered.WriteString(fallbackWithError(fallback, err)) continue } - block, id, err := KittyImageBlock(img, width-4) + transmission, placeholders, id, err := KittyImageBlock(img, width-4) if err != nil { rendered.WriteString(fallbackWithError(fallback, err)) continue } rendered.WriteString("\n") - rendered.WriteString(block) + page.Graphics += transmission + rendered.WriteString(placeholders) rendered.WriteString("\n\n") page.ImageIDs = append(page.ImageIDs, id) } @@ -239,7 +241,7 @@ func renderTerminalMedia(segment terminalSegment, doc Doc, root Root) (image.Ima switch segment.kind { case "mermaid": if len(segment.source) > maxDiagramBytes { - return nil, fmt.Errorf("Mermaid source exceeds 1 MiB") + return nil, fmt.Errorf("Mermaid source exceeds 1 MiB") //nolint:staticcheck // Mermaid is a proper name. } pngBytes, err := raster.PNG(segment.source, 1, mermaid.WithCustomTheme("artificer", mermaid.Palette{ @@ -248,7 +250,7 @@ func renderTerminalMedia(segment terminalSegment, doc Doc, root Root) (image.Ima }), ) if err != nil { - return nil, fmt.Errorf("Mermaid is unsupported or invalid: %w", err) + return nil, fmt.Errorf("Mermaid is unsupported or invalid: %w", err) //nolint:staticcheck // Mermaid is a proper name. } img, _, err := image.Decode(bytes.NewReader(pngBytes)) return img, err @@ -324,11 +326,14 @@ func resolveTerminalResource(target string, doc Doc, root Root) (string, error) } func decodeBoundedImage(path string) (image.Image, error) { + // path was canonicalized, contained to the selected docs root, extension + // checked, and statted as a regular file by resolveTerminalResource. + // #nosec G304 -- this is the validated local resource the user selected. f, err := os.Open(path) if err != nil { return nil, err } - defer f.Close() + defer func() { _ = f.Close() }() config, _, err := image.DecodeConfig(io.LimitReader(f, maxLocalImageBytes+1)) if err != nil { return nil, fmt.Errorf("decode image metadata: %w", err) @@ -347,11 +352,14 @@ func decodeBoundedImage(path string) (image.Image, error) { } func readBounded(path string, limit int64) ([]byte, error) { + // path passed the same canonical containment and regular-file validation as + // decodeBoundedImage. + // #nosec G304 -- this is the validated local resource the user selected. f, err := os.Open(path) if err != nil { return nil, err } - defer f.Close() + defer func() { _ = f.Close() }() raw, err := io.ReadAll(io.LimitReader(f, limit+1)) if err != nil { return nil, err diff --git a/internal/docs/terminal_render_test.go b/internal/docs/terminal_render_test.go index d3b359c..44dcba2 100644 --- a/internal/docs/terminal_render_test.go +++ b/internal/docs/terminal_render_test.go @@ -37,12 +37,11 @@ func TestRenderTerminal_TextLinksAndFallbacks(t *testing.T) { func TestRenderTerminal_LocalImageAndMermaid(t *testing.T) { dir := t.TempDir() - docPath := filepath.Join(dir, "README.md") dir, err := filepath.EvalSymlinks(dir) if err != nil { t.Fatal(err) } - docPath = filepath.Join(dir, "README.md") + docPath := filepath.Join(dir, "README.md") imagePath := filepath.Join(dir, "pixel.png") img := image.NewRGBA(image.Rect(0, 0, 8, 4)) img.Set(0, 0, color.RGBA{R: 255, A: 255}) @@ -68,9 +67,12 @@ func TestRenderTerminal_LocalImageAndMermaid(t *testing.T) { if len(page.ImageIDs) != 2 { t.Fatalf("image IDs = %v, content = %q", page.ImageIDs, page.Content) } - if strings.Count(page.Content, string(kitty.Placeholder)) == 0 { + if strings.Count(page.Content, string(kitty.Placeholder)) == 0 || !strings.Contains(page.Graphics, "\x1b_G") { t.Fatal("rendered media has no placeholders") } + if strings.Contains(page.Content, "\x1b_G") { + t.Fatal("scrollable content contains Kitty transmission bytes") + } } func TestRenderTerminal_RejectsEscapingAndRemoteImages(t *testing.T) { diff --git a/internal/docstui/tui.go b/internal/docstui/tui.go index 2d4ccaf..2c0e550 100644 --- a/internal/docstui/tui.go +++ b/internal/docstui/tui.go @@ -74,6 +74,9 @@ type model struct { history []location currentIDs []uint32 allIDs []uint32 + // graphicsPreamble is consumed by the next View before Lipgloss sees it. + // APC payloads are protocol data, not layout content. + graphicsPreamble string } // Run owns the alternate-screen reader until the user quits or ctx is @@ -90,7 +93,7 @@ func Run(ctx context.Context, idx *docs.Index, runner forgexec.Runner, mode docs if err != nil { return fmt.Errorf("start docs live reload: %w", err) } - defer watcher.Close() + defer func() { _ = watcher.Close() }() watchCtx, stopWatch := context.WithCancel(ctx) defer stopWatch() go watcher.Run(watchCtx) @@ -98,7 +101,7 @@ func Run(ctx context.Context, idx *docs.Index, runner forgexec.Runner, mode docs m := newModel(ctx, store, reloadC, runner, docs.KittyGraphicsEnabled(mode, os.Getenv)) p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithContext(ctx), tea.WithInput(in), tea.WithOutput(out)) final, err := p.Run() - if fm, ok := final.(model); ok && len(fm.allIDs) > 0 { + if fm, ok := final.(*model); ok && len(fm.allIDs) > 0 { _, _ = io.WriteString(out, docs.KittyCleanupSequence(fm.allIDs)) } if err != nil { @@ -107,14 +110,14 @@ func Run(ctx context.Context, idx *docs.Index, runner forgexec.Runner, mode docs return nil } -func newModel(ctx context.Context, store *docs.Store, reloadC <-chan string, runner forgexec.Runner, graphics bool) model { +func newModel(ctx context.Context, store *docs.Store, reloadC <-chan string, runner forgexec.Runner, graphics bool) *model { docsList := list.New(docItems(store.Current()), list.NewDefaultDelegate(), 0, 0) docsList.Title = "Documents" docsList.SetShowHelp(false) linksList := list.New(nil, list.NewDefaultDelegate(), 0, 0) linksList.Title = "Links" linksList.SetShowHelp(false) - m := model{ + m := &model{ ctx: ctx, store: store, reloadC: reloadC, runner: runner, graphics: graphics, docsList: docsList, linksList: linksList, reader: viewport.New(0, 0), } @@ -133,7 +136,7 @@ func docItems(idx *docs.Index) []list.Item { return items } -func (m model) Init() tea.Cmd { return waitReload(m.reloadC) } +func (m *model) Init() tea.Cmd { return waitReload(m.reloadC) } func waitReload(ch <-chan string) tea.Cmd { return func() tea.Msg { @@ -144,7 +147,7 @@ func waitReload(ch <-chan string) tea.Cmd { } } -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.width, m.height = msg.Width, msg.Height @@ -240,9 +243,7 @@ func (m *model) load(doc docs.Doc, anchor string, push bool) { m.status = errorStyle.Render(termsafe.SafeLine(err.Error())) return } - if len(m.currentIDs) > 0 { - page.Content = docs.KittyCleanupSequence(m.currentIDs) + page.Content - } + m.graphicsPreamble = docs.KittyCleanupSequence(m.currentIDs) + page.Graphics m.current = doc m.currentIDs = page.ImageIDs m.allIDs = append(m.allIDs, page.ImageIDs...) @@ -297,7 +298,7 @@ func (m *model) openLinks() { m.linksList.SetSize(max(20, m.width-4), max(6, m.height-4)) } -func (m model) updateLinks(msg tea.KeyMsg) (tea.Model, tea.Cmd) { +func (m *model) updateLinks(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "q", "esc": m.linkMode = false @@ -315,7 +316,7 @@ func (m model) updateLinks(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, cmd } -func (m model) follow(link docs.TerminalLink) (tea.Model, tea.Cmd) { +func (m *model) follow(link docs.TerminalLink) (tea.Model, tea.Cmd) { u, err := url.Parse(link.Target) if err != nil { m.status = errorStyle.Render("invalid link") @@ -349,7 +350,7 @@ func (m model) follow(link docs.TerminalLink) (tea.Model, tea.Cmd) { return m, nil } -func (m model) confirmExternal(msg tea.KeyMsg) (tea.Model, tea.Cmd) { +func (m *model) confirmExternal(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "y", "Y": target := m.pending.Target @@ -387,7 +388,9 @@ func findAnchorLine(content, anchor string) int { return 0 } -func (m model) View() string { +func (m *model) View() string { + preamble := m.graphicsPreamble + m.graphicsPreamble = "" header := accentStyle.Render("◆ forgectl docs") if m.current.Title != "" { header += mutedStyle.Render(" · " + termsafe.SafeLine(m.current.Title)) @@ -415,5 +418,5 @@ func (m model) View() string { m.reader.View(), ) } - return lipgloss.JoinVertical(lipgloss.Left, header, body, footer) + return preamble + lipgloss.JoinVertical(lipgloss.Left, header, body, footer) } diff --git a/internal/docstui/tui_test.go b/internal/docstui/tui_test.go index c193f39..3e23cde 100644 --- a/internal/docstui/tui_test.go +++ b/internal/docstui/tui_test.go @@ -13,7 +13,7 @@ import ( forgexec "github.com/cameronsjo/forgectl/internal/exec" ) -func testModel(t *testing.T) model { +func testModel(t *testing.T) *model { t.Helper() dir := t.TempDir() for name, body := range map[string]string{ @@ -47,17 +47,29 @@ func TestModel_AdaptiveLayoutAndNavigation(t *testing.T) { t.Fatalf("wide view lacks current document: %q", m.View()) } updated, _ := m.Update(tea.WindowSizeMsg{Width: 60, Height: 20}) - m = updated.(model) + m = updated.(*model) if m.width >= narrowWidth || m.reader.Width != 58 { t.Fatalf("narrow sizing: width=%d reader=%d", m.width, m.reader.Width) } updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyTab}) - m = updated.(model) + m = updated.(*model) if m.focus != 1 { t.Fatalf("focus = %d, want reader", m.focus) } } +func TestModel_GraphicsPreambleBypassesLayoutExactlyOnce(t *testing.T) { + m := testModel(t) + const transmission = "\x1b_Gf=100,i=7;payload\x1b\\" + m.graphicsPreamble = transmission + if got := m.View(); !strings.HasPrefix(got, transmission) { + t.Fatalf("first view altered or misplaced Kitty transmission: %q", got) + } + if got := m.View(); strings.Contains(got, transmission) { + t.Fatal("second view retransmitted consumed Kitty payload") + } +} + func TestModel_InternalLinkHistoryAndExternalConfirmation(t *testing.T) { m := testModel(t) var internal, external docs.TerminalLink @@ -70,7 +82,7 @@ func TestModel_InternalLinkHistoryAndExternalConfirmation(t *testing.T) { } } updated, _ := m.follow(internal) - m = updated.(model) + m = updated.(*model) if m.current.Title != "Next" || len(m.history) != 1 { t.Fatalf("internal navigation: current=%q history=%d", m.current.Title, len(m.history)) } @@ -79,12 +91,12 @@ func TestModel_InternalLinkHistoryAndExternalConfirmation(t *testing.T) { t.Fatalf("back returned to %q", m.current.Title) } updated, _ = m.follow(external) - m = updated.(model) + m = updated.(*model) if m.pending == nil || !strings.Contains(m.status, "system browser") { t.Fatalf("external link did not require confirmation: pending=%v status=%q", m.pending, m.status) } updated, _ = m.confirmExternal(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}}) - m = updated.(model) + m = updated.(*model) if m.pending != nil { t.Fatal("declined external link remained pending") } From 696e47b0b7fe2b3bfcbe6f24e78b63c4e39abf87 Mon Sep 17 00:00:00 2001 From: Cameron Sjo <4084915+cameronsjo@users.noreply.github.com> Date: Sat, 29 Aug 2026 18:40:07 -0500 Subject: [PATCH 05/21] fix(docs): transmit Kitty image data --- docs/plans/2026-08-29-native-docs-explorer.md | 4 ++-- internal/docs/kitty_graphics.go | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/plans/2026-08-29-native-docs-explorer.md b/docs/plans/2026-08-29-native-docs-explorer.md index 8535ebd..599ca57 100644 --- a/docs/plans/2026-08-29-native-docs-explorer.md +++ b/docs/plans/2026-08-29-native-docs-explorer.md @@ -40,8 +40,8 @@ graphics are unavailable. - [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, README, and changelog. -- [ ] Run fresh build, vet, tests, formatting, lint, and Ghostty acceptance. +- [x] Update help and README; leave generated changelog prose to Release Please. +- [x] Run fresh build, vet, tests, formatting, lint, and Ghostty acceptance. ## Acceptance diff --git a/internal/docs/kitty_graphics.go b/internal/docs/kitty_graphics.go index c3948a5..e6de43a 100644 --- a/internal/docs/kitty_graphics.go +++ b/internal/docs/kitty_graphics.go @@ -125,6 +125,7 @@ func KittyImageBlock(img image.Image, columns int) (transmission, placeholders s Quite: 2, ID: int(id), Format: kitty.PNG, + Transmission: kitty.Direct, Chunk: true, Columns: columns, Rows: rows, From aecf08ced2596b03f9a0f6f67b989d15e62b5141 Mon Sep 17 00:00:00 2001 From: Cameron Sjo <4084915+cameronsjo@users.noreply.github.com> Date: Sat, 29 Aug 2026 18:44:16 -0500 Subject: [PATCH 06/21] refactor(docs): reuse Kitty diacritics --- internal/docs/kitty_graphics.go | 25 ++++--------------------- 1 file changed, 4 insertions(+), 21 deletions(-) diff --git a/internal/docs/kitty_graphics.go b/internal/docs/kitty_graphics.go index e6de43a..69fb4b7 100644 --- a/internal/docs/kitty_graphics.go +++ b/internal/docs/kitty_graphics.go @@ -56,23 +56,6 @@ func KittyGraphicsEnabled(mode GraphicsMode, getenv func(string) string) bool { strings.Contains(program, "wezterm") || strings.Contains(program, "konsole") } -// numberToDiacritic is the protocol-defined row/column table generated by -// Kitty from Unicode 17. The reader caps graphics below this table's size. -var numberToDiacritic = [...]rune{ - 0x305, 0x30d, 0x30e, 0x310, 0x312, 0x33d, 0x33e, 0x33f, - 0x346, 0x34a, 0x34b, 0x34c, 0x350, 0x351, 0x352, 0x357, - 0x35b, 0x363, 0x364, 0x365, 0x366, 0x367, 0x368, 0x369, - 0x36a, 0x36b, 0x36c, 0x36d, 0x36e, 0x36f, 0x483, 0x484, - 0x485, 0x486, 0x487, 0x592, 0x593, 0x594, 0x595, 0x597, - 0x598, 0x599, 0x59c, 0x59d, 0x59e, 0x59f, 0x5a0, 0x5a1, - 0x5a8, 0x5a9, 0x5ab, 0x5ac, 0x5af, 0x5c4, 0x610, 0x611, - 0x612, 0x613, 0x614, 0x615, 0x616, 0x617, 0x657, 0x658, - 0x659, 0x65a, 0x65b, 0x65d, 0x65e, 0x6d6, 0x6d7, 0x6d8, - 0x6d9, 0x6da, 0x6db, 0x6dc, 0x6df, 0x6e0, 0x6e1, 0x6e2, - 0x6e4, 0x6e7, 0x6e8, 0x6eb, 0x6ec, 0x730, 0x732, 0x733, - 0x735, 0x736, 0x73a, 0x73d, 0x73f, 0x740, 0x741, 0x743, -} - // KittyImageBlock encodes img for transmission and returns separate placeholder // rows that a TUI can scroll like ordinary text. Keeping the protocol bytes out // of layout composition is important: width-aware renderers may deliberately @@ -113,7 +96,7 @@ func KittyImageBlock(img image.Image, columns int) (transmission, placeholders s } id = h.Sum32() // The reader caps placeholders at 96 cells, so keep the most-significant - // byte within the same checked diacritic table. + // byte within the same range of Kitty's published diacritic table. id &= 0x5fffffff if id>>24 == 0 { id |= 1 << 24 @@ -148,9 +131,9 @@ func KittyImageBlock(img image.Image, columns int) (transmission, placeholders s for row := 0; row < rows; row++ { for col := 0; col < columns; col++ { out.WriteRune(kitty.Placeholder) - out.WriteRune(numberToDiacritic[row]) - out.WriteRune(numberToDiacritic[col]) - out.WriteRune(numberToDiacritic[most]) + out.WriteRune(kitty.Diacritic(row)) + out.WriteRune(kitty.Diacritic(col)) + out.WriteRune(kitty.Diacritic(most)) } out.WriteString("\x1b[39m") if row != rows-1 { From 81f5015cfb6ccd40e6daba106e8adac320f28179 Mon Sep 17 00:00:00 2001 From: Cameron Sjo <4084915+cameronsjo@users.noreply.github.com> Date: Sat, 29 Aug 2026 19:03:32 -0500 Subject: [PATCH 07/21] docs(plan): pivot docs reader to embedded preview --- .../plans/2026-08-29-embedded-docs-preview.md | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 docs/plans/2026-08-29-embedded-docs-preview.md 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..e868d51 --- /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. +- [ ] Replace the bare docs/TUI entry point with embedded-cmux preview startup. +- [ ] Add persisted reading typography and measure controls. +- [ ] Serve contained local Markdown images through the loopback reader. +- [ ] Remove terminal-reader code and dependencies. +- [ ] Update help and README; leave generated changelog prose to Release Please. +- [ ] Run fresh build, vet, tests, formatting, lint, and live cmux acceptance. +- [ ] 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. From f350ca5015d178af4b730b8d49304c721f7e445c Mon Sep 17 00:00:00 2001 From: Cameron Sjo <4084915+cameronsjo@users.noreply.github.com> Date: Sat, 29 Aug 2026 19:10:51 -0500 Subject: [PATCH 08/21] feat(docs): open preview inside cmux --- .../plans/2026-08-29-embedded-docs-preview.md | 4 +- go.mod | 7 - go.sum | 25 -- internal/cli/docs.go | 28 +- internal/cli/docs_browse.go | 31 +- internal/cli/docs_browse_test.go | 18 +- internal/cli/docs_serve.go | 51 ++- internal/docs/browser.go | 26 ++ internal/docs/browser_test.go | 44 ++ internal/docs/index.go | 9 +- internal/docs/kitty_graphics.go | 163 ------- internal/docs/kitty_graphics_test.go | 71 --- internal/docs/terminal_render.go | 371 --------------- internal/docs/terminal_render_test.go | 113 ----- internal/docstui/tui.go | 422 ------------------ internal/docstui/tui_test.go | 103 ----- 16 files changed, 150 insertions(+), 1336 deletions(-) create mode 100644 internal/docs/browser_test.go delete mode 100644 internal/docs/kitty_graphics.go delete mode 100644 internal/docs/kitty_graphics_test.go delete mode 100644 internal/docs/terminal_render.go delete mode 100644 internal/docs/terminal_render_test.go delete mode 100644 internal/docstui/tui.go delete mode 100644 internal/docstui/tui_test.go diff --git a/docs/plans/2026-08-29-embedded-docs-preview.md b/docs/plans/2026-08-29-embedded-docs-preview.md index e868d51..4308348 100644 --- a/docs/plans/2026-08-29-embedded-docs-preview.md +++ b/docs/plans/2026-08-29-embedded-docs-preview.md @@ -48,10 +48,10 @@ ordinary path, and address the reading gaps exposed by the live cmux proof. - [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. -- [ ] Replace the bare docs/TUI entry point with embedded-cmux preview startup. +- [x] Replace the bare docs/TUI entry point with embedded-cmux preview startup. - [ ] Add persisted reading typography and measure controls. - [ ] Serve contained local Markdown images through the loopback reader. -- [ ] Remove terminal-reader code and dependencies. +- [x] Remove terminal-reader code and dependencies. - [ ] Update help and README; leave generated changelog prose to Release Please. - [ ] Run fresh build, vet, tests, formatting, lint, and live cmux acceptance. - [ ] Update and push the existing pull request, then monitor its checks. diff --git a/go.mod b/go.mod index ccb0831..f37b17b 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,6 @@ module github.com/cameronsjo/forgectl go 1.26.0 require ( - charm.land/glamour/v2 v2.0.1 github.com/BurntSushi/toml v1.6.0 github.com/alecthomas/chroma/v2 v2.27.0 github.com/charmbracelet/bubbles v1.0.0 @@ -20,7 +19,6 @@ require ( github.com/spf13/pflag v1.0.9 github.com/yuin/goldmark v1.8.4 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc - github.com/zkrebbekx/go-mermaid v0.1.3 golang.org/x/sys v0.46.0 golang.org/x/term v0.44.0 ) @@ -35,7 +33,6 @@ require ( github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 // indirect - github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect @@ -62,11 +59,7 @@ require ( github.com/muesli/roff v0.1.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/sahilm/fuzzy v0.1.1 // indirect - github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect - github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - github.com/yuin/goldmark-emoji v1.0.5 // indirect - golang.org/x/image v0.0.0-20211028202545-6944b10bf410 // 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/go.sum b/go.sum index 4b9e38f..2acb0fe 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,3 @@ -charm.land/glamour/v2 v2.0.1 h1:xl+r00A4aJWU0z8fgwKd9fQQ4rsphqGUzuEiXZP5n+c= -charm.land/glamour/v2 v2.0.1/go.mod h1:jo9z8XqVKPeEFMVdvCRLGk++RyJ3CdUwgNr7EvXLw3k= charm.land/lipgloss/v2 v2.0.4 h1:lcPeVtcp23SNra7lHy8iYE4UC2aIipVQ47sbGyyxR5Q= charm.land/lipgloss/v2 v2.0.4/go.mod h1:0653x8epbZSzdDfO/XPS1a/uYPOBeSsCssOpJOqDzik= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= @@ -50,8 +48,6 @@ github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 h1:I github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= -github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= -github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= @@ -82,8 +78,6 @@ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= -github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= -github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= @@ -98,8 +92,6 @@ github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= -github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= -github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= @@ -135,18 +127,10 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= -github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY= -github.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec= -github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY= -github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE= -github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q= -github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ= -github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -155,20 +139,13 @@ github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= github.com/yuin/goldmark v1.8.4 h1:oat/nd3U6NeQqFEL3xpEJq7d7c86NI+DbSNGAs4xnjA= github.com/yuin/goldmark v1.8.4/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= -github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk= -github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ= github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I= -github.com/zkrebbekx/go-mermaid v0.1.3 h1:FwZoPevbDlUQsEcJZ16icR83vbH0TXP74FjkDluJyYs= -github.com/zkrebbekx/go-mermaid v0.1.3/go.mod h1:QyaHQJfxlwRAosq8Nh245XMHj+XcvuL3oCK40qC0xp8= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= -golang.org/x/image v0.0.0-20211028202545-6944b10bf410 h1:hTftEOvwiOq2+O8k2D5/Q7COC7k5Qcrgc2TFURJYnvQ= -golang.org/x/image v0.0.0-20211028202545-6944b10bf410/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM= golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= @@ -179,10 +156,8 @@ golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/cli/docs.go b/internal/cli/docs.go index 63ce03d..9575671 100644 --- a/internal/cli/docs.go +++ b/internal/cli/docs.go @@ -19,26 +19,23 @@ var docsModule = module.Manifest{ // are attached as subcommands, mirroring newBenchCmd's parent/subcommand // shape. func newDocsCmd(deps module.Deps) *cobra.Command { - var graphics string cmd := &cobra.Command{ Use: "docs [dir|file ...]", - Short: "Browse an indexed Markdown doc set in the terminal or over loopback HTTP", + 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 runDocsBrowse(cmd, deps, args, graphics) + return runDocsPreview(cmd, deps, args) }, Long: `docs is forgectl's local markdown reader (forgectl#93): pure-Go -rendering with an Artificer-themed terminal explorer as the ordinary path. -Local images, SVG, and supported Mermaid diagrams render through the Kitty -graphics protocol in compatible terminals such as Ghostty and fall back to -readable text elsewhere. The loopback HTTP reader remains available for remote -and phone access and for exact Mermaid.js rendering. +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 ...] browse in the terminal - forgectl docs browse [dir|file ...] explicit spelling of the same reader + 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 @@ -46,11 +43,10 @@ and phone access and for exact Mermaid.js rendering. forgectl docs list [dir|file ...] list the indexed docs, no server forgectl docs list --json machine-readable output for scripts -In the terminal, a fenced code block tagged mermaid is rendered by a pure-Go -renderer when supported and otherwise remains visible as source. In the web -reader, Mermaid.js renders those 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). +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 @@ -69,11 +65,9 @@ Protected servers cannot be opened directly with --open because browser navigation cannot attach an Authorization header.`, } cmd.AddCommand( - newDocsBrowseCmd(deps), newDocsServeCmd(deps), newDocsOpenCmd(deps), newDocsListCmd(deps), ) - cmd.Flags().StringVar(&graphics, "graphics", "auto", "image mode for terminal browsing: auto, kitty, or off") return cmd } diff --git a/internal/cli/docs_browse.go b/internal/cli/docs_browse.go index fbfc656..fe3a322 100644 --- a/internal/cli/docs_browse.go +++ b/internal/cli/docs_browse.go @@ -6,8 +6,7 @@ import ( "github.com/spf13/cobra" "golang.org/x/term" - "github.com/cameronsjo/forgectl/internal/docs" - "github.com/cameronsjo/forgectl/internal/docstui" + docspkg "github.com/cameronsjo/forgectl/internal/docs" "github.com/cameronsjo/forgectl/internal/module" ) @@ -16,37 +15,19 @@ var docsStreamIsTerminal = func(stream any) bool { return ok && term.IsTerminal(int(fd.Fd())) } -func newDocsBrowseCmd(deps module.Deps) *cobra.Command { - var graphics string - cmd := &cobra.Command{ - Use: "browse [dir|file ...]", - Short: "Browse rendered docs in the terminal", - Args: cobra.ArbitraryArgs, - RunE: func(cmd *cobra.Command, args []string) error { - return runDocsBrowse(cmd, deps, args, graphics) - }, - } - cmd.Flags().StringVar(&graphics, "graphics", "auto", "image mode: auto, kitty, or off") - return cmd -} - -func runDocsBrowse(cmd *cobra.Command, deps module.Deps, args []string, graphics string) error { +func runDocsPreview(cmd *cobra.Command, deps module.Deps, args []string) error { if !docsStreamIsTerminal(cmd.InOrStdin()) || !docsStreamIsTerminal(cmd.OutOrStdout()) { - return fmt.Errorf("docs browse requires an interactive terminal; use `forgectl docs list` for text output or `forgectl docs serve` for the web reader") - } - mode, err := docs.ParseGraphicsMode(graphics) - if err != nil { - return err + 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 := docs.NewIndex(roots) + idx, err := docspkg.NewIndex(roots) if err != nil { return err } - return docstui.Run(cmd.Context(), idx, deps.Runner, mode, cmd.InOrStdin(), cmd.OutOrStdout()) + return runDocsPreviewServer(cmd, deps, idx) } func docsHelpForNonTTY(cmd *cobra.Command, args []string) (bool, error) { @@ -56,5 +37,5 @@ func docsHelpForNonTTY(cmd *cobra.Command, args []string) (bool, error) { if len(args) == 0 { return true, cmd.Help() } - return true, fmt.Errorf("docs browsing requires an interactive terminal; use `forgectl docs list` for text output or `forgectl docs serve` for the web reader") + 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_browse_test.go b/internal/cli/docs_browse_test.go index d9dc1c2..aa874a9 100644 --- a/internal/cli/docs_browse_test.go +++ b/internal/cli/docs_browse_test.go @@ -30,31 +30,27 @@ func TestDocsCommand_NonTTYBareInvocationKeepsHelpBehavior(t *testing.T) { } } -func TestDocsBrowse_RejectsNonTTYAndInvalidGraphics(t *testing.T) { +func TestDocsPreview_RejectsNonTTY(t *testing.T) { previous := docsStreamIsTerminal t.Cleanup(func() { docsStreamIsTerminal = previous }) deps := module.Deps{Cfg: config.Config{}, Runner: &forgexec.FakeRunner{}} - cmd := newDocsBrowseCmd(deps) + cmd := newDocsCmd(deps) cmd.SetIn(strings.NewReader("")) cmd.SetOut(new(bytes.Buffer)) docsStreamIsTerminal = func(any) bool { return false } - if err := runDocsBrowse(cmd, deps, nil, "auto"); err == nil || !strings.Contains(err.Error(), "interactive terminal") { + if err := runDocsPreview(cmd, deps, nil); err == nil || !strings.Contains(err.Error(), "interactive terminal") { t.Fatalf("non-TTY error = %v", err) } - docsStreamIsTerminal = func(any) bool { return true } - if err := runDocsBrowse(cmd, deps, nil, "sixel"); err == nil || !strings.Contains(err.Error(), "invalid graphics mode") { - t.Fatalf("invalid graphics error = %v", err) - } } -func TestDocsCommand_RegistersNativeAndLegacyEntrypoints(t *testing.T) { +func TestDocsCommand_RegistersPreviewAndServerEntrypoints(t *testing.T) { cmd := newDocsCmd(module.Deps{Cfg: config.Config{}, Runner: &forgexec.FakeRunner{}}) - for _, name := range []string{"browse", "serve", "open", "list"} { + 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 cmd.Flag("graphics") == nil { - t.Fatal("bare docs command lacks --graphics") + 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..281fd9a 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,18 @@ 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 { + return runDocsServeWithRuntimeMode(cmd, deps, idx, "", 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 +275,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 +478,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 +487,18 @@ 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 { + fmt.Fprintln(out, " preview: embedded in cmux (server remains in this terminal)") + } 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 +551,14 @@ 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 + } + fmt.Fprintln(out, " preview: system browser (server remains in this terminal)") +} + // 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/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 4430387..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 local // markdown reader. It indexes a closed set of root directories and renders -// their contents for both a terminal explorer and the retained loopback HTTP -// reader. It knows nothing of Cobra — that decoupling is the house pattern +// their contents through the loopback HTTP reader. It knows nothing of Cobra — +// that decoupling is the house pattern // (see internal/tmux, internal/net). // -// A filesystem Watcher rebuilds the Index and a Broker notifies both reader -// frontends. The terminal path uses local-only media resolution and Kitty -// graphics; the web path renders sanitized HTML and notifies browsers over SSE. +// 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/kitty_graphics.go b/internal/docs/kitty_graphics.go deleted file mode 100644 index 69fb4b7..0000000 --- a/internal/docs/kitty_graphics.go +++ /dev/null @@ -1,163 +0,0 @@ -package docs - -import ( - "bytes" - "encoding/binary" - "fmt" - "hash/fnv" - "image" - "os" - "strings" - - "github.com/charmbracelet/x/ansi" - "github.com/charmbracelet/x/ansi/kitty" -) - -const ( - maxGraphicColumns = 96 - maxGraphicRows = 48 -) - -// GraphicsMode controls terminal image output. Auto enables Kitty graphics in -// terminals known to implement the protocol; Kitty forces it for terminals -// whose environment cannot advertise the outer emulator; Off is always text. -type GraphicsMode string - -const ( - GraphicsAuto GraphicsMode = "auto" - GraphicsKitty GraphicsMode = "kitty" - GraphicsOff GraphicsMode = "off" -) - -func ParseGraphicsMode(value string) (GraphicsMode, error) { - mode := GraphicsMode(value) - switch mode { - case GraphicsAuto, GraphicsKitty, GraphicsOff: - return mode, nil - default: - return "", fmt.Errorf("invalid graphics mode %q (want auto, kitty, or off)", value) - } -} - -// KittyGraphicsEnabled is deliberately conservative. Unknown terminals get -// readable fallbacks; --graphics=kitty is the explicit escape hatch. -func KittyGraphicsEnabled(mode GraphicsMode, getenv func(string) string) bool { - if mode == GraphicsOff { - return false - } - if mode == GraphicsKitty { - return true - } - term := strings.ToLower(getenv("TERM")) - program := strings.ToLower(getenv("TERM_PROGRAM")) - return strings.Contains(term, "kitty") || strings.Contains(term, "ghostty") || - strings.Contains(term, "wezterm") || strings.Contains(term, "konsole") || - strings.Contains(program, "kitty") || strings.Contains(program, "ghostty") || - strings.Contains(program, "wezterm") || strings.Contains(program, "konsole") -} - -// KittyImageBlock encodes img for transmission and returns separate placeholder -// rows that a TUI can scroll like ordinary text. Keeping the protocol bytes out -// of layout composition is important: width-aware renderers may deliberately -// discard an APC payload while measuring it. The returned IDs are deterministic -// for a given image and size, so redraws replace rather than accumulate. -func KittyImageBlock(img image.Image, columns int) (transmission, placeholders string, id uint32, err error) { - if columns < 1 { - columns = 1 - } - if columns > maxGraphicColumns { - columns = maxGraphicColumns - } - bounds := img.Bounds() - if bounds.Dx() < 1 || bounds.Dy() < 1 { - return "", "", 0, fmt.Errorf("image has no pixels") - } - // Terminal cells are approximately twice as tall as they are wide. - rows := (columns*bounds.Dy() + (bounds.Dx() * 2) - 1) / (bounds.Dx() * 2) - if rows < 1 { - rows = 1 - } - if rows > maxGraphicRows { - rows = maxGraphicRows - } - - h := fnv.New32a() - _, _ = fmt.Fprintf(h, "%dx%d:%dx%d", bounds.Dx(), bounds.Dy(), columns, rows) - var pixel [16]byte - for y := bounds.Min.Y; y < bounds.Max.Y; y++ { - for x := bounds.Min.X; x < bounds.Max.X; x++ { - r, g, b, a := img.At(x, y).RGBA() - binary.BigEndian.PutUint32(pixel[0:4], r) - binary.BigEndian.PutUint32(pixel[4:8], g) - binary.BigEndian.PutUint32(pixel[8:12], b) - binary.BigEndian.PutUint32(pixel[12:16], a) - _, _ = h.Write(pixel[:]) - } - } - id = h.Sum32() - // The reader caps placeholders at 96 cells, so keep the most-significant - // byte within the same range of Kitty's published diacritic table. - id &= 0x5fffffff - if id>>24 == 0 { - id |= 1 << 24 - } - - var tx bytes.Buffer - opts := &kitty.Options{ - Action: kitty.TransmitAndPut, - Quite: 2, - ID: int(id), - Format: kitty.PNG, - Transmission: kitty.Direct, - Chunk: true, - Columns: columns, - Rows: rows, - VirtualPlacement: true, - DoNotMoveCursor: true, - } - if os.Getenv("TMUX") != "" { - opts.ChunkFormatter = tmuxPassthrough - } - if err := kitty.EncodeGraphics(&tx, img, opts); err != nil { - return "", "", 0, err - } - - r := int(id & 0xff) - g := int((id >> 8) & 0xff) - b := int((id >> 16) & 0xff) - most := int((id >> 24) & 0xff) - var out strings.Builder - _, _ = fmt.Fprintf(&out, "\x1b[38;2;%d;%d;%dm", r, g, b) - for row := 0; row < rows; row++ { - for col := 0; col < columns; col++ { - out.WriteRune(kitty.Placeholder) - out.WriteRune(kitty.Diacritic(row)) - out.WriteRune(kitty.Diacritic(col)) - out.WriteRune(kitty.Diacritic(most)) - } - out.WriteString("\x1b[39m") - if row != rows-1 { - out.WriteByte('\n') - _, _ = fmt.Fprintf(&out, "\x1b[38;2;%d;%d;%dm", r, g, b) - } - } - out.WriteString("\x1b[39m") - return tx.String(), out.String(), id, nil -} - -func tmuxPassthrough(sequence string) string { - return "\x1bPtmux;" + strings.ReplaceAll(sequence, "\x1b", "\x1b\x1b") + "\x1b\\" -} - -// KittyCleanupSequence removes image data owned by the reader on teardown. -func KittyCleanupSequence(ids []uint32) string { - var out strings.Builder - for _, id := range ids { - seq := ansi.KittyGraphics(nil, "a=d", "d=I", fmt.Sprintf("i=%d", id)) - if os.Getenv("TMUX") != "" { - seq = tmuxPassthrough(seq) - } - out.WriteString(seq) - } - return out.String() -} diff --git a/internal/docs/kitty_graphics_test.go b/internal/docs/kitty_graphics_test.go deleted file mode 100644 index dabed06..0000000 --- a/internal/docs/kitty_graphics_test.go +++ /dev/null @@ -1,71 +0,0 @@ -package docs - -import ( - "image" - "image/color" - "strings" - "testing" - "unicode/utf8" - - "github.com/charmbracelet/x/ansi/kitty" -) - -func TestParseGraphicsMode(t *testing.T) { - for _, value := range []string{"auto", "kitty", "off"} { - if got, err := ParseGraphicsMode(value); err != nil || string(got) != value { - t.Fatalf("ParseGraphicsMode(%q) = %q, %v", value, got, err) - } - } - if _, err := ParseGraphicsMode("sixel"); err == nil { - t.Fatal("ParseGraphicsMode(sixel) succeeded") - } -} - -func TestKittyGraphicsEnabled(t *testing.T) { - env := map[string]string{"TERM": "xterm-256color", "TERM_PROGRAM": "ghostty"} - getenv := func(key string) string { return env[key] } - if !KittyGraphicsEnabled(GraphicsAuto, getenv) { - t.Fatal("auto did not detect Ghostty") - } - if KittyGraphicsEnabled(GraphicsOff, getenv) { - t.Fatal("off enabled graphics") - } - if !KittyGraphicsEnabled(GraphicsKitty, func(string) string { return "" }) { - t.Fatal("forced kitty did not enable graphics") - } -} - -func TestKittyImageBlock_UsesPlaceholdersAndContentIdentity(t *testing.T) { - red := image.NewRGBA(image.Rect(0, 0, 4, 2)) - blue := image.NewRGBA(image.Rect(0, 0, 4, 2)) - for y := 0; y < 2; y++ { - for x := 0; x < 4; x++ { - red.Set(x, y, color.RGBA{R: 255, A: 255}) - blue.Set(x, y, color.RGBA{B: 255, A: 255}) - } - } - transmission, placeholders, redID, err := KittyImageBlock(red, 8) - if err != nil { - t.Fatal(err) - } - _, _, blueID, err := KittyImageBlock(blue, 8) - if err != nil { - t.Fatal(err) - } - if redID == blueID { - t.Fatal("same-sized images received the same ID") - } - if !strings.Contains(transmission, "\x1b_G") || !strings.Contains(transmission, ";") || strings.ContainsRune(transmission, kitty.Placeholder) { - t.Fatalf("transmission = %q", transmission) - } - if !strings.ContainsRune(placeholders, kitty.Placeholder) || strings.Contains(placeholders, "\x1b_G") { - t.Fatalf("placeholders = %q", placeholders) - } - if !utf8.ValidString(transmission) || !utf8.ValidString(placeholders) { - t.Fatal("Kitty output is not valid UTF-8") - } - cleanup := KittyCleanupSequence([]uint32{redID}) - if !strings.Contains(cleanup, "a=d") || !strings.Contains(cleanup, "d=I") { - t.Fatalf("cleanup = %q", cleanup) - } -} diff --git a/internal/docs/terminal_render.go b/internal/docs/terminal_render.go deleted file mode 100644 index 8c845d8..0000000 --- a/internal/docs/terminal_render.go +++ /dev/null @@ -1,371 +0,0 @@ -package docs - -import ( - "bytes" - "fmt" - "image" - _ "image/gif" - _ "image/jpeg" - _ "image/png" - "io" - "net/url" - "os" - "path/filepath" - "regexp" - "strings" - "unicode" - - "charm.land/glamour/v2" - "charm.land/glamour/v2/styles" - mermaid "github.com/zkrebbekx/go-mermaid" - "github.com/zkrebbekx/go-mermaid/raster" - - "github.com/cameronsjo/forgectl/internal/termsafe" -) - -const ( - maxLocalImageBytes = 32 << 20 - maxDecodedPixels = 64 << 20 - maxDiagramBytes = 1 << 20 -) - -type TerminalLink struct { - Text string - Target string -} - -type TerminalPage struct { - Content string - Graphics string - Links []TerminalLink - ImageIDs []uint32 -} - -type terminalSegment struct { - kind string - source string - target string - alt string -} - -var ( - standaloneImageRE = regexp.MustCompile(`^\s*!\[([^]]*)\]\(([^) ]+)(?:\s+"[^"]*")?\)\s*$`) - linkRE = regexp.MustCompile(`(^|[^!])\[([^]]+)\]\(([^) ]+)(?:\s+"[^"]*")?\)`) -) - -// RenderTerminal renders a document for a cell-based viewport. Block images, -// SVG, and Mermaid are kept as distinct segments so Kitty placeholders remain -// attached to the document rows Bubble Tea scrolls. -func RenderTerminal(source []byte, doc Doc, root Root, width int, graphics bool) (TerminalPage, error) { - if width < 20 { - width = 20 - } - safeSource := safeMarkdownSource(string(source)) - segments := splitTerminalSegments(safeSource) - page := TerminalPage{Links: terminalLinks(safeSource)} - var rendered strings.Builder - - for _, segment := range segments { - if segment.kind == "text" { - text, err := renderTerminalMarkdown(segment.source, width) - if err != nil { - return TerminalPage{}, err - } - rendered.WriteString(text) - continue - } - - fallback := mediaFallback(segment) - if !graphics { - rendered.WriteString(fallback) - continue - } - img, err := renderTerminalMedia(segment, doc, root) - if err != nil { - rendered.WriteString(fallbackWithError(fallback, err)) - continue - } - transmission, placeholders, id, err := KittyImageBlock(img, width-4) - if err != nil { - rendered.WriteString(fallbackWithError(fallback, err)) - continue - } - rendered.WriteString("\n") - page.Graphics += transmission - rendered.WriteString(placeholders) - rendered.WriteString("\n\n") - page.ImageIDs = append(page.ImageIDs, id) - } - page.Content = rendered.String() - return page, nil -} - -// safeMarkdownSource preserves Markdown's structural newlines and tabs while -// visibly quoting terminal controls and bidi formatting. Glamour intentionally -// emits ANSI of its own, so sanitization must happen before rendering rather -// than stripping the completed output. -func safeMarkdownSource(value string) string { - var out strings.Builder - for _, r := range value { - if r == '\n' || r == '\t' || (!termsafe.IsUnsafeTerminalRune(r) && (unicode.IsGraphic(r) || r == ' ')) { - out.WriteRune(r) - continue - } - out.WriteString(termsafe.SafeLine(string(r))) - } - return out.String() -} - -func renderTerminalMarkdown(source string, width int) (string, error) { - style := styles.DarkStyleConfig - style.Document.Color = stringPtr("#c5c8c6") - style.Heading.Color = stringPtr("#B0B9F9") - style.H1.Color = stringPtr("#f0c674") - style.H1.BackgroundColor = nil - style.Link.Color = stringPtr("#8abeb7") - style.LinkText.Color = stringPtr("#8abeb7") - style.Code.Color = stringPtr("#b5bd68") - style.BlockQuote.Color = stringPtr("#8abeb7") - - renderer, err := glamour.NewTermRenderer( - glamour.WithStyles(style), - glamour.WithWordWrap(width), - ) - if err != nil { - return "", fmt.Errorf("terminal markdown renderer: %w", err) - } - out, err := renderer.Render(source) - if err != nil { - return "", fmt.Errorf("render terminal markdown: %w", err) - } - return out, nil -} - -func stringPtr(value string) *string { return &value } - -func splitTerminalSegments(source string) []terminalSegment { - lines := strings.SplitAfter(source, "\n") - var segments []terminalSegment - var text strings.Builder - flush := func() { - if text.Len() == 0 { - return - } - segments = append(segments, terminalSegment{kind: "text", source: text.String()}) - text.Reset() - } - - for i := 0; i < len(lines); i++ { - line := strings.TrimSuffix(lines[i], "\n") - trimmed := strings.TrimSpace(line) - if strings.HasPrefix(trimmed, "```mermaid") { - flush() - var diagram strings.Builder - for i++; i < len(lines); i++ { - candidate := strings.TrimSuffix(lines[i], "\n") - if strings.TrimSpace(candidate) == "```" { - break - } - diagram.WriteString(lines[i]) - } - segments = append(segments, terminalSegment{kind: "mermaid", source: diagram.String()}) - continue - } - if match := standaloneImageRE.FindStringSubmatch(line); match != nil { - flush() - segments = append(segments, terminalSegment{kind: "image", alt: match[1], target: match[2]}) - continue - } - if strings.Contains(trimmed, "") && i+1 < len(lines) { - i++ - svg.WriteString(lines[i]) - } - segments = append(segments, terminalSegment{kind: "svg", source: svg.String()}) - continue - } - text.WriteString(lines[i]) - } - flush() - return segments -} - -func terminalLinks(source string) []TerminalLink { - matches := linkRE.FindAllStringSubmatch(source, -1) - links := make([]TerminalLink, 0, len(matches)) - for _, match := range matches { - links = append(links, TerminalLink{Text: match[2], Target: match[3]}) - } - return links -} - -func mediaFallback(segment terminalSegment) string { - switch segment.kind { - case "image": - label := segment.alt - if label == "" { - label = filepath.Base(segment.target) - } - return fmt.Sprintf("\n [image: %s — %s]\n\n", termsafe.SafeLine(label), termsafe.SafeLine(segment.target)) - case "mermaid": - return "\n [Mermaid diagram — graphics unavailable]\n\n```mermaid\n" + safeMultiline(segment.source) + "```\n\n" - case "svg": - return "\n [inline SVG — graphics unavailable]\n\n" - default: - return "\n [media unavailable]\n\n" - } -} - -func fallbackWithError(fallback string, err error) string { - return fallback + fmt.Sprintf(" [rendering note: %s; use `forgectl docs serve --open` for the web reader]\n\n", termsafe.SafeLine(err.Error())) -} - -func safeMultiline(value string) string { - lines := strings.SplitAfter(value, "\n") - var out strings.Builder - for _, line := range lines { - if strings.HasSuffix(line, "\n") { - out.WriteString(termsafe.SafeLine(strings.TrimSuffix(line, "\n"))) - out.WriteByte('\n') - } else { - out.WriteString(termsafe.SafeLine(line)) - } - } - return out.String() -} - -func renderTerminalMedia(segment terminalSegment, doc Doc, root Root) (image.Image, error) { - switch segment.kind { - case "mermaid": - if len(segment.source) > maxDiagramBytes { - return nil, fmt.Errorf("Mermaid source exceeds 1 MiB") //nolint:staticcheck // Mermaid is a proper name. - } - pngBytes, err := raster.PNG(segment.source, 1, - mermaid.WithCustomTheme("artificer", mermaid.Palette{ - Background: "#1d1f21", NodeFill: "#282a2e", NodeStroke: "#B0B9F9", - Text: "#c5c8c6", Edge: "#8abeb7", - }), - ) - if err != nil { - return nil, fmt.Errorf("Mermaid is unsupported or invalid: %w", err) //nolint:staticcheck // Mermaid is a proper name. - } - img, _, err := image.Decode(bytes.NewReader(pngBytes)) - return img, err - case "svg": - if len(segment.source) > maxDiagramBytes { - return nil, fmt.Errorf("inline SVG exceeds 1 MiB") - } - pngBytes, err := raster.RasterizeSVG([]byte(segment.source), 1) - if err != nil { - return nil, err - } - img, _, err := image.Decode(bytes.NewReader(pngBytes)) - return img, err - case "image": - path, err := resolveTerminalResource(segment.target, doc, root) - if err != nil { - return nil, err - } - if strings.EqualFold(filepath.Ext(path), ".svg") { - raw, err := readBounded(path, maxLocalImageBytes) - if err != nil { - return nil, err - } - pngBytes, err := raster.RasterizeSVG(raw, 1) - if err != nil { - return nil, err - } - img, _, err := image.Decode(bytes.NewReader(pngBytes)) - return img, err - } - return decodeBoundedImage(path) - default: - return nil, fmt.Errorf("unsupported media kind %q", segment.kind) - } -} - -func resolveTerminalResource(target string, doc Doc, root Root) (string, error) { - u, err := url.Parse(target) - if err != nil { - return "", fmt.Errorf("invalid image target") - } - if u.Scheme != "" || u.Host != "" || strings.HasPrefix(target, "//") { - return "", fmt.Errorf("remote images are disabled") - } - if u.Path == "" || filepath.IsAbs(filepath.FromSlash(u.Path)) { - return "", fmt.Errorf("image path must be relative") - } - candidate := filepath.Join(filepath.Dir(doc.AbsPath), filepath.FromSlash(u.Path)) - resolved, err := filepath.EvalSymlinks(candidate) - if err != nil { - return "", fmt.Errorf("resolve image: %w", err) - } - resolved = filepath.Clean(resolved) - if !withinRoot(root.Path, resolved) { - return "", fmt.Errorf("image escapes docs root") - } - if root.OnlyFile != "" { - return "", fmt.Errorf("single-file roots do not grant sibling image access") - } - switch strings.ToLower(filepath.Ext(resolved)) { - case ".png", ".jpg", ".jpeg", ".gif", ".svg": - default: - return "", fmt.Errorf("unsupported local image format") - } - info, err := os.Stat(resolved) - if err != nil || !info.Mode().IsRegular() { - return "", fmt.Errorf("image is not a regular file") - } - if info.Size() > maxLocalImageBytes { - return "", fmt.Errorf("image exceeds 32 MiB") - } - return resolved, nil -} - -func decodeBoundedImage(path string) (image.Image, error) { - // path was canonicalized, contained to the selected docs root, extension - // checked, and statted as a regular file by resolveTerminalResource. - // #nosec G304 -- this is the validated local resource the user selected. - f, err := os.Open(path) - if err != nil { - return nil, err - } - defer func() { _ = f.Close() }() - config, _, err := image.DecodeConfig(io.LimitReader(f, maxLocalImageBytes+1)) - if err != nil { - return nil, fmt.Errorf("decode image metadata: %w", err) - } - if config.Width < 1 || config.Height < 1 || int64(config.Width)*int64(config.Height) > maxDecodedPixels { - return nil, fmt.Errorf("decoded image exceeds 64 megapixels") - } - if _, err := f.Seek(0, io.SeekStart); err != nil { - return nil, err - } - img, _, err := image.Decode(io.LimitReader(f, maxLocalImageBytes+1)) - if err != nil { - return nil, fmt.Errorf("decode image: %w", err) - } - return img, nil -} - -func readBounded(path string, limit int64) ([]byte, error) { - // path passed the same canonical containment and regular-file validation as - // decodeBoundedImage. - // #nosec G304 -- this is the validated local resource the user selected. - f, err := os.Open(path) - if err != nil { - return nil, err - } - defer func() { _ = f.Close() }() - raw, err := io.ReadAll(io.LimitReader(f, limit+1)) - if err != nil { - return nil, err - } - if int64(len(raw)) > limit { - return nil, fmt.Errorf("image exceeds 32 MiB") - } - return raw, nil -} diff --git a/internal/docs/terminal_render_test.go b/internal/docs/terminal_render_test.go deleted file mode 100644 index 44dcba2..0000000 --- a/internal/docs/terminal_render_test.go +++ /dev/null @@ -1,113 +0,0 @@ -package docs - -import ( - "image" - "image/color" - "image/png" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/charmbracelet/x/ansi/kitty" -) - -func TestRenderTerminal_TextLinksAndFallbacks(t *testing.T) { - dir := t.TempDir() - docPath := filepath.Join(dir, "README.md") - source := []byte("# Hello\n\n[Next](next.md)\n\n![remote](https://example.com/x.png)\n") - if err := os.WriteFile(docPath, source, 0o600); err != nil { - t.Fatal(err) - } - doc := Doc{RootLabel: "root", RelPath: "README.md", AbsPath: docPath, Title: "Hello"} - page, err := RenderTerminal(source, doc, Root{Label: "root", Path: dir}, 60, false) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(page.Content, "Hello") || !strings.Contains(page.Content, "image: remote") { - t.Fatalf("content = %q", page.Content) - } - if len(page.Links) != 1 || page.Links[0].Target != "next.md" { - t.Fatalf("links = %+v", page.Links) - } - if len(page.ImageIDs) != 0 || strings.ContainsRune(page.Content, kitty.Placeholder) { - t.Fatal("graphics-off page emitted graphics") - } -} - -func TestRenderTerminal_LocalImageAndMermaid(t *testing.T) { - dir := t.TempDir() - dir, err := filepath.EvalSymlinks(dir) - if err != nil { - t.Fatal(err) - } - docPath := filepath.Join(dir, "README.md") - imagePath := filepath.Join(dir, "pixel.png") - img := image.NewRGBA(image.Rect(0, 0, 8, 4)) - img.Set(0, 0, color.RGBA{R: 255, A: 255}) - f, err := os.Create(imagePath) - if err != nil { - t.Fatal(err) - } - if err := png.Encode(f, img); err != nil { - t.Fatal(err) - } - if err := f.Close(); err != nil { - t.Fatal(err) - } - source := []byte("![pixel](pixel.png)\n\n```mermaid\ngraph LR\nA --> B\n```\n") - if err := os.WriteFile(docPath, source, 0o600); err != nil { - t.Fatal(err) - } - doc := Doc{RootLabel: "root", RelPath: "README.md", AbsPath: docPath} - page, err := RenderTerminal(source, doc, Root{Label: "root", Path: dir}, 60, true) - if err != nil { - t.Fatal(err) - } - if len(page.ImageIDs) != 2 { - t.Fatalf("image IDs = %v, content = %q", page.ImageIDs, page.Content) - } - if strings.Count(page.Content, string(kitty.Placeholder)) == 0 || !strings.Contains(page.Graphics, "\x1b_G") { - t.Fatal("rendered media has no placeholders") - } - if strings.Contains(page.Content, "\x1b_G") { - t.Fatal("scrollable content contains Kitty transmission bytes") - } -} - -func TestRenderTerminal_RejectsEscapingAndRemoteImages(t *testing.T) { - dir := t.TempDir() - docPath := filepath.Join(dir, "README.md") - doc := Doc{RootLabel: "root", RelPath: "README.md", AbsPath: docPath} - root := Root{Label: "root", Path: dir} - for _, target := range []string{"../outside.png", "https://example.com/x.png", "/tmp/x.png"} { - _, err := resolveTerminalResource(target, doc, root) - if err == nil { - t.Fatalf("resolveTerminalResource(%q) succeeded", target) - } - } -} - -func TestRenderTerminal_FallbackNeutralizesControls(t *testing.T) { - segment := terminalSegment{kind: "mermaid", source: "graph LR\nA[\x1b[2J]-->B\n"} - fallback := mediaFallback(segment) - if strings.ContainsRune(fallback, '\x1b') || !strings.Contains(fallback, `\x1b`) { - t.Fatalf("fallback did not visibly neutralize control: %q", fallback) - } -} - -func TestRenderTerminal_MarkdownNeutralizesControls(t *testing.T) { - dir := t.TempDir() - docPath := filepath.Join(dir, "README.md") - doc := Doc{RootLabel: "root", RelPath: "README.md", AbsPath: docPath} - page, err := RenderTerminal([]byte("# heading\n\ntext \x1b[2J end\n"), doc, Root{Label: "root", Path: dir}, 60, false) - if err != nil { - t.Fatal(err) - } - // Glamour's own ANSI is expected. Removing CSI prefixes leaves any raw - // source escape visible to this assertion. - stripped := strings.ReplaceAll(page.Content, "\x1b[", "") - if strings.ContainsRune(stripped, '\x1b') || !strings.Contains(page.Content, `\x1b`) { - t.Fatalf("content did not visibly neutralize source control: %q", page.Content) - } -} diff --git a/internal/docstui/tui.go b/internal/docstui/tui.go deleted file mode 100644 index 2c0e550..0000000 --- a/internal/docstui/tui.go +++ /dev/null @@ -1,422 +0,0 @@ -// Package docstui provides the interactive terminal reader for forgectl docs. -package docstui - -import ( - "context" - "fmt" - "io" - "net/url" - "os" - "path/filepath" - "strings" - - "github.com/charmbracelet/bubbles/list" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/x/ansi" - - "github.com/cameronsjo/forgectl/internal/docs" - forgexec "github.com/cameronsjo/forgectl/internal/exec" - "github.com/cameronsjo/forgectl/internal/termsafe" -) - -const narrowWidth = 80 - -var ( - accentStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#B0B9F9")).Bold(true) - mutedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")) - errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#cc6666")) - okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#b5bd68")) -) - -type docItem struct{ doc docs.Doc } - -func (i docItem) Title() string { return termsafe.SafeLine(i.doc.Title) } -func (i docItem) Description() string { - return termsafe.SafeLine(i.doc.RootLabel + "/" + i.doc.RelPath) -} -func (i docItem) FilterValue() string { - return i.doc.Title + " " + i.doc.RootLabel + "/" + i.doc.RelPath -} - -type linkItem struct{ link docs.TerminalLink } - -func (i linkItem) Title() string { return termsafe.SafeLine(i.link.Text) } -func (i linkItem) Description() string { return termsafe.SafeLine(i.link.Target) } -func (i linkItem) FilterValue() string { return i.link.Text + " " + i.link.Target } - -type reloadMsg struct{} -type openedMsg struct{ err error } - -type location struct { - doc docs.Doc - offset int -} - -type model struct { - ctx context.Context - store *docs.Store - reloadC <-chan string - runner forgexec.Runner - graphics bool - - width, height int - focus int - docsList list.Model - linksList list.Model - reader viewport.Model - linkMode bool - pending *docs.TerminalLink - status string - - current docs.Doc - history []location - currentIDs []uint32 - allIDs []uint32 - // graphicsPreamble is consumed by the next View before Lipgloss sees it. - // APC payloads are protocol data, not layout content. - graphicsPreamble string -} - -// Run owns the alternate-screen reader until the user quits or ctx is -// cancelled. The watcher and broker are the same proven reload path as the web -// reader; only the subscriber is a Bubble Tea command instead of SSE. -func Run(ctx context.Context, idx *docs.Index, runner forgexec.Runner, mode docs.GraphicsMode, in io.Reader, out io.Writer) error { - store := docs.NewStore(idx) - broker := docs.NewBroker() - reloadC, unsubscribe := broker.Subscribe() - defer unsubscribe() - defer broker.Close() - - watcher, err := docs.NewWatcher(store, broker) - if err != nil { - return fmt.Errorf("start docs live reload: %w", err) - } - defer func() { _ = watcher.Close() }() - watchCtx, stopWatch := context.WithCancel(ctx) - defer stopWatch() - go watcher.Run(watchCtx) - - m := newModel(ctx, store, reloadC, runner, docs.KittyGraphicsEnabled(mode, os.Getenv)) - p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithContext(ctx), tea.WithInput(in), tea.WithOutput(out)) - final, err := p.Run() - if fm, ok := final.(*model); ok && len(fm.allIDs) > 0 { - _, _ = io.WriteString(out, docs.KittyCleanupSequence(fm.allIDs)) - } - if err != nil { - return fmt.Errorf("run docs reader: %w", err) - } - return nil -} - -func newModel(ctx context.Context, store *docs.Store, reloadC <-chan string, runner forgexec.Runner, graphics bool) *model { - docsList := list.New(docItems(store.Current()), list.NewDefaultDelegate(), 0, 0) - docsList.Title = "Documents" - docsList.SetShowHelp(false) - linksList := list.New(nil, list.NewDefaultDelegate(), 0, 0) - linksList.Title = "Links" - linksList.SetShowHelp(false) - m := &model{ - ctx: ctx, store: store, reloadC: reloadC, runner: runner, graphics: graphics, - docsList: docsList, linksList: linksList, reader: viewport.New(0, 0), - } - if item, ok := docsList.SelectedItem().(docItem); ok { - m.load(item.doc, "", false) - } - return m -} - -func docItems(idx *docs.Index) []list.Item { - listed := idx.List() - items := make([]list.Item, 0, len(listed)) - for _, doc := range listed { - items = append(items, docItem{doc: doc}) - } - return items -} - -func (m *model) Init() tea.Cmd { return waitReload(m.reloadC) } - -func waitReload(ch <-chan string) tea.Cmd { - return func() tea.Msg { - if _, ok := <-ch; !ok { - return nil - } - return reloadMsg{} - } -} - -func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.width, m.height = msg.Width, msg.Height - m.applySize() - if m.current.AbsPath != "" { - m.load(m.current, "", false) - } - return m, nil - case reloadMsg: - m.reload() - return m, waitReload(m.reloadC) - case openedMsg: - if msg.err != nil { - m.status = errorStyle.Render(termsafe.SafeLine("open link: " + msg.err.Error())) - } else { - m.status = okStyle.Render("opened external link") - } - return m, nil - case tea.KeyMsg: - if m.pending != nil { - return m.confirmExternal(msg) - } - if m.linkMode { - return m.updateLinks(msg) - } - switch msg.String() { - case "ctrl+c", "q": - return m, tea.Quit - case "tab": - m.focus = (m.focus + 1) % 2 - return m, nil - case "l": - m.openLinks() - return m, nil - case "b": - m.goBack() - return m, nil - case "?": - m.status = "tab panes · / filter · enter open · l links · b back · q quit" - return m, nil - case "enter": - if m.focus == 0 { - if item, ok := m.docsList.SelectedItem().(docItem); ok { - m.load(item.doc, "", true) - if m.width < narrowWidth { - m.focus = 1 - } - } - return m, nil - } - } - } - - var cmd tea.Cmd - if m.focus == 0 { - m.docsList, cmd = m.docsList.Update(msg) - } else { - m.reader, cmd = m.reader.Update(msg) - } - return m, cmd -} - -func (m *model) applySize() { - bodyHeight := max(1, m.height-4) - if m.width < narrowWidth { - m.docsList.SetSize(max(20, m.width-2), bodyHeight) - m.reader.Width = max(20, m.width-2) - m.reader.Height = bodyHeight - return - } - left := max(28, m.width/3) - m.docsList.SetSize(left-2, bodyHeight) - m.reader.Width = max(20, m.width-left-3) - m.reader.Height = bodyHeight -} - -func (m *model) load(doc docs.Doc, anchor string, push bool) { - if push && m.current.AbsPath != "" && m.current.AbsPath != doc.AbsPath { - m.history = append(m.history, location{doc: m.current, offset: m.reader.YOffset}) - } - raw, err := os.ReadFile(doc.AbsPath) - if err != nil { - m.status = errorStyle.Render(termsafe.SafeLine(err.Error())) - return - } - root, ok := rootFor(m.store.Current(), doc.RootLabel) - if !ok { - m.status = errorStyle.Render("document root disappeared") - return - } - page, err := docs.RenderTerminal(raw, doc, root, max(20, m.reader.Width), m.graphics) - if err != nil { - m.status = errorStyle.Render(termsafe.SafeLine(err.Error())) - return - } - m.graphicsPreamble = docs.KittyCleanupSequence(m.currentIDs) + page.Graphics - m.current = doc - m.currentIDs = page.ImageIDs - m.allIDs = append(m.allIDs, page.ImageIDs...) - m.reader.SetContent(page.Content) - m.reader.GotoTop() - if anchor != "" { - m.reader.SetYOffset(findAnchorLine(page.Content, anchor)) - } - items := make([]list.Item, 0, len(page.Links)) - for _, link := range page.Links { - items = append(items, linkItem{link: link}) - } - m.linksList.SetItems(items) - m.status = "" -} - -func rootFor(idx *docs.Index, label string) (docs.Root, bool) { - for _, root := range idx.Roots() { - if root.Label == label { - return root, true - } - } - return docs.Root{}, false -} - -func (m *model) reload() { - idx := m.store.Current() - selected := m.current.AbsPath - m.docsList.SetItems(docItems(idx)) - if selected == "" { - return - } - if doc, ok := idx.FindByAbsPath(selected); ok { - offset := m.reader.YOffset - m.load(doc, "", false) - m.reader.SetYOffset(offset) - m.status = okStyle.Render("reloaded") - return - } - if item, ok := m.docsList.SelectedItem().(docItem); ok { - m.load(item.doc, "", false) - m.status = errorStyle.Render("current document was removed") - } -} - -func (m *model) openLinks() { - if len(m.linksList.Items()) == 0 { - m.status = mutedStyle.Render("this document has no links") - return - } - m.linkMode = true - m.linksList.SetSize(max(20, m.width-4), max(6, m.height-4)) -} - -func (m *model) updateLinks(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "q", "esc": - m.linkMode = false - return m, nil - case "enter": - item, ok := m.linksList.SelectedItem().(linkItem) - if !ok { - return m, nil - } - m.linkMode = false - return m.follow(item.link) - } - var cmd tea.Cmd - m.linksList, cmd = m.linksList.Update(msg) - return m, cmd -} - -func (m *model) follow(link docs.TerminalLink) (tea.Model, tea.Cmd) { - u, err := url.Parse(link.Target) - if err != nil { - m.status = errorStyle.Render("invalid link") - return m, nil - } - if u.Scheme == "http" || u.Scheme == "https" { - m.pending = &link - m.status = fmt.Sprintf("Open %s in the system browser? y/n", termsafe.SafeLine(link.Target)) - return m, nil - } - if u.Scheme != "" || u.Host != "" { - m.status = errorStyle.Render("unsupported link scheme") - return m, nil - } - if u.Path == "" { - m.load(m.current, u.Fragment, false) - return m, nil - } - candidate := filepath.Join(filepath.Dir(m.current.AbsPath), filepath.FromSlash(u.Path)) - resolved, err := filepath.EvalSymlinks(candidate) - if err != nil { - m.status = errorStyle.Render("linked document is unavailable") - return m, nil - } - doc, ok := m.store.Current().FindByAbsPath(filepath.Clean(resolved)) - if !ok { - m.status = errorStyle.Render("linked document is outside the index") - return m, nil - } - m.load(doc, u.Fragment, true) - return m, nil -} - -func (m *model) confirmExternal(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "y", "Y": - target := m.pending.Target - m.pending = nil - m.status = "opening external link…" - return m, func() tea.Msg { - return openedMsg{err: docs.OpenBrowser(m.ctx, m.runner, target)} - } - case "n", "N", "esc": - m.pending = nil - m.status = mutedStyle.Render("link not opened") - } - return m, nil -} - -func (m *model) goBack() { - if len(m.history) == 0 { - m.status = mutedStyle.Render("no previous document") - return - } - last := m.history[len(m.history)-1] - m.history = m.history[:len(m.history)-1] - m.load(last.doc, "", false) - m.reader.SetYOffset(last.offset) -} - -func findAnchorLine(content, anchor string) int { - want := strings.ReplaceAll(strings.ToLower(anchor), "-", " ") - for lineNo, line := range strings.Split(ansi.Strip(content), "\n") { - normalized := strings.ToLower(strings.TrimSpace(line)) - if normalized == want || strings.Contains(normalized, want) { - return lineNo - } - } - return 0 -} - -func (m *model) View() string { - preamble := m.graphicsPreamble - m.graphicsPreamble = "" - header := accentStyle.Render("◆ forgectl docs") - if m.current.Title != "" { - header += mutedStyle.Render(" · " + termsafe.SafeLine(m.current.Title)) - } - footer := mutedStyle.Render("tab panes · / filter · enter open · l links · b back · ? help · q quit") - if m.status != "" { - footer = m.status + "\n" + footer - } - if m.pending != nil { - footer = m.status - } - var body string - if m.linkMode { - body = m.linksList.View() - } else if m.width < narrowWidth { - if m.focus == 0 { - body = m.docsList.View() - } else { - body = m.reader.View() - } - } else { - body = lipgloss.JoinHorizontal(lipgloss.Top, - lipgloss.NewStyle().Width(m.docsList.Width()).Render(m.docsList.View()), - mutedStyle.Render("│ "), - m.reader.View(), - ) - } - return preamble + lipgloss.JoinVertical(lipgloss.Left, header, body, footer) -} diff --git a/internal/docstui/tui_test.go b/internal/docstui/tui_test.go deleted file mode 100644 index 3e23cde..0000000 --- a/internal/docstui/tui_test.go +++ /dev/null @@ -1,103 +0,0 @@ -package docstui - -import ( - "context" - "os" - "path/filepath" - "strings" - "testing" - - tea "github.com/charmbracelet/bubbletea" - - "github.com/cameronsjo/forgectl/internal/docs" - forgexec "github.com/cameronsjo/forgectl/internal/exec" -) - -func testModel(t *testing.T) *model { - t.Helper() - dir := t.TempDir() - for name, body := range map[string]string{ - "README.md": "# Home\n\n[Next](next.md)\n\n[Site](https://example.com)\n", - "next.md": "# Next\n\nBody\n", - } { - if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o600); err != nil { - t.Fatal(err) - } - } - idx, err := docs.NewIndex([]string{dir}) - if err != nil { - t.Fatal(err) - } - reloadC := make(chan string) - m := newModel(context.Background(), docs.NewStore(idx), reloadC, &forgexec.FakeRunner{}, false) - m.width, m.height = 100, 30 - m.applySize() - // Select Home regardless of recency ordering. - for _, doc := range idx.List() { - if doc.Title == "Home" { - m.load(doc, "", false) - } - } - return m -} - -func TestModel_AdaptiveLayoutAndNavigation(t *testing.T) { - m := testModel(t) - if !strings.Contains(m.View(), "Home") { - t.Fatalf("wide view lacks current document: %q", m.View()) - } - updated, _ := m.Update(tea.WindowSizeMsg{Width: 60, Height: 20}) - m = updated.(*model) - if m.width >= narrowWidth || m.reader.Width != 58 { - t.Fatalf("narrow sizing: width=%d reader=%d", m.width, m.reader.Width) - } - updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyTab}) - m = updated.(*model) - if m.focus != 1 { - t.Fatalf("focus = %d, want reader", m.focus) - } -} - -func TestModel_GraphicsPreambleBypassesLayoutExactlyOnce(t *testing.T) { - m := testModel(t) - const transmission = "\x1b_Gf=100,i=7;payload\x1b\\" - m.graphicsPreamble = transmission - if got := m.View(); !strings.HasPrefix(got, transmission) { - t.Fatalf("first view altered or misplaced Kitty transmission: %q", got) - } - if got := m.View(); strings.Contains(got, transmission) { - t.Fatal("second view retransmitted consumed Kitty payload") - } -} - -func TestModel_InternalLinkHistoryAndExternalConfirmation(t *testing.T) { - m := testModel(t) - var internal, external docs.TerminalLink - for _, item := range m.linksList.Items() { - link := item.(linkItem).link - if strings.HasPrefix(link.Target, "http") { - external = link - } else { - internal = link - } - } - updated, _ := m.follow(internal) - m = updated.(*model) - if m.current.Title != "Next" || len(m.history) != 1 { - t.Fatalf("internal navigation: current=%q history=%d", m.current.Title, len(m.history)) - } - m.goBack() - if m.current.Title != "Home" { - t.Fatalf("back returned to %q", m.current.Title) - } - updated, _ = m.follow(external) - m = updated.(*model) - if m.pending == nil || !strings.Contains(m.status, "system browser") { - t.Fatalf("external link did not require confirmation: pending=%v status=%q", m.pending, m.status) - } - updated, _ = m.confirmExternal(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}}) - m = updated.(*model) - if m.pending != nil { - t.Fatal("declined external link remained pending") - } -} From 44ccb4e864fb1051e9518fa1f11d5db1af1f0cac Mon Sep 17 00:00:00 2001 From: Cameron Sjo <4084915+cameronsjo@users.noreply.github.com> Date: Sat, 29 Aug 2026 19:12:57 -0500 Subject: [PATCH 09/21] feat(docs): add reading appearance controls --- .../plans/2026-08-29-embedded-docs-preview.md | 2 +- internal/docs/assets.go | 11 ++ internal/docs/assets/reader-settings.js | 103 +++++++++++++ internal/docs/assets/reader.css | 136 ++++++++++++++++++ internal/docs/server.go | 2 + internal/docs/server_test.go | 23 ++- internal/docs/templates/shell.html.tmpl | 61 +++++++- 7 files changed, 335 insertions(+), 3 deletions(-) create mode 100644 internal/docs/assets/reader-settings.js create mode 100644 internal/docs/assets/reader.css diff --git a/docs/plans/2026-08-29-embedded-docs-preview.md b/docs/plans/2026-08-29-embedded-docs-preview.md index 4308348..0bfa633 100644 --- a/docs/plans/2026-08-29-embedded-docs-preview.md +++ b/docs/plans/2026-08-29-embedded-docs-preview.md @@ -49,7 +49,7 @@ ordinary path, and address the reading gaps exposed by the live cmux proof. stealing focus. - [x] Persist and commit the approved pivot before implementation. - [x] Replace the bare docs/TUI entry point with embedded-cmux preview startup. -- [ ] Add persisted reading typography and measure controls. +- [x] Add persisted reading typography and measure controls. - [ ] Serve contained local Markdown images through the loopback reader. - [x] Remove terminal-reader code and dependencies. - [ ] Update help and README; leave generated changelog prose to Release Please. diff --git a/internal/docs/assets.go b/internal/docs/assets.go index 3f7b9ad..bab64a2 100644 --- a/internal/docs/assets.go +++ b/internal/docs/assets.go @@ -45,6 +45,17 @@ var reloadJS []byte //go:embed assets/sidenav-filter.js var sidenavFilterJS []byte +// readerCSS 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-settings.js +var readerSettingsJS []byte + // mermaidJS is vendored mermaid (version, license, and sha256 recorded in // assets/provenance-mermaid.json). Embedded rather than loaded from a CDN: the // reader must render a diagram with no network call, because opening a local 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.css b/internal/docs/assets/reader.css new file mode 100644 index 0000000..7c9d41a --- /dev/null +++ b/internal/docs/assets/reader.css @@ -0,0 +1,136 @@ +: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; +} + +.docs-reader { + box-sizing: border-box; + min-width: 0; + padding: clamp(28px, 5vw, 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); +} + +.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: var(--s-xl) var(--s-lg); + } + + .reader-settings__panel { + position: fixed; + top: 64px; + right: 12px; + } +} diff --git a/internal/docs/server.go b/internal/docs/server.go index 56468ee..f05d25e 100644 --- a/internal/docs/server.go +++ b/internal/docs/server.go @@ -191,6 +191,8 @@ func NewHandler(store *Store, events *Broker) http.Handler { mux.HandleFunc("GET /assets/svg-panzoom.js", serveStaticJS(panZoomJS)) mux.HandleFunc("GET /assets/artificer-tree.js", serveStaticJS(artificerTreeJS)) mux.HandleFunc("GET /assets/sidenav-filter.js", serveStaticJS(sidenavFilterJS)) + mux.HandleFunc("GET /assets/reader.css", serveStaticCSS(readerCSS)) + mux.HandleFunc("GET /assets/reader-settings.js", serveStaticJS(readerSettingsJS)) mux.HandleFunc("GET /assets/chroma.css", serveStaticCSS(ChromaCSS())) mux.HandleFunc("GET /assets/diagram.css", serveStaticCSS(diagramCSS)) diff --git a/internal/docs/server_test.go b/internal/docs/server_test.go index 6f1e852..3335bf7 100644 --- a/internal/docs/server_test.go +++ b/internal/docs/server_test.go @@ -98,7 +98,7 @@ func TestServer_StaticAssets_Served(t *testing.T) { idx, _ := testIndex(t) h := testHandler(idx) - for _, path := range []string{"/assets/artificer.css", "/assets/artificer-theme.js", "/assets/reload.js", "/assets/chroma.css", "/assets/sidenav-filter.js"} { + for _, path := range []string{"/assets/artificer.css", "/assets/artificer-theme.js", "/assets/reload.js", "/assets/chroma.css", "/assets/sidenav-filter.js", "/assets/reader.css", "/assets/reader-settings.js"} { t.Run(path, func(t *testing.T) { rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil)) @@ -112,6 +112,27 @@ func TestServer_StaticAssets_Served(t *testing.T) { } } +func TestServer_ShellIncludesPersistedReadingControls(t *testing.T) { + idx, _ := testIndex(t) + rec := httptest.NewRecorder() + testHandler(idx).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + + body := rec.Body.String() + for _, want := range []string{ + `data-reader-setting="bodyFont"`, + `data-reader-setting="headingFont"`, + `data-reader-setting="codeFont"`, + `data-reader-setting="fontSize"`, + `data-reader-setting="lineHeight"`, + `data-reader-setting="measure"`, + `src="/assets/reader-settings.js"`, + } { + if !strings.Contains(body, want) { + t.Errorf("shell missing %q", want) + } + } +} + // doRequestFollowingOneRedirect drives req through h and, if the response is // a redirect (Go's stdlib ServeMux 307s a request whose path contains a // literal "../" segment before our handler ever sees it — its own, diff --git a/internal/docs/templates/shell.html.tmpl b/internal/docs/templates/shell.html.tmpl index f29ea6f..dd38a7b 100644 --- a/internal/docs/templates/shell.html.tmpl +++ b/internal/docs/templates/shell.html.tmpl @@ -7,10 +7,12 @@ + + @@ -24,6 +26,61 @@ forgectl docs
+
+ Aa +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + 18px +
+
+
+ +
+ + 1.72 +
+
+
+ +
+ + 72ch +
+
+ +
+
@@ -42,13 +99,15 @@ {{end}} -
+
+
{{if .Content}}{{.Content}}{{else}}

No doc selected

Choose a document from the sidebar to start reading.

{{end}} +
From 57d380fc2bd49e6a76d3a8f88cb31464b1e83f40 Mon Sep 17 00:00:00 2001 From: Cameron Sjo <4084915+cameronsjo@users.noreply.github.com> Date: Sat, 29 Aug 2026 19:16:02 -0500 Subject: [PATCH 10/21] feat(docs): render referenced local images --- .../plans/2026-08-29-embedded-docs-preview.md | 2 +- go.mod | 2 +- internal/docs/media.go | 213 ++++++++++++++++++ internal/docs/media_test.go | 122 ++++++++++ internal/docs/server.go | 7 + internal/docs/watcher.go | 13 +- internal/docs/watcher_test.go | 11 +- 7 files changed, 362 insertions(+), 8 deletions(-) create mode 100644 internal/docs/media.go create mode 100644 internal/docs/media_test.go diff --git a/docs/plans/2026-08-29-embedded-docs-preview.md b/docs/plans/2026-08-29-embedded-docs-preview.md index 0bfa633..5b67d1f 100644 --- a/docs/plans/2026-08-29-embedded-docs-preview.md +++ b/docs/plans/2026-08-29-embedded-docs-preview.md @@ -50,7 +50,7 @@ ordinary path, and address the reading gaps exposed by the live cmux proof. - [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. -- [ ] Serve contained local Markdown images through the loopback reader. +- [x] Serve contained local Markdown images through the loopback reader. - [x] Remove terminal-reader code and dependencies. - [ ] Update help and README; leave generated changelog prose to Release Please. - [ ] Run fresh build, vet, tests, formatting, lint, and live cmux acceptance. diff --git a/go.mod b/go.mod index f37b17b..f57f30f 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,7 @@ require ( github.com/spf13/pflag v1.0.9 github.com/yuin/goldmark v1.8.4 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc + golang.org/x/net v0.55.0 golang.org/x/sys v0.46.0 golang.org/x/term v0.44.0 ) @@ -60,7 +61,6 @@ require ( github.com/rivo/uniseg v0.4.7 // 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/docs/media.go b/internal/docs/media.go new file mode 100644 index 0000000..dfcbc2f --- /dev/null +++ b/internal/docs/media.go @@ -0,0 +1,213 @@ +package docs + +import ( + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "os" + "path" + "path/filepath" + "strings" + + "golang.org/x/net/html" +) + +var errMediaNotReferenced = errors.New("media is not referenced by the document") + +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 + } + + token := tokens.Token() + 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) + } + } + } + out.WriteString(token.String()) + } +} + +// 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 + } + source, err := os.ReadFile(docPath) + if err != nil { + http.NotFound(w, r) + return + } + rendered, err := Render(source) + if err != nil || !renderedReferencesMedia(rendered, docRel, requested) { + slog.Debug("docs: media request was not referenced by its document.", "root", rootLabel, "doc", docRel, "media", requested, "error", errMediaNotReferenced) + 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 + } + file, err := os.Open(mediaPath) + if err != nil { + http.NotFound(w, r) + return + } + defer file.Close() + 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..258583a --- /dev/null +++ b/internal/docs/media_test.go @@ -0,0 +1,122 @@ +package docs + +import ( + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "strings" + "testing" +) + +func TestRewriteLocalImageURLs_RewritesOnlyContainedRelativeImages(t *testing.T) { + rendered := `

local` + + `remote` + + `inline` + + `escape

` + + got, err := RewriteLocalImageURLs(rendered, "docs", "guide/setup/readme.md") + if err != nil { + t.Fatal(err) + } + wantQuery := url.Values{"doc": {"guide/setup/readme.md"}, "path": {"guide/images/architecture.svg"}}.Encode() + if !strings.Contains(got, `/media/docs?`+strings.ReplaceAll(wantQuery, "&", "&")+`#focus`) { + t.Errorf("rewritten HTML missing local media URL: %s", got) + } + for _, want := range []string{ + `src="https://example.com/tracker.png"`, + `src="data:image/png;base64,abc"`, + `src="../../../escape.png"`, + } { + if !strings.Contains(got, want) { + t.Errorf("rewritten HTML changed %q: %s", want, got) + } + } +} + +func TestServer_RelativeMarkdownImageIsServed(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "guide", "readme.md"), "# Guide\n\n![diagram](../images/architecture.svg#detail)\n") + writeFile(t, filepath.Join(dir, "images", "architecture.svg"), ``) + idx, err := NewIndex([]string{dir}) + if err != nil { + t.Fatal(err) + } + label := idx.Roots()[0].Label + h := testHandler(idx) + + docRec := httptest.NewRecorder() + h.ServeHTTP(docRec, httptest.NewRequest(http.MethodGet, "/doc/"+label+"/guide/readme.md", nil)) + if docRec.Code != http.StatusOK { + t.Fatalf("doc status = %d", docRec.Code) + } + wantQuery := url.Values{"doc": {"guide/readme.md"}, "path": {"images/architecture.svg"}}.Encode() + mediaURL := "/media/" + label + "?" + wantQuery + if !strings.Contains(docRec.Body.String(), strings.ReplaceAll(mediaURL, "&", "&")+"#detail") { + t.Fatalf("doc body missing rewritten media URL %q: %s", mediaURL, docRec.Body.String()) + } + + mediaRec := httptest.NewRecorder() + h.ServeHTTP(mediaRec, httptest.NewRequest(http.MethodGet, mediaURL, nil)) + if mediaRec.Code != http.StatusOK { + t.Fatalf("media status = %d, body: %s", mediaRec.Code, mediaRec.Body.String()) + } + if got := mediaRec.Header().Get("Content-Type"); got != "image/svg+xml" { + t.Errorf("Content-Type = %q, want image/svg+xml", got) + } + if got := mediaRec.Header().Get("Content-Security-Policy"); !strings.Contains(got, "sandbox") { + t.Errorf("SVG CSP = %q, want sandbox", got) + } +} + +func TestServer_MediaRequiresDocumentReference(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "readme.md"), "# Guide\n\nNo image here.\n") + writeFile(t, filepath.Join(dir, "secret.png"), "not really a png") + idx, err := NewIndex([]string{dir}) + if err != nil { + t.Fatal(err) + } + label := idx.Roots()[0].Label + query := url.Values{"doc": {"readme.md"}, "path": {"secret.png"}}.Encode() + rec := httptest.NewRecorder() + testHandler(idx).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/media/"+label+"?"+query, nil)) + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404", rec.Code) + } +} + +func TestServer_SingleFileRootMayServeItsReferencedSiblingImage(t *testing.T) { + dir := t.TempDir() + doc := filepath.Join(dir, "readme.md") + writeFile(t, doc, "# Guide\n\n![sample](sample.png)\n") + writeFile(t, filepath.Join(dir, "sample.png"), "png fixture") + idx, err := NewIndex([]string{doc}) + if err != nil { + t.Fatal(err) + } + label := idx.Roots()[0].Label + query := url.Values{"doc": {"readme.md"}, "path": {"sample.png"}}.Encode() + rec := httptest.NewRecorder() + testHandler(idx).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/media/"+label+"?"+query, nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String()) + } +} + +func TestServer_MediaUnderExcludedDirectoryIsRejected(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "readme.md"), "# Guide\n\n![hidden](.private/image.png)\n") + writeFile(t, filepath.Join(dir, ".private", "image.png"), "png fixture") + idx, err := NewIndex([]string{dir}) + if err != nil { + t.Fatal(err) + } + label := idx.Roots()[0].Label + query := url.Values{"doc": {"readme.md"}, "path": {".private/image.png"}}.Encode() + rec := httptest.NewRecorder() + testHandler(idx).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/media/"+label+"?"+query, nil)) + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404", rec.Code) + } +} diff --git a/internal/docs/server.go b/internal/docs/server.go index f05d25e..54efae3 100644 --- a/internal/docs/server.go +++ b/internal/docs/server.go @@ -198,6 +198,7 @@ func NewHandler(store *Store, events *Broker) http.Handler { mux.HandleFunc("GET "+eventsPath, handleEvents(events)) mux.HandleFunc("GET "+locatePath, handleLocate(store)) + mux.HandleFunc("GET /media/{root}", handleMedia(store)) mux.HandleFunc("GET /doc/{root}/{rest...}", handleDoc(store)) mux.HandleFunc("GET /{$}", handleIndexRoot(store)) @@ -360,6 +361,12 @@ func handleDoc(store *Store) http.HandlerFunc { http.Error(w, "render failed", http.StatusInternalServerError) return } + rendered, err = RewriteLocalImageURLs(rendered, root, rest) + if err != nil { + slog.Error("docs: local image URL rewrite failed.", "root", root, "rest", rest, "error", err) + http.Error(w, "render failed", http.StatusInternalServerError) + return + } doc, _ := idx.Find(root, rest) renderShell(w, idx, pageContext{ diff --git a/internal/docs/watcher.go b/internal/docs/watcher.go index 64c5956..5a642c9 100644 --- a/internal/docs/watcher.go +++ b/internal/docs/watcher.go @@ -189,8 +189,9 @@ func (w *Watcher) refreshWatch(ev fsnotify.Event) { // relevant reports whether an event path should trigger a reload. // -// It reuses AllowedExt and excludedDir rather than restating either, so the -// watcher cannot disagree with the indexer about what counts as a doc. The +// It reuses AllowedExt, AllowedMediaExt, and excludedDir rather than restating +// them, so the watcher cannot disagree with the server about what can affect a +// rendered doc. The // exclusion half is a security check, not a performance one: without it, a // write under .trash/ or node_modules/ would wake the reader up and rebuild the // index on behalf of a file the reader will then correctly refuse to serve — @@ -202,7 +203,7 @@ func (w *Watcher) refreshWatch(ev fsnotify.Event) { // trigger a rebuild, so relevance is decided from the path string against the // already-canonical root paths. func (w *Watcher) relevant(path string) bool { - if !AllowedExt(path) { + if !AllowedExt(path) && !AllowedMediaExt(path) { return false } @@ -211,8 +212,10 @@ func (w *Watcher) relevant(path string) bool { if !withinRoot(root.Path, path) { continue } - // Naming a single file must not make its siblings live-reloadable any - // more than it makes them servable. + // A single-file root may serve an image only after proving that its sole + // doc references that image. The watcher does not parse every event's + // source to repeat that proof, so it conservatively reloads only the doc + // itself; a changed sibling image is visible after a manual refresh. if root.OnlyFile != "" { return path == root.OnlyFile } diff --git a/internal/docs/watcher_test.go b/internal/docs/watcher_test.go index ca0f228..bcc71b4 100644 --- a/internal/docs/watcher_test.go +++ b/internal/docs/watcher_test.go @@ -122,6 +122,15 @@ func TestWatcherRelevant_MarkdownInNestedDir_IsRelevant(t *testing.T) { } } +func TestWatcherRelevant_MediaUnderDirectoryRootIsRelevant(t *testing.T) { + w, root := relevanceFixture(t) + + path := filepath.Join(root, "images", "architecture.svg") + if !w.relevant(path) { + t.Errorf("relevant(%q) = false, want true", path) + } +} + func TestWatcherRelevant_DotFileWithMarkdownExt_IsRelevant(t *testing.T) { w, root := relevanceFixture(t) @@ -199,7 +208,7 @@ func TestWatcherRelevant_SiblingOfSingleFileRoot_IsNotRelevant(t *testing.T) { func TestWatcherRelevant_NonMarkdownExtension_IsNotRelevant(t *testing.T) { w, root := relevanceFixture(t) - for _, name := range []string{"notes.txt", "secret.env", "image.png", "noext"} { + for _, name := range []string{"notes.txt", "secret.env", "archive.zip", "noext"} { path := filepath.Join(root, name) if w.relevant(path) { t.Errorf("relevant(%q) = true, want false — extension is not in the docs allowlist", path) From 8ee667be961216a98bd89a20388829f4ee9a220b Mon Sep 17 00:00:00 2001 From: Cameron Sjo <4084915+cameronsjo@users.noreply.github.com> Date: Sat, 29 Aug 2026 19:17:19 -0500 Subject: [PATCH 11/21] docs: explain embedded docs preview --- README.md | 38 +++++++++---------- .../plans/2026-08-29-embedded-docs-preview.md | 2 +- .../cli/{docs_browse.go => docs_preview.go} | 0 ...cs_browse_test.go => docs_preview_test.go} | 2 +- internal/cli/init_cmd.go | 2 +- internal/config/config.go | 2 +- 6 files changed, 22 insertions(+), 24 deletions(-) rename internal/cli/{docs_browse.go => docs_preview.go} (100%) rename internal/cli/{docs_browse_test.go => docs_preview_test.go} (93%) diff --git a/README.md b/README.md index ec95eed..799701b 100644 --- a/README.md +++ b/README.md @@ -201,16 +201,12 @@ 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 — native terminal explorer; no separate browser for ordinary reading -forgectl docs [dir|file ...] # browse with the adaptive terminal UI (cwd by default) -forgectl docs browse [dir|file ...] # explicit spelling of the same terminal reader -forgectl docs --graphics off # keep the TUI but render media as readable text fallbacks +# 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 list [dir|file ...] # list the indexed docs, no server (--json for scripting) -# terminal docs keys: tab panes · / filter · enter open · l links · b back · q quit - # net — check cached reachability of the configured probe endpoint forgectl net # show the cached (or freshly probed) answer forgectl net --refresh # force a new probe, bypassing the cache @@ -305,19 +301,21 @@ forgectl y last 5 # print the 5 most recent zsh commands, # acknowledgement only: forgectl does not scan or redact the history ``` -The docs terminal reader uses a two-pane document list and reader when space -allows, then collapses to one pane in a narrow terminal. Relative Markdown -links stay inside the reader; opening an external HTTP link always asks first. - -"Kitty graphics" is a terminal escape-sequence protocol for placing images in -terminal cells. It does not require the Kitty app: Ghostty and several other -terminal emulators implement the same protocol. `--graphics auto` (the default) -enables it only for a recognized terminal, `--graphics kitty` forces it, and -`--graphics off` disables image escape sequences. The reader supports local -PNG, JPEG, static GIF, SVG, and the Mermaid syntax handled by its pure-Go -renderer; remote images, invalid diagrams, and unsupported Mermaid features -remain visible as deliberate text fallbacks. Use `docs serve` when you need -remote or phone access, or the browser reader's Mermaid.js compatibility. +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 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: diff --git a/docs/plans/2026-08-29-embedded-docs-preview.md b/docs/plans/2026-08-29-embedded-docs-preview.md index 5b67d1f..73f014c 100644 --- a/docs/plans/2026-08-29-embedded-docs-preview.md +++ b/docs/plans/2026-08-29-embedded-docs-preview.md @@ -52,7 +52,7 @@ ordinary path, and address the reading gaps exposed by the live cmux proof. - [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. -- [ ] Update help and README; leave generated changelog prose to Release Please. +- [x] Update help and README; leave generated changelog prose to Release Please. - [ ] Run fresh build, vet, tests, formatting, lint, and live cmux acceptance. - [ ] Update and push the existing pull request, then monitor its checks. diff --git a/internal/cli/docs_browse.go b/internal/cli/docs_preview.go similarity index 100% rename from internal/cli/docs_browse.go rename to internal/cli/docs_preview.go diff --git a/internal/cli/docs_browse_test.go b/internal/cli/docs_preview_test.go similarity index 93% rename from internal/cli/docs_browse_test.go rename to internal/cli/docs_preview_test.go index aa874a9..9101dc3 100644 --- a/internal/cli/docs_browse_test.go +++ b/internal/cli/docs_preview_test.go @@ -25,7 +25,7 @@ func TestDocsCommand_NonTTYBareInvocationKeepsHelpBehavior(t *testing.T) { if err := cmd.ExecuteContext(context.Background()); err != nil { t.Fatal(err) } - if !strings.Contains(out.String(), "browse") || !strings.Contains(out.String(), "serve") { + if !strings.Contains(out.String(), "serve + open the reading preview") || !strings.Contains(out.String(), "docs serve") { t.Fatalf("help = %q", out.String()) } } diff --git a/internal/cli/init_cmd.go b/internal/cli/init_cmd.go index a02a55b..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: terminal + web 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 78da46f..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 — terminal + web 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 From 089cb152edda56ee8d792edb72f2ef7392bc5b10 Mon Sep 17 00:00:00 2001 From: Cameron Sjo <4084915+cameronsjo@users.noreply.github.com> Date: Sat, 29 Aug 2026 19:20:09 -0500 Subject: [PATCH 12/21] fix(docs): preserve preview boundaries --- docs/plans/2026-08-29-native-docs-explorer.md | 5 ++++ go.mod | 6 ++--- go.sum | 16 ++++++------- internal/cli/docs_serve.go | 6 ++++- internal/docs/media.go | 23 +++++++++++++++---- internal/docs/media_test.go | 6 ++++- 6 files changed, 44 insertions(+), 18 deletions(-) diff --git a/docs/plans/2026-08-29-native-docs-explorer.md b/docs/plans/2026-08-29-native-docs-explorer.md index 599ca57..1a4a23b 100644 --- a/docs/plans/2026-08-29-native-docs-explorer.md +++ b/docs/plans/2026-08-29-native-docs-explorer.md @@ -1,5 +1,10 @@ # 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 diff --git a/go.mod b/go.mod index f57f30f..30813f0 100644 --- a/go.mod +++ b/go.mod @@ -25,13 +25,13 @@ require ( ) require ( - charm.land/lipgloss/v2 v2.0.4 // indirect + charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/catppuccin/go v0.3.0 // indirect - github.com/charmbracelet/colorprofile v0.4.3 // indirect - github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20251106190538-99ea45596692 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect diff --git a/go.sum b/go.sum index 2acb0fe..8dde925 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -charm.land/lipgloss/v2 v2.0.4 h1:lcPeVtcp23SNra7lHy8iYE4UC2aIipVQ47sbGyyxR5Q= -charm.land/lipgloss/v2 v2.0.4/go.mod h1:0653x8epbZSzdDfO/XPS1a/uYPOBeSsCssOpJOqDzik= +charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410 h1:D9PbaszZYpB4nj+d6HTWr1onlmlyuGVNfL9gAi8iB3k= +charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410/go.mod h1:1qZyvvVCenJO2M1ac2mX0yyiIZJoZmDM4DG4s0udJkU= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= @@ -16,8 +16,8 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= -github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= @@ -26,16 +26,16 @@ github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5f github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= -github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= -github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= github.com/charmbracelet/fang v1.0.0 h1:jESBY40agJOlLYnnv9jE0mLqDGTxEk0hkOnx7YGyRlQ= github.com/charmbracelet/fang v1.0.0/go.mod h1:P5/DNb9DddQ0Z0dbc0P3ol4/ix5Po7Ofr2KMBfAqoCo= github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw= github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 h1:OqDqxQZliC7C8adA7KjelW3OjtAxREfeHkNcd66wpeI= -github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318/go.mod h1:Y6kE2GzHfkyQQVCSL9r2hwokSrIlHGzZG+71+wDYSZI= +github.com/charmbracelet/ultraviolet v0.0.0-20251106190538-99ea45596692 h1:r/3jQZ1LjWW6ybp8HHfhrKrwHIWiJhUuY7wwYIWZulQ= +github.com/charmbracelet/ultraviolet v0.0.0-20251106190538-99ea45596692/go.mod h1:Y8B4DzWeTb0ama8l3+KyopZtkE8fZjwRQ3aEAPEXHE0= github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= diff --git a/internal/cli/docs_serve.go b/internal/cli/docs_serve.go index 281fd9a..f4e9d0c 100644 --- a/internal/cli/docs_serve.go +++ b/internal/cli/docs_serve.go @@ -231,7 +231,11 @@ func runDocsServe(cmd *cobra.Command, deps module.Deps, idx *docspkg.Index, addr } func runDocsPreviewServer(cmd *cobra.Command, deps module.Deps, idx *docspkg.Index) error { - return runDocsServeWithRuntimeMode(cmd, deps, idx, "", docsOpenEmbedded, "", productionDocsServeRuntime()) + // 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 diff --git a/internal/docs/media.go b/internal/docs/media.go index dfcbc2f..27e0f88 100644 --- a/internal/docs/media.go +++ b/internal/docs/media.go @@ -15,8 +15,6 @@ import ( "golang.org/x/net/html" ) -var errMediaNotReferenced = errors.New("media is not referenced by the document") - var mediaTypes = map[string]string{ ".avif": "image/avif", ".gif": "image/gif", @@ -56,7 +54,12 @@ func RewriteLocalImageURLs(rendered, rootLabel, docRel string) (string, error) { 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" { @@ -71,9 +74,14 @@ func RewriteLocalImageURLs(rendered, rootLabel, docRel string) (string, error) { if fragment != "" { token.Attr[i].Val += "#" + url.PathEscape(fragment) } + rewritten = true } } - out.WriteString(token.String()) + if rewritten { + out.WriteString(token.String()) + } else { + out.Write(rawToken) + } } } @@ -146,8 +154,13 @@ func handleMedia(store *Store) http.HandlerFunc { return } rendered, err := Render(source) - if err != nil || !renderedReferencesMedia(rendered, docRel, requested) { - slog.Debug("docs: media request was not referenced by its document.", "root", rootLabel, "doc", docRel, "media", requested, "error", errMediaNotReferenced) + 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 } diff --git a/internal/docs/media_test.go b/internal/docs/media_test.go index 258583a..3b3839d 100644 --- a/internal/docs/media_test.go +++ b/internal/docs/media_test.go @@ -10,7 +10,8 @@ import ( ) func TestRewriteLocalImageURLs_RewritesOnlyContainedRelativeImages(t *testing.T) { - rendered := `

local` + + rendered := `` + + `

local` + `remote` + `inline` + `escape

` @@ -19,6 +20,9 @@ func TestRewriteLocalImageURLs_RewritesOnlyContainedRelativeImages(t *testing.T) if err != nil { t.Fatal(err) } + if !strings.Contains(got, ``) { + t.Errorf("rewrite changed unrelated inline SVG markup: %s", got) + } wantQuery := url.Values{"doc": {"guide/setup/readme.md"}, "path": {"guide/images/architecture.svg"}}.Encode() if !strings.Contains(got, `/media/docs?`+strings.ReplaceAll(wantQuery, "&", "&")+`#focus`) { t.Errorf("rewritten HTML missing local media URL: %s", got) From 91136936df67f35ce2752a2a479cc3113ee23b46 Mon Sep 17 00:00:00 2001 From: Cameron Sjo <4084915+cameronsjo@users.noreply.github.com> Date: Sat, 29 Aug 2026 19:23:11 -0500 Subject: [PATCH 13/21] fix(docs): satisfy preview safety checks --- internal/cli/docs_serve.go | 8 ++++++-- internal/docs/media.go | 14 +++++++++++--- internal/docs/media_test.go | 11 ++++++----- internal/docs/server_test.go | 3 ++- 4 files changed, 25 insertions(+), 11 deletions(-) diff --git a/internal/cli/docs_serve.go b/internal/cli/docs_serve.go index f4e9d0c..c029cec 100644 --- a/internal/cli/docs_serve.go +++ b/internal/cli/docs_serve.go @@ -495,7 +495,9 @@ func runDocsServeWithRuntimeMode( workspaceID := os.Getenv("CMUX_WORKSPACE_ID") if workspaceID != "" { if openErr := docspkg.OpenCMUXPreview(ctx, deps.Runner, workspaceID, url); openErr == nil { - fmt.Fprintln(out, " preview: embedded in cmux (server remains in this terminal)") + 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) @@ -560,7 +562,9 @@ func openSystemBrowser(ctx context.Context, deps module.Deps, url string, out, e warnDocsServe(errOut, "warning: failed to open system browser: %v", openErr) return } - fmt.Fprintln(out, " preview: system browser (server remains in this terminal)") + 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. diff --git a/internal/docs/media.go b/internal/docs/media.go index 27e0f88..bb8ab15 100644 --- a/internal/docs/media.go +++ b/internal/docs/media.go @@ -148,7 +148,9 @@ func handleMedia(store *Store) http.HandlerFunc { http.NotFound(w, r) return } - source, err := os.ReadFile(docPath) + // 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 @@ -175,12 +177,18 @@ func handleMedia(store *Store) http.HandlerFunc { http.NotFound(w, r) return } - file, err := os.Open(mediaPath) + // 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 file.Close() + 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) diff --git a/internal/docs/media_test.go b/internal/docs/media_test.go index 3b3839d..c3fb472 100644 --- a/internal/docs/media_test.go +++ b/internal/docs/media_test.go @@ -1,6 +1,7 @@ package docs import ( + "context" "net/http" "net/http/httptest" "net/url" @@ -50,7 +51,7 @@ func TestServer_RelativeMarkdownImageIsServed(t *testing.T) { h := testHandler(idx) docRec := httptest.NewRecorder() - h.ServeHTTP(docRec, httptest.NewRequest(http.MethodGet, "/doc/"+label+"/guide/readme.md", nil)) + h.ServeHTTP(docRec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/doc/"+label+"/guide/readme.md", nil)) if docRec.Code != http.StatusOK { t.Fatalf("doc status = %d", docRec.Code) } @@ -61,7 +62,7 @@ func TestServer_RelativeMarkdownImageIsServed(t *testing.T) { } mediaRec := httptest.NewRecorder() - h.ServeHTTP(mediaRec, httptest.NewRequest(http.MethodGet, mediaURL, nil)) + h.ServeHTTP(mediaRec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, mediaURL, nil)) if mediaRec.Code != http.StatusOK { t.Fatalf("media status = %d, body: %s", mediaRec.Code, mediaRec.Body.String()) } @@ -84,7 +85,7 @@ func TestServer_MediaRequiresDocumentReference(t *testing.T) { label := idx.Roots()[0].Label query := url.Values{"doc": {"readme.md"}, "path": {"secret.png"}}.Encode() rec := httptest.NewRecorder() - testHandler(idx).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/media/"+label+"?"+query, nil)) + testHandler(idx).ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/media/"+label+"?"+query, nil)) if rec.Code != http.StatusNotFound { t.Fatalf("status = %d, want 404", rec.Code) } @@ -102,7 +103,7 @@ func TestServer_SingleFileRootMayServeItsReferencedSiblingImage(t *testing.T) { label := idx.Roots()[0].Label query := url.Values{"doc": {"readme.md"}, "path": {"sample.png"}}.Encode() rec := httptest.NewRecorder() - testHandler(idx).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/media/"+label+"?"+query, nil)) + testHandler(idx).ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/media/"+label+"?"+query, nil)) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String()) } @@ -119,7 +120,7 @@ func TestServer_MediaUnderExcludedDirectoryIsRejected(t *testing.T) { label := idx.Roots()[0].Label query := url.Values{"doc": {"readme.md"}, "path": {".private/image.png"}}.Encode() rec := httptest.NewRecorder() - testHandler(idx).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/media/"+label+"?"+query, nil)) + testHandler(idx).ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/media/"+label+"?"+query, nil)) if rec.Code != http.StatusNotFound { t.Fatalf("status = %d, want 404", rec.Code) } diff --git a/internal/docs/server_test.go b/internal/docs/server_test.go index 3335bf7..8f48b13 100644 --- a/internal/docs/server_test.go +++ b/internal/docs/server_test.go @@ -30,6 +30,7 @@ package docs // filesystem existence import ( + "context" "encoding/json" "net/http" "net/http/httptest" @@ -115,7 +116,7 @@ func TestServer_StaticAssets_Served(t *testing.T) { func TestServer_ShellIncludesPersistedReadingControls(t *testing.T) { idx, _ := testIndex(t) rec := httptest.NewRecorder() - testHandler(idx).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + testHandler(idx).ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/", nil)) body := rec.Body.String() for _, want := range []string{ From 96a52147de03b30d7e987044a940ffafd6ef6c1a Mon Sep 17 00:00:00 2001 From: Cameron Sjo <4084915+cameronsjo@users.noreply.github.com> Date: Sat, 29 Aug 2026 19:27:05 -0500 Subject: [PATCH 14/21] docs(plan): record embedded preview verification --- docs/plans/2026-08-29-embedded-docs-preview.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plans/2026-08-29-embedded-docs-preview.md b/docs/plans/2026-08-29-embedded-docs-preview.md index 73f014c..3fc1fa0 100644 --- a/docs/plans/2026-08-29-embedded-docs-preview.md +++ b/docs/plans/2026-08-29-embedded-docs-preview.md @@ -53,7 +53,7 @@ ordinary path, and address the reading gaps exposed by the live cmux proof. - [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. -- [ ] Run fresh build, vet, tests, formatting, lint, and live cmux acceptance. +- [x] Run fresh build, vet, tests, formatting, lint, and live cmux acceptance. - [ ] Update and push the existing pull request, then monitor its checks. ## Acceptance From 08890193457b4ab291edd685d80d0eb7c0f2f476 Mon Sep 17 00:00:00 2001 From: Cameron Sjo <4084915+cameronsjo@users.noreply.github.com> Date: Sat, 29 Aug 2026 19:28:24 -0500 Subject: [PATCH 15/21] docs(plan): record PR update --- docs/plans/2026-08-29-embedded-docs-preview.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plans/2026-08-29-embedded-docs-preview.md b/docs/plans/2026-08-29-embedded-docs-preview.md index 3fc1fa0..be7aabc 100644 --- a/docs/plans/2026-08-29-embedded-docs-preview.md +++ b/docs/plans/2026-08-29-embedded-docs-preview.md @@ -54,7 +54,7 @@ ordinary path, and address the reading gaps exposed by the live cmux proof. - [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. -- [ ] Update and push the existing pull request, then monitor its checks. +- [x] Update and push the existing pull request, then monitor its checks. ## Acceptance From b987028e0a58cee372c32766e16b15eb00052ff7 Mon Sep 17 00:00:00 2001 From: Cameron Sjo <4084915+cameronsjo@users.noreply.github.com> Date: Sat, 29 Aug 2026 20:27:58 -0500 Subject: [PATCH 16/21] docs(plan): refine docs preview for reading --- .../2026-08-29-reading-first-docs-preview.md | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 docs/plans/2026-08-29-reading-first-docs-preview.md 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..7244aa5 --- /dev/null +++ b/docs/plans/2026-08-29-reading-first-docs-preview.md @@ -0,0 +1,54 @@ +# 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. +- [ ] Replace the stacking sidebar with an accessible off-canvas drawer. +- [ ] Reduce the header and make the current document the primary label. +- [ ] Tune article spacing and responsive behavior for a half-screen cmux pane. +- [ ] Update tests and user-facing documentation. +- [ ] Run fresh formatting, JavaScript syntax, lint, vet, full tests, and live + cmux visual acceptance. +- [ ] Push and monitor the updated pull request. + +## 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. From 4b26215ae767fa36d301c6d94aed7dd9b4bfce58 Mon Sep 17 00:00:00 2001 From: Cameron Sjo <4084915+cameronsjo@users.noreply.github.com> Date: Sun, 30 Aug 2026 09:01:06 -0500 Subject: [PATCH 17/21] feat(docs): make embedded preview reading-first --- README.md | 4 +- .../2026-08-29-reading-first-docs-preview.md | 8 +- internal/docs/assets.go | 7 +- internal/docs/assets/reader-shell.js | 50 ++++++ internal/docs/assets/reader.css | 164 +++++++++++++++++- internal/docs/server.go | 1 + internal/docs/server_test.go | 26 ++- internal/docs/templates/shell.html.tmpl | 26 ++- 8 files changed, 267 insertions(+), 19 deletions(-) create mode 100644 internal/docs/assets/reader-shell.js diff --git a/README.md b/README.md index 799701b..624d7e7 100644 --- a/README.md +++ b/README.md @@ -309,7 +309,9 @@ 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 renders sanitized Markdown, syntax highlighting, tables, Mermaid, +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 diff --git a/docs/plans/2026-08-29-reading-first-docs-preview.md b/docs/plans/2026-08-29-reading-first-docs-preview.md index 7244aa5..f86bf7b 100644 --- a/docs/plans/2026-08-29-reading-first-docs-preview.md +++ b/docs/plans/2026-08-29-reading-first-docs-preview.md @@ -34,10 +34,10 @@ especially at the narrow widths created by a right-hand cmux split. ## Checklist - [x] Persist and commit the approved reading-first refinement. -- [ ] Replace the stacking sidebar with an accessible off-canvas drawer. -- [ ] Reduce the header and make the current document the primary label. -- [ ] Tune article spacing and responsive behavior for a half-screen cmux pane. -- [ ] Update tests and user-facing documentation. +- [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. - [ ] Run fresh formatting, JavaScript syntax, lint, vet, full tests, and live cmux visual acceptance. - [ ] Push and monitor the updated pull request. diff --git a/internal/docs/assets.go b/internal/docs/assets.go index bab64a2..cd9fe54 100644 --- a/internal/docs/assets.go +++ b/internal/docs/assets.go @@ -45,7 +45,7 @@ var reloadJS []byte //go:embed assets/sidenav-filter.js var sidenavFilterJS []byte -// readerCSS and readerSettingsJS are the docs reader's deliberately local +// 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. @@ -53,6 +53,9 @@ var sidenavFilterJS []byte //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 @@ -85,7 +88,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-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 index 7c9d41a..19d2b14 100644 --- a/internal/docs/assets/reader.css +++ b/internal/docs/assets/reader.css @@ -5,12 +5,164 @@ --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__title:hover { + color: var(--fg); +} + +.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 { + position: fixed; + z-index: 35; + top: var(--reader-toolbar-height); + bottom: 0; + left: 0; + display: flex; + width: min(320px, calc(100vw - 40px)); + flex-direction: column; + border-right: 1px solid var(--border); + background: var(--bg-raised); + box-shadow: 12px 0 36px color-mix(in srgb, #000 32%, transparent); + transform: translateX(calc(-100% - 16px)); + transition: transform 160ms ease; + visibility: hidden; +} + +[data-reader-shell][data-nav-open] .reader-nav { + transform: translateX(0); + visibility: visible; +} + +.reader-nav__scrim { + position: fixed; + 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; + opacity: 0; + pointer-events: none; + transition: opacity 160ms ease; + visibility: hidden; +} + +[data-reader-shell][data-nav-open] .reader-nav__scrim { + opacity: 1; + pointer-events: auto; + visibility: visible; +} + +.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; - padding: clamp(28px, 5vw, 72px); + min-height: calc(100vh - var(--reader-toolbar-height)); + padding: clamp(28px, 4vw, 56px) clamp(20px, 6vw, 72px); } .reader-content { @@ -79,6 +231,7 @@ grid-template-columns: 1fr 1fr; gap: var(--s-md); padding: var(--s-lg); + z-index: 50; } .reader-settings__panel .field { @@ -125,7 +278,7 @@ @media (max-width: 800px) { .docs-reader { - padding: var(--s-xl) var(--s-lg); + padding: 26px 20px 48px; } .reader-settings__panel { @@ -134,3 +287,10 @@ right: 12px; } } + +@media (prefers-reduced-motion: reduce) { + .reader-nav, + .reader-nav__scrim { + transition: none; + } +} diff --git a/internal/docs/server.go b/internal/docs/server.go index 54efae3..6c7d6f1 100644 --- a/internal/docs/server.go +++ b/internal/docs/server.go @@ -192,6 +192,7 @@ func NewHandler(store *Store, events *Broker) http.Handler { mux.HandleFunc("GET /assets/artificer-tree.js", serveStaticJS(artificerTreeJS)) mux.HandleFunc("GET /assets/sidenav-filter.js", serveStaticJS(sidenavFilterJS)) mux.HandleFunc("GET /assets/reader.css", serveStaticCSS(readerCSS)) + mux.HandleFunc("GET /assets/reader-shell.js", serveStaticJS(readerShellJS)) mux.HandleFunc("GET /assets/reader-settings.js", serveStaticJS(readerSettingsJS)) mux.HandleFunc("GET /assets/chroma.css", serveStaticCSS(ChromaCSS())) mux.HandleFunc("GET /assets/diagram.css", serveStaticCSS(diagramCSS)) diff --git a/internal/docs/server_test.go b/internal/docs/server_test.go index 8f48b13..3482308 100644 --- a/internal/docs/server_test.go +++ b/internal/docs/server_test.go @@ -99,7 +99,7 @@ func TestServer_StaticAssets_Served(t *testing.T) { idx, _ := testIndex(t) h := testHandler(idx) - for _, path := range []string{"/assets/artificer.css", "/assets/artificer-theme.js", "/assets/reload.js", "/assets/chroma.css", "/assets/sidenav-filter.js", "/assets/reader.css", "/assets/reader-settings.js"} { + for _, path := range []string{"/assets/artificer.css", "/assets/artificer-theme.js", "/assets/reload.js", "/assets/chroma.css", "/assets/sidenav-filter.js", "/assets/reader.css", "/assets/reader-shell.js", "/assets/reader-settings.js"} { t.Run(path, func(t *testing.T) { rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil)) @@ -134,6 +134,30 @@ func TestServer_ShellIncludesPersistedReadingControls(t *testing.T) { } } +func TestServer_ShellUsesReadingFirstNavigation(t *testing.T) { + idx, label := testIndex(t) + rec := httptest.NewRecorder() + testHandler(idx).ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/doc/"+label+"/welcome.md", nil)) + + body := rec.Body.String() + for _, want := range []string{ + `data-docs-nav-toggle`, + `aria-controls="docs-navigation"`, + `aria-expanded="false"`, + `data-docs-nav aria-hidden="true"`, + `data-docs-nav-scrim`, + `src="/assets/reader-shell.js"`, + `class="reader-toolbar__title" href="/">Welcome`, + } { + if !strings.Contains(body, want) { + t.Errorf("shell missing %q", want) + } + } + if strings.Contains(body, `class="split-pane"`) { + t.Error("shell still uses a persistent or stacking split-pane navigator") + } +} + // doRequestFollowingOneRedirect drives req through h and, if the response is // a redirect (Go's stdlib ServeMux 307s a request whose path contains a // literal "../" segment before our handler ever sees it — its own, diff --git a/internal/docs/templates/shell.html.tmpl b/internal/docs/templates/shell.html.tmpl index dd38a7b..e988fc9 100644 --- a/internal/docs/templates/shell.html.tmpl +++ b/internal/docs/templates/shell.html.tmpl @@ -12,6 +12,7 @@ +