Skip to content

Commit 21448ae

Browse files
committed
feat(docs): render /doc inside the dashboard chrome for signed-in users
A signed-in visitor now gets every doc page (core and extension alike, both flow through docs.Handler.Render) inside the authenticated dashboard shell with the Documentation sidebar item lit and a docs breadcrumb, instead of the standalone public shell. Anonymous visitors and auth-less community builds keep the standalone rendering unchanged. Wired in the composition root via docs EnableInApp + web.RenderDocPage, with the render func wrapped in the auth middleware so the sidebar tenant and ingest-key mask resolve. See docs/adr/0023-in-app-documentation.md.
1 parent 3d35333 commit 21448ae

8 files changed

Lines changed: 239 additions & 18 deletions

File tree

‎docs/adr/0023-in-app-documentation.md‎

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,20 +31,31 @@ core repo and must exist in every build, not only behind the commercial binary.
3131
uses `goldmark` (GFM + auto heading IDs) in its safe default (raw HTML in a
3232
source file is escaped), so a future externally sourced page cannot inject
3333
markup.
34-
- **Its own public shell, not the dashboard chrome.** `/doc` renders in a
35-
standalone dark shell (its own inline CSS, the product palette and fonts) with a
36-
left table-of-contents rail. It deliberately does **not** reuse the dashboard's
37-
authenticated `RenderShell`: docs must render for anonymous visitors and in the
38-
community build, where there is no org/session context. The pages carry a
39-
`script-src 'none'` CSP — they are pure HTML + CSS, no JavaScript.
34+
- **Standalone shell for anonymous visitors, dashboard chrome for signed-in
35+
users.** For an anonymous request `/doc` renders in a standalone dark shell (its
36+
own inline CSS, the product palette and fonts) with a left table-of-contents
37+
rail — docs must render without an org/session context, and in the community
38+
build (no auth layer) every request takes this path. These pages carry a
39+
`script-src 'none'` CSP — pure HTML + CSS, no JavaScript. When dashboard auth is
40+
on and the request carries a valid session, the same pages render **inside the
41+
dashboard chrome** instead: the composition root wires
42+
`docs.Handler.EnableInApp(authLayer.Authenticated, ...)`, and `Render` hands the
43+
doc fragment (same grouped TOC rail + prose, `doc-`-prefixed scoped styles) to
44+
`web.RenderDocPage`, which lights the Documentation sidebar item and adds a
45+
`docs → <title>` breadcrumb. The render func wraps itself in the auth middleware
46+
so the verified session lands in the request context (sidebar tenant and
47+
ingest-key mask); it cannot redirect, since it only runs after `Authenticated`
48+
reported the cookie valid. Clicking Documentation therefore never drops a
49+
signed-in user out of the dashboard onto a marketing-looking page.
4050
- **Two composition seams (extends ADR-0016).** So the enterprise binary can add
4151
its own doc pages without forking:
4252
- `WithDocSections(...docs.Section)` injects entries into the shared table of
4353
contents, so an extension's topics appear in the rail on every doc page.
4454
- `RouteContext.RenderDoc(w, r, title, body)` wraps an extension-produced body
4555
(rendered from its own embedded Markdown via the exported
4656
`docs.MarkdownToHTML`) in the exact same shell and merged TOC. An extension
47-
page is therefore indistinguishable from a core one.
57+
page is therefore indistinguishable from a core one — including the
58+
anonymous/signed-in split above, since both flow through the same `Render`.
4859
- `WithDocHeader(template.HTML)` injects a full site header rendered above every
4960
doc page, so the documentation wears the same chrome as the rest of the site
5061
(same logo, nav, and calls to action) rather than a detached bar — the fix for

‎server/app/assemble.go‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package app
22

33
import (
44
"context"
5+
"html/template"
56
"log/slog"
67
"net/http"
78
"sync/atomic"
@@ -112,6 +113,21 @@ func assembleMux(d builtDeps, o options, log *slog.Logger) (http.Handler, *atomi
112113
docsHandler := docs.NewHandler(o.docSections, o.docHeader, log)
113114
docsHandler.Register(mux)
114115

116+
// With dashboard auth on, a signed-in visitor gets every doc page (core and
117+
// extension alike, since both flow through docsHandler.Render) inside the
118+
// dashboard chrome instead of the standalone public shell. The render func
119+
// wraps itself in the auth middleware so the verified session lands in the
120+
// request context — the sidebar tenant resolution and ingest-key mask need it.
121+
// The middleware cannot redirect here: this path is only taken after
122+
// Authenticated(r) already reported the same cookie valid.
123+
if authLayer != nil {
124+
docsHandler.EnableInApp(authLayer.Authenticated, func(w http.ResponseWriter, r *http.Request, title string, content template.HTML) {
125+
authLayer.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
126+
webHandler.RenderDocPage(w, r, title, content)
127+
})).ServeHTTP(w, r)
128+
})
129+
}
130+
115131
// Extension routes mount after the core surfaces so their patterns compose by
116132
// ServeMux precedence. The pool is nil in static dev mode (no control plane).
117133
// gate/sessionOrg give a registrar the dashboard auth middleware and the

