Skip to content

Commit 8650959

Browse files
authored
Merge pull request #1456 from lerd-env/feat/dashboard-docs
feat(ui): serve the documentation in the dashboard, offline
2 parents ec5414b + be13118 commit 8650959

42 files changed

Lines changed: 1901 additions & 52 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/features/web-ui.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ A service worker ships with the dashboard, so when lerd is stopped (including vi
3232

3333
The default landing page is a **Dashboard** with at-a-glance widgets across sites, services, workers, and system health. Selecting Sites, Services, or System switches to a three-pane layout:
3434

35-
- **Left icon rail**: the lerd logo at the top is the link back to the Dashboard; below it sit the Sites, Services, and System icon buttons; a separator further down lists a per-service icon for every running service that exposes a dashboard (phpMyAdmin, pgAdmin, Mailpit, RustFS, Meilisearch, Mongo Express, Selenium, etc.), and clicking one opens that dashboard inline as a full-width iframe over the middle and detail panels; the notification bell, theme switcher and docs link at the bottom. The bell keeps recent notifications with an unread count; the theme switcher is a single icon showing the mode in effect (sun, moon, or a half circle for following the system) that opens a menu to pick between Light, Dark and System, with the tooltip naming what System currently resolves to. Hovering any rail icon reveals its label in a floating tooltip to the right, so the collapsed rail stays readable at a glance
35+
- **Left icon rail**: the lerd logo at the top is the link back to the Dashboard; below it sit the Sites, Services, and System icon buttons; a separator further down lists a per-service icon for every running service that exposes a dashboard (phpMyAdmin, pgAdmin, Mailpit, RustFS, Meilisearch, Mongo Express, Selenium, etc.), and clicking one opens that dashboard inline as a full-width iframe over the middle and detail panels; the notification bell, theme switcher and documentation button at the bottom, the last of which opens the [built-in documentation](#documentation) rather than the website. The bell keeps recent notifications with an unread count; the theme switcher is a single icon showing the mode in effect (sun, moon, or a half circle for following the system) that opens a menu to pick between Light, Dark and System, with the tooltip naming what System currently resolves to. Hovering any rail icon reveals its label in a floating tooltip to the right, so the collapsed rail stays readable at a glance
3636
- **Middle list panel**: scrollable list of all items in the active section; status dots, compact rows, collapsible groups (hidden on the Dashboard)
3737
- **Detail panel**: full controls and live logs for the selected item
3838

@@ -168,6 +168,12 @@ Selecting an item opens its detail panel:
168168

169169
The **Start** / **Stop** buttons in the System panel header start or stop all core services (DNS, nginx, and all PHP-FPM containers for versions that have active sites).
170170

171+
## Documentation
172+
173+
The book icon at the bottom of the icon rail opens this documentation inside the dashboard. The pages are the ones embedded in the binary, the same set [`lerd man`](../reference/commands.md) reads in the terminal, so they open on a machine with no internet instead of sending you to lerd.sh. Pick a page from the list on the left, or type in the search box above it to search the full text of every page; each result shows the sentence the match sits in.
174+
175+
Links between pages stay inside the dashboard and headings scroll into view, so a cross-reference reads the way it does on the website. The address follows along (`#docs/usage/sites`), which makes any page a bookmark, and the header keeps a link to the same page on lerd.sh for when you do want the website. Screenshots come out of the binary too, and because the pages are ordinary requests to the daemon, the service worker keeps the ones you have opened for later.
176+
171177
## Updates
172178

173179
Shows the current version. When an update is available, the Lerd entry exposes an **Open terminal & update** button that launches the host's terminal emulator running `lerd update`. A remote dashboard shows the same action once `lerd remote-control full-access on` is set; the terminal opens on the host that runs Lerd.

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ require (
1919
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
2020
github.com/spf13/cobra v1.10.2
2121
github.com/spf13/viper v1.21.0
22+
github.com/yuin/goldmark v1.8.4
2223
golang.org/x/crypto v0.54.0
2324
golang.org/x/sys v0.47.0
2425
golang.org/x/term v0.45.0
@@ -75,7 +76,6 @@ require (
7576
github.com/spf13/pflag v1.0.10 // indirect
7677
github.com/subosito/gotenv v1.6.0 // indirect
7778
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
78-
github.com/yuin/goldmark v1.8.4 // indirect
7979
github.com/yuin/goldmark-emoji v1.0.6 // indirect
8080
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
8181
go.opentelemetry.io/otel v1.44.0 // indirect

internal/cli/man.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"strings"
77

88
tea "charm.land/bubbletea/v2"
9+
"github.com/geodro/lerd/internal/docs"
910
"github.com/geodro/lerd/internal/man"
1011
"github.com/spf13/cobra"
1112
"golang.org/x/term"
@@ -33,7 +34,7 @@ func runMan(_ *cobra.Command, args []string) error {
3334
// would otherwise block until a key press.
3435
glamStyle := glamourStyle()
3536

36-
pages := man.BuildRegistry()
37+
pages := docs.BuildRegistry()
3738
m := man.NewModel(pages, args, glamStyle)
3839
p := tea.NewProgram(m)
3940
_, err := p.Run()
@@ -50,7 +51,7 @@ func glamourStyle() string {
5051
}
5152

5253
func runManPlain(args []string) error {
53-
pages := man.BuildRegistry()
54+
pages := docs.BuildRegistry()
5455

5556
if len(args) > 0 {
5657
query := args[0]
@@ -70,7 +71,7 @@ func runManPlain(args []string) error {
7071
for _, p := range pages {
7172
if p.Section != lastSection {
7273
lastSection = p.Section
73-
fmt.Printf("\n%s\n", man.SectionLabel(p.Section))
74+
fmt.Printf("\n%s\n", docs.SectionLabel(p.Section))
7475
}
7576
fmt.Printf(" %-32s lerd man %s\n", p.Title, p.Slug)
7677
}

internal/docs/html.go

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
package docs
2+
3+
import (
4+
"bytes"
5+
"path"
6+
"strings"
7+
8+
"github.com/yuin/goldmark"
9+
"github.com/yuin/goldmark/ast"
10+
"github.com/yuin/goldmark/extension"
11+
"github.com/yuin/goldmark/parser"
12+
"github.com/yuin/goldmark/text"
13+
"github.com/yuin/goldmark/util"
14+
)
15+
16+
// AssetPrefix is where the dashboard serves the images the docs reference.
17+
const AssetPrefix = "/docs/"
18+
19+
// RoutePrefix is the dashboard hash route a documentation page lives at.
20+
const RoutePrefix = "#docs/"
21+
22+
// RenderHTML renders a page to an HTML fragment for the dashboard. Links between
23+
// pages become dashboard routes and images become URLs the binary serves, so a
24+
// reader never leaves the machine. Raw HTML is dropped rather than emitted.
25+
func RenderHTML(p Page) (string, error) {
26+
md := goldmark.New(
27+
goldmark.WithExtensions(extension.GFM),
28+
goldmark.WithParserOptions(
29+
parser.WithAutoHeadingID(),
30+
parser.WithASTTransformers(util.Prioritized(&linkRewriter{page: p}, 100)),
31+
),
32+
)
33+
var buf bytes.Buffer
34+
if err := md.Convert([]byte(Normalize(p.Content())), &buf); err != nil {
35+
return "", err
36+
}
37+
return buf.String(), nil
38+
}
39+
40+
type linkRewriter struct {
41+
page Page
42+
}
43+
44+
func (t *linkRewriter) Transform(doc *ast.Document, _ text.Reader, _ parser.Context) {
45+
_ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
46+
if !entering {
47+
return ast.WalkContinue, nil
48+
}
49+
switch v := n.(type) {
50+
case *ast.Image:
51+
v.Destination = []byte(AssetURL(string(v.Destination), t.page.Section))
52+
case *ast.Link:
53+
dest := string(v.Destination)
54+
if isExternal(dest) {
55+
v.SetAttributeString("target", []byte("_blank"))
56+
v.SetAttributeString("rel", []byte("noopener noreferrer"))
57+
return ast.WalkContinue, nil
58+
}
59+
v.Destination = []byte(PageHref(dest, t.page))
60+
}
61+
return ast.WalkContinue, nil
62+
})
63+
}
64+
65+
// PageHref rewrites a link between documentation pages into the dashboard route
66+
// that shows it. Anchors survive, and an image or download link keeps pointing at
67+
// the served file.
68+
func PageHref(dest string, from Page) string {
69+
if dest == "" {
70+
return dest
71+
}
72+
if strings.HasPrefix(dest, "#") {
73+
return RoutePrefix + from.Route() + dest
74+
}
75+
76+
target, anchor, _ := strings.Cut(dest, "#")
77+
if anchor != "" {
78+
anchor = "#" + anchor
79+
}
80+
if ext := path.Ext(target); ext != "" && ext != ".md" {
81+
return AssetURL(dest, from.Section)
82+
}
83+
84+
target = strings.TrimSuffix(target, ".md")
85+
if strings.HasPrefix(target, "/") {
86+
target = strings.TrimPrefix(target, "/")
87+
} else {
88+
target = path.Join(from.Section, target)
89+
}
90+
target = strings.TrimSuffix(path.Clean("/"+target), "/")
91+
return RoutePrefix + strings.TrimPrefix(target, "/") + anchor
92+
}
93+
94+
// AssetURL rewrites a file reference in the docs into the URL the binary serves
95+
// it from. Site-absolute paths keep their shape under the prefix, relative ones
96+
// resolve against the page's section.
97+
func AssetURL(dest, section string) string {
98+
if dest == "" || isExternal(dest) || strings.HasPrefix(dest, "data:") {
99+
return dest
100+
}
101+
if !strings.HasPrefix(dest, "/") {
102+
dest = "/" + path.Join(section, dest)
103+
}
104+
return strings.TrimSuffix(AssetPrefix, "/") + path.Clean(dest)
105+
}
106+
107+
func isExternal(dest string) bool {
108+
return strings.Contains(dest, "://") || strings.HasPrefix(dest, "mailto:")
109+
}

internal/docs/html_test.go

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
package docs
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
func page(section, slug, content string) Page {
9+
return Page{Title: "T", Section: section, Slug: slug, Path: "docs/" + slug + ".md", content: content}
10+
}
11+
12+
func TestPageHref(t *testing.T) {
13+
from := page("usage", "sites", "")
14+
15+
tests := []struct {
16+
dest string
17+
want string
18+
}{
19+
{"../features/mcp.md", "#docs/features/mcp"},
20+
{"site-groups.md", "#docs/usage/site-groups"},
21+
{"./service-presets.md", "#docs/usage/service-presets"},
22+
{"/features/web-ui", "#docs/features/web-ui"},
23+
{"/configuration#per-project-config", "#docs/configuration#per-project-config"},
24+
{"../features/git-worktrees.md#env-overrides", "#docs/features/git-worktrees#env-overrides"},
25+
{"#env-overrides", "#docs/usage/sites#env-overrides"},
26+
{"/assets/screenshots/dashboard.png", "/docs/assets/screenshots/dashboard.png"},
27+
}
28+
29+
for _, tt := range tests {
30+
if got := PageHref(tt.dest, from); got != tt.want {
31+
t.Errorf("PageHref(%q) = %q, want %q", tt.dest, got, tt.want)
32+
}
33+
}
34+
}
35+
36+
func TestPageHrefFromTopLevelPage(t *testing.T) {
37+
from := page("", "configuration", "")
38+
if got, want := PageHref("./features/git-worktrees.md", from), "#docs/features/git-worktrees"; got != want {
39+
t.Errorf("PageHref() = %q, want %q", got, want)
40+
}
41+
if got, want := PageHref("#env-overrides", from), "#docs/configuration#env-overrides"; got != want {
42+
t.Errorf("PageHref() = %q, want %q", got, want)
43+
}
44+
}
45+
46+
func TestAssetURL(t *testing.T) {
47+
tests := []struct {
48+
dest string
49+
section string
50+
want string
51+
}{
52+
{"/assets/screenshots/dashboard.png", "usage", "/docs/assets/screenshots/dashboard.png"},
53+
{"images/local.png", "features", "/docs/features/images/local.png"},
54+
{"https://lerd.sh/a.png", "usage", "https://lerd.sh/a.png"},
55+
}
56+
for _, tt := range tests {
57+
if got := AssetURL(tt.dest, tt.section); got != tt.want {
58+
t.Errorf("AssetURL(%q, %q) = %q, want %q", tt.dest, tt.section, got, tt.want)
59+
}
60+
}
61+
}
62+
63+
func TestRenderHTMLRewritesLinksAndImages(t *testing.T) {
64+
p := page("usage", "sites", "# Sites\n\nSee [MCP](../features/mcp.md) and [lerd.sh](https://lerd.sh).\n\n![shot](/assets/screenshots/dashboard.png)\n")
65+
66+
got, err := RenderHTML(p)
67+
if err != nil {
68+
t.Fatalf("RenderHTML() error: %v", err)
69+
}
70+
71+
for _, want := range []string{
72+
`href="#docs/features/mcp"`,
73+
`href="https://lerd.sh"`,
74+
`target="_blank"`,
75+
`src="/docs/assets/screenshots/dashboard.png"`,
76+
} {
77+
if !strings.Contains(got, want) {
78+
t.Errorf("RenderHTML() = %q, want it to contain %q", got, want)
79+
}
80+
}
81+
}
82+
83+
func TestRenderHTMLTablesAndHeadingIDs(t *testing.T) {
84+
p := page("", "configuration", "## Per-project config\n\n| Key | Meaning |\n|---|---|\n| `php` | version |\n")
85+
86+
got, err := RenderHTML(p)
87+
if err != nil {
88+
t.Fatalf("RenderHTML() error: %v", err)
89+
}
90+
for _, want := range []string{"<table>", `id="per-project-config"`, "<code>php</code>"} {
91+
if !strings.Contains(got, want) {
92+
t.Errorf("RenderHTML() = %q, want it to contain %q", got, want)
93+
}
94+
}
95+
}
96+
97+
func TestRenderHTMLNormalizesContainers(t *testing.T) {
98+
p := page("usage", "services", "::: warning Known limitation\nOnly loopback.\n:::\n")
99+
100+
got, err := RenderHTML(p)
101+
if err != nil {
102+
t.Fatalf("RenderHTML() error: %v", err)
103+
}
104+
if !strings.Contains(got, "<blockquote>") || !strings.Contains(got, "Warning: Known limitation") {
105+
t.Errorf("RenderHTML() = %q, want a blockquote callout", got)
106+
}
107+
if strings.Contains(got, ":::") {
108+
t.Errorf("RenderHTML() leaked container syntax: %q", got)
109+
}
110+
}
111+
112+
func TestRenderHTMLDropsRawHTML(t *testing.T) {
113+
p := page("usage", "sites", "<script>alert(1)</script>\n\nSafe text.\n")
114+
115+
got, err := RenderHTML(p)
116+
if err != nil {
117+
t.Fatalf("RenderHTML() error: %v", err)
118+
}
119+
if strings.Contains(got, "<script>") {
120+
t.Errorf("RenderHTML() emitted raw HTML: %q", got)
121+
}
122+
}
123+
124+
func TestRenderHTMLEveryEmbeddedPage(t *testing.T) {
125+
for _, p := range BuildRegistry() {
126+
out, err := RenderHTML(p)
127+
if err != nil {
128+
t.Errorf("RenderHTML(%s) error: %v", p.Path, err)
129+
continue
130+
}
131+
if strings.TrimSpace(out) == "" {
132+
t.Errorf("RenderHTML(%s) produced nothing", p.Path)
133+
}
134+
if strings.Contains(out, ":::") {
135+
t.Errorf("RenderHTML(%s) leaked container syntax", p.Path)
136+
}
137+
}
138+
}

0 commit comments

Comments
 (0)