‎server/docs/docs.go‎

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
// Package docs serves the public, server-rendered product documentation at /doc.
2-
// It owns the doc shell (its own dark chrome, no dashboard auth), a Markdown
3-
// renderer, and the table-of-contents model. Core registers its own product pages
4-
// from embedded Markdown; a composing build adds pages of its own by injecting
5-
// Sections (for the shared left rail) and rendering its Markdown through the
6-
// RenderDoc capability the app hands it — so every doc page looks identical
7-
// whichever module served it.
2+
// It owns the doc shell (its own dark chrome, no dashboard auth required), a
3+
// Markdown renderer, and the table-of-contents model. Core registers its own
4+
// product pages from embedded Markdown; a composing build adds pages of its own
5+
// by injecting Sections (for the shared left rail) and rendering its Markdown
6+
// through the RenderDoc capability the app hands it — so every doc page looks
7+
// identical whichever module served it. When the app wires EnableInApp (dashboard
8+
// auth on), a signed-in visitor gets the same pages inside the dashboard chrome
9+
// instead of the standalone shell; anonymous visitors and auth-less builds keep
10+
// the standalone rendering.
811
package docs
912

1013
import (
@@ -66,6 +69,12 @@ type Handler struct {
6669
nav []Section
6770
header template.HTML
6871
log *slog.Logger
72+
// authed + renderApp are the optional in-app collaborators (see EnableInApp):
73+
// when both are set and authed(r) is true, Render hands a two-column doc
74+
// fragment to renderApp (the dashboard chrome) instead of writing the
75+
// standalone shell. Both nil in the community build (no auth layer).
76+
authed func(*http.Request) bool
77+
renderApp func(http.ResponseWriter, *http.Request, string, template.HTML)
6978
}
7079

7180
// NewHandler builds the doc handler, merging the injected extension sections after
@@ -124,8 +133,15 @@ func (h *Handler) serveFile(w http.ResponseWriter, r *http.Request, slug, active
124133
// Render wraps a rendered body in the doc shell with the rail lit for the current
125134
// request path. It is the capability the app exposes as RouteContext.RenderDoc so
126135
// a composing build renders its own pages in this exact chrome, with the shared
127-
// table of contents, without importing this package's internals.
136+
// table of contents, without importing this package's internals. When EnableInApp
137+
// was wired and the request is authenticated, the page renders inside the
138+
// dashboard chrome instead — core and extension doc pages take the exact same
139+
// branch, so the two never diverge.
128140
func (h *Handler) Render(w http.ResponseWriter, r *http.Request, title string, body template.HTML) {
141+
if h.authed != nil && h.renderApp != nil && h.authed(r) {
142+
h.renderInApp(w, r, title, body)
143+
return
144+
}
129145
w.Header().Set("Content-Type", "text/html; charset=utf-8")
130146
w.Header().Set("Content-Security-Policy", contentSecurityPolicy)
131147
data := shellData{Title: title, Body: body, Groups: h.groups(r.URL.Path), Header: h.header}

‎server/docs/docs_test.go‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package docs
22

33
import (
4+
"html/template"
45
"io"
56
"log/slog"
67
"net/http"
@@ -93,6 +94,44 @@ func TestCommunityBuildFallsBackToHomeBrand(t *testing.T) {
9394
assert.NotContains(t, body, `/pricing`)
9495
}
9596

97+
func TestRenderInAppWhenAuthenticated(t *testing.T) {
98+
h, mux := testHandler(Section{Group: "Enterprise", Title: "Billing", Href: "/doc/billing", Order: 1})
99+
var gotTitle string
100+
var gotContent template.HTML
101+
h.EnableInApp(
102+
func(*http.Request) bool { return true },
103+
func(w http.ResponseWriter, _ *http.Request, title string, content template.HTML) {
104+
gotTitle, gotContent = title, content
105+
w.WriteHeader(http.StatusOK)
106+
},
107+
)
108+
rec := get(t, mux, "/doc/quickstart")
109+
require.Equal(t, http.StatusOK, rec.Code)
110+
require.Equal(t, "Quickstart", gotTitle, "in-app render receives the page title")
111+
frag := string(gotContent)
112+
assert.NotContains(t, frag, "<html", "in-app content is a fragment, not a full page")
113+
assert.NotContains(t, frag, `class="topbar"`, "no standalone chrome in the fragment")
114+
assert.Contains(t, frag, `class="doc-lnk on" href="/doc/quickstart"`, "active TOC link lit")
115+
assert.Contains(t, frag, `href="/doc/architecture"`, "core TOC present in the fragment")
116+
assert.Contains(t, frag, `href="/doc/billing"`, "injected sections share the in-app TOC too")
117+
}
118+
119+
func TestRenderStandaloneWhenAnonymous(t *testing.T) {
120+
h, mux := testHandler()
121+
called := false
122+
h.EnableInApp(
123+
func(*http.Request) bool { return false },
124+
func(http.ResponseWriter, *http.Request, string, template.HTML) { called = true },
125+
)
126+
rec := get(t, mux, "/doc")
127+
require.Equal(t, http.StatusOK, rec.Code)
128+
assert.False(t, called, "anonymous request must not take the in-app path")
129+
body := rec.Body.String()
130+
assert.Contains(t, body, "<html", "standalone shell renders for anonymous visitors")
131+
assert.Contains(t, body, `class="topbar"`)
132+
assert.Equal(t, contentSecurityPolicy, rec.Header().Get("Content-Security-Policy"))
133+
}
134+
96135
func TestMarkdownRendersTablesAndCode(t *testing.T) {
97136
out, err := MarkdownToHTML([]byte("| a | b |\n|---|---|\n| 1 | 2 |\n\n`code`"))
98137
require.NoError(t, err)

‎server/docs/render_app.go‎

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
package docs
2+
3+
// This file holds the in-app rendering path: when the composition root wires
4+
// EnableInApp (dashboard auth on), a signed-in visitor's doc page renders as a
5+
// fragment inside the dashboard chrome instead of the standalone public shell in
6+
// render.go. The fragment keeps the classic doc layout (grouped TOC rail + prose
7+
// column) and carries its own scoped <style> block; it is kept in its own file
8+
// because template literals count toward the 500-line checklen budget.
9+
10+
import (
11+
"bytes"
12+
"html/template"
13+
"log/slog"
14+
"net/http"
15+
)
16+
17+
// EnableInApp wires the two collaborators the in-app rendering path needs:
18+
// authed soft-checks the session cookie (no redirect), and render writes a
19+
// trusted HTML fragment inside the authenticated dashboard chrome. The
20+
// composition root calls this only when an auth layer exists; until then Render
21+
// keeps the standalone shell for every request, so the community build (no
22+
// control plane) is untouched.
23+
func (h *Handler) EnableInApp(authed func(*http.Request) bool, render func(http.ResponseWriter, *http.Request, string, template.HTML)) {
24+
h.authed = authed
25+
h.renderApp = render
26+
}
27+
28+
// appData is the in-app fragment's view model: the rendered prose body and the
29+
// same grouped, active-lit table of contents the standalone shell shows.
30+
type appData struct {
31+
Body template.HTML
32+
Groups []navGroup
33+
}
34+
35+
// renderInApp executes the fragment template and hands the result to the
36+
// injected dashboard renderer, which wraps it in the sidebar + top-bar chrome
37+
// with the Documentation nav item lit. A template failure logs and 500s (nothing
38+
// has been written yet, so the error page is still deliverable).
39+
func (h *Handler) renderInApp(w http.ResponseWriter, r *http.Request, title string, body template.HTML) {
40+
var buf bytes.Buffer
41+
if err := appTmpl.Execute(&buf, appData{Body: body, Groups: h.groups(r.URL.Path)}); err != nil {
42+
h.log.Error("docs: execute in-app fragment", slog.Any("err", err))
43+
http.Error(w, "documentation unavailable", http.StatusInternalServerError)
44+
return
45+
}
46+
h.renderApp(w, r, title, template.HTML(buf.String())) //nolint:gosec // template-produced, autoescaped output
47+
}
48+
49+
// appTmpl is the in-app doc fragment: the two-column layout (grouped TOC rail +
50+
// prose column) dropped into the dashboard's content area. No <html>/<head> and
51+
// no scripts — the dashboard CSP allows inline styles but no inline JS. Every
52+
// class is doc- prefixed so nothing can collide with the dashboard chrome
53+
// classes, and the colours come from the CSS variables the dashboard head
54+
// defines (--panel, --line, --txt*, --accent, --blue, --mono, --ui), so the
55+
// fragment inherits the dashboard theme instead of restating it. The prose rules
56+
// mirror the standalone shell's (render.go) so a page reads identically in both
57+
// chromes.
58+
var appTmpl = template.Must(template.New("docapp").Parse(`<style>
59+
.doc-wrap{display:grid;grid-template-columns:240px minmax(0,1fr);align-items:start;}
60+
.doc-rail{border-right:1px solid var(--line);padding:2px 14px 2px 0;}
61+
.doc-grp{font:700 10px var(--mono);color:var(--txt-3);letter-spacing:1px;text-transform:uppercase;margin:20px 6px 8px;}
62+
.doc-grp:first-child{margin-top:0;}
63+
.doc-lnk{display:block;padding:7px 10px;border-radius:8px;font:600 13px var(--ui);color:var(--txt-2);}
64+
.doc-lnk:hover{background:var(--panel-2);color:var(--txt);}
65+
.doc-lnk.on{background:rgba(180,241,74,.10);color:var(--txt);}
66+
.doc-prose{max-width:760px;padding:0 34px;}
67+
.doc-prose h1{font:800 32px/1.15 var(--ui);letter-spacing:-.5px;margin:0 0 24px;}
68+
.doc-prose h2{font:700 22px/1.2 var(--ui);letter-spacing:-.3px;margin:44px 0 14px;padding-top:14px;border-top:1px solid var(--line-soft);}
69+
.doc-prose h3{font:700 16px var(--ui);margin:28px 0 10px;}
70+
.doc-prose p{font:400 15px/1.7 var(--ui);color:var(--txt);margin:0 0 16px;}
71+
.doc-prose li{font:400 15px/1.7 var(--ui);color:var(--txt);margin:6px 0;}
72+
.doc-prose ul,.doc-prose ol{padding-left:22px;margin:0 0 16px;}
73+
.doc-prose a{color:var(--blue);}
74+
.doc-prose a:hover{text-decoration:underline;}
75+
.doc-prose strong{color:var(--txt);font-weight:700;}
76+
.doc-prose code{font:500 13px var(--mono);background:var(--panel-3);color:#D8E4C0;padding:2px 6px;border-radius:5px;}
77+
.doc-prose pre{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:16px 18px;overflow-x:auto;margin:0 0 18px;}
78+
.doc-prose pre code{background:none;padding:0;color:var(--txt);font-size:13px;line-height:1.6;}
79+
.doc-prose blockquote{margin:0 0 18px;padding:12px 18px;border-left:3px solid var(--accent);background:var(--panel-2);border-radius:0 10px 10px 0;color:var(--txt-2);}
80+
.doc-prose blockquote p{margin:0;font-size:14px;}
81+
.doc-prose table{width:100%;border-collapse:collapse;margin:0 0 20px;font-size:13.5px;}
82+
.doc-prose th{text-align:left;font:600 11px var(--mono);color:var(--txt-3);letter-spacing:.5px;text-transform:uppercase;padding:10px 14px;border-bottom:1px solid var(--line);background:var(--panel-2);}
83+
.doc-prose td{padding:11px 14px;border-bottom:1px solid var(--line-soft);color:var(--txt-2);vertical-align:top;}
84+
.doc-prose td code{font-size:12px;}
85+
.doc-prose hr{border:none;border-top:1px solid var(--line);margin:32px 0;}
86+
.doc-prose h2 a,.doc-prose h3 a{color:inherit;}
87+
@media(max-width:980px){
88+
.doc-wrap{grid-template-columns:1fr;}
89+
.doc-rail{border-right:none;border-bottom:1px solid var(--line);padding:0 0 16px;margin-bottom:20px;}
90+
.doc-prose{padding:0;}
91+
}
92+
</style>
93+
<div class="doc-wrap">
94+
<aside class="doc-rail">
95+
{{range .Groups}}
96+
<div class="doc-grp">{{.Group}}</div>
97+
{{range .Items}}<a class="doc-lnk{{if .Active}} on{{end}}" href="{{.Href}}">{{.Title}}</a>{{end}}
98+
{{end}}
99+
</aside>
100+
<article class="doc-prose">
101+
{{.Body}}
102+
</article>
103+
</div>`))

‎server/internal/web/chrome.go‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -175,13 +175,15 @@ func buildNav(active, winKey string) []navItem {
175175
{Label: "Services", Icon: "▦", Href: withWin("/", winKey), Badge: ""},
176176
{Label: "Performance", Icon: "◈", Href: withWin("/performance", winKey), Badge: ""},
177177
{Label: "Setup", Icon: "✦", Href: withWin("/setup", winKey), Badge: ""},
178-
// Documentation opens the public /doc surface; it renders in its own shell
179-
// (not this dashboard chrome), so it never carries an active state here and the
180-
// window param is not threaded onto it.
178+
// Documentation opens /doc, which renders inside this dashboard chrome for a
179+
// signed-in user (the docs handler routes authenticated requests through
180+
// RenderDocPage, activeNav "docs") and in its own standalone shell otherwise.
181+
// The window param is not threaded onto it: doc pages have no lookback.
181182
{Label: "Documentation", Icon: "❖", Href: "/doc", Badge: ""},
182183
}
183184
navKey := map[string]string{
184185
"Services": "overview", "Performance": "performance", "Setup": "setup",
186+
"Documentation": "docs",
185187
}
186188
for i := range items {
187189
items[i].Active = navKey[items[i].Label] == active

‎server/internal/web/web.go‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,24 @@ func (h *Handler) buildShell(r *http.Request, activeNav string, crumbs []crumb,
180180
// content is trusted HTML the caller has already escaped/produced from a template;
181181
// title sets the top-bar heading and the browser tab.
182182
func (h *Handler) RenderShellPage(w http.ResponseWriter, r *http.Request, title string, content template.HTML) {
183-
shell := h.buildShell(r, "", []crumb{{Label: title}}, title, false, normalizeWindow(r.URL.Query().Get("win")))
183+
h.renderShellContent(w, r, "", []crumb{{Label: title}}, title, content)
184+
}
185+
186+
// RenderDocPage renders a documentation page inside the dashboard chrome: the
187+
// Documentation sidebar item lit and a breadcrumb trail back to /doc. It is the
188+
// in-app counterpart of the standalone public doc shell — the docs handler routes
189+
// a signed-in request here so clicking Documentation never drops the user out of
190+
// the dashboard onto a marketing-looking page.
191+
func (h *Handler) RenderDocPage(w http.ResponseWriter, r *http.Request, title string, content template.HTML) {
192+
h.renderShellContent(w, r, "docs", []crumb{{Label: "docs", Href: "/doc"}, {Label: title}}, title, content)
193+
}
194+
195+
// renderShellContent is the shared "shellpage" executor behind RenderShellPage
196+
// and RenderDocPage: it builds the per-request chrome with the given active nav
197+
// item and breadcrumbs, then renders the trusted content into the dashboard's
198+
// content area.
199+
func (h *Handler) renderShellContent(w http.ResponseWriter, r *http.Request, activeNav string, crumbs []crumb, title string, content template.HTML) {
200+
shell := h.buildShell(r, activeNav, crumbs, title, false, normalizeWindow(r.URL.Query().Get("win")))
184201
data := struct {
185202
Shell Shell
186203
Title string

‎server/internal/web/web_test.go‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,23 @@ func TestRenderShellPage(t *testing.T) {
183183
assert.Contains(t, body, `<a href="/account" class="userbox">`, "user block links to account inside the shell page too")
184184
}
185185

186+
func TestRenderDocPageLightsDocumentationNav(t *testing.T) {
187+
h, err := NewHandler(Config{Querier: fakeQuerier{}, Tenant: constTenant})
188+
require.NoError(t, err)
189+
rec := httptest.NewRecorder()
190+
h.RenderDocPage(rec, httptest.NewRequest(http.MethodGet, "/doc/quickstart", nil),
191+
"Quickstart", template.HTML(`<div class="doc-wrap">DOC-CONTENT</div>`))
192+
193+
assert.Equal(t, http.StatusOK, rec.Code)
194+
body := rec.Body.String()
195+
assert.Contains(t, body, "DOC-CONTENT", "doc fragment renders inside the shell")
196+
assert.Contains(t, body, `class="aside"`, "sidebar chrome renders")
197+
assert.Contains(t, body, `<a href="/doc" class="nav-item on">`, "Documentation nav item is lit")
198+
assert.NotContains(t, body, `<a href="/?win=1h" class="nav-item on">`, "Services stays unlit")
199+
assert.Contains(t, body, `<a href="/doc" class="c-accent">docs</a>`, "breadcrumb links back to /doc")
200+
assert.Contains(t, body, "mAPI-ng — Quickstart", "browser title set from page title")
201+
}
202+
186203
func TestCopyJSAssetServed(t *testing.T) {
187204
srv := newServer(t, Config{Querier: fakeQuerier{}, Tenant: constTenant})
188205

0 commit comments

Comments
 (0)