Skip to content

Commit 06e591e

Browse files
authored
feat: shared content renderer with single CSS build (#226)
* feat: shared content renderer with single CSS build for public viewer Eliminate the second Tailwind CSS build for the content viewer. Both the internal portal and public viewer now share the same SPA-built CSS, fixing prose rendering issues caused by divergent Tailwind builds. Key changes: - Delete content-viewer.css (second Tailwind entry point) - Build SPA first, then copy its CSS as content-viewer.css - IIFE build (vite.content-viewer.config.ts) produces JS only - Add @custom-variant dark so dark: variants use .dark class, not @media(prefers-color-scheme:dark) — fixes white-on-white prose when OS is dark but page is toggled to light - Add shadcn HSL variables to public viewer template so SPA CSS resolves colors correctly - Rename page shell --border to --page-border to avoid collision with shadcn HSL --border triplet - Add content viewer embed package, preview server, and Makefile targets * fix: patch coverage and review findings for shared content renderer - Extract loadBundles() from init() in contentviewer/embed.go so file-reading logic is testable via fstest.MapFS (100% coverage) - Extract newHandler() from main() in preview server so HTTP handler is testable; add table-driven tests for all content types (82.1%) - Fix doc comment: go run /tmp/preview-content-viewer.go -> ./cmd/... - Pin SeaweedFS image to 3.88@sha256:98e034... (was :latest) - Document intentional HSL variable divergence in public viewer template (neutral palette vs SPA's blue-tinted grays) - Use find instead of fragile ls index-*.css glob in Makefile - Add defensive-fallback comment on MutationObserver in entry.tsx - Fix noctx lint: use NewRequestWithContext in test
1 parent 6ce571d commit 06e591e

File tree

18 files changed

+682
-476
lines changed

18 files changed

+682
-476
lines changed

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ site/
4343
/dist/
4444
build/
4545
/mcp-data-platform
46+
/preview-content-viewer
4647
NOTES_*
4748

4849
# E2E test artifacts
@@ -61,10 +62,13 @@ apps/preview-data.json
6162
# Portal UI
6263
ui/node_modules/
6364
ui/dist/
65+
ui/dist-content-viewer/
6466
ui/.vite/
6567
ui/test-results/
6668

6769
# Root node_modules (if any)
6870
node_modules/
6971
internal/ui/dist/*
7072
!internal/ui/dist/.gitkeep
73+
internal/contentviewer/dist/*
74+
!internal/contentviewer/dist/.gitkeep

Makefile

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ BUILD_DIR := ./build
1313
DIST_DIR := ./dist
1414
UI_DIR := ./ui
1515
UI_EMBED_DIR := ./internal/ui/dist
16+
CV_EMBED_DIR := ./internal/contentviewer/dist
1617

1718
# Tool versions — keep in sync with .github/workflows/ci.yml
1819
GOLANGCI_LINT_VERSION := v2.8.0
@@ -29,7 +30,8 @@ GOLINT := golangci-lint
2930
.PHONY: all build test lint fmt clean install help docs-serve docs-build verify \
3031
tools-check dead-code mutate patch-coverage doc-check swagger swagger-check \
3132
semgrep codeql sast embed-clean \
32-
frontend-install frontend-build frontend-dev frontend-mock frontend-test \
33+
frontend-install frontend-build frontend-build-content-viewer \
34+
frontend-dev frontend-mock frontend-test \
3335
e2e-up e2e-down e2e-seed e2e-test e2e e2e-logs e2e-clean \
3436
dev-up dev-down preview-apps preview-platform-info
3537

@@ -90,9 +92,10 @@ clean:
9092
@echo "Cleaning..."
9193
@rm -rf $(BUILD_DIR) $(DIST_DIR)
9294
@rm -f coverage.out coverage.html
93-
@rm -rf $(UI_DIR)/dist $(UI_DIR)/node_modules
94-
@# Reset embed dir but keep .gitkeep
95+
@rm -rf $(UI_DIR)/dist $(UI_DIR)/dist-content-viewer $(UI_DIR)/node_modules
96+
@# Reset embed dirs but keep .gitkeep
9597
@find $(UI_EMBED_DIR) -not -name '.gitkeep' -not -path $(UI_EMBED_DIR) -delete 2>/dev/null || true
98+
@find $(CV_EMBED_DIR) -not -name '.gitkeep' -not -path $(CV_EMBED_DIR) -delete 2>/dev/null || true
9699
@echo "Clean complete."
97100

98101
## install: Install the binary
@@ -252,10 +255,11 @@ tools-check:
252255
fi
253256
@echo "All required tools found."
254257

255-
## embed-clean: Reset UI embed dir to .gitkeep only (matches CI clean checkout)
258+
## embed-clean: Reset UI embed dirs to .gitkeep only (matches CI clean checkout)
256259
embed-clean:
257-
@echo "Cleaning UI embed directory..."
260+
@echo "Cleaning UI embed directories..."
258261
@find $(UI_EMBED_DIR) -not -name '.gitkeep' -not -path $(UI_EMBED_DIR) -delete 2>/dev/null || true
262+
@find $(CV_EMBED_DIR) -not -name '.gitkeep' -not -path $(CV_EMBED_DIR) -delete 2>/dev/null || true
259263

260264
## verify: Run the full CI-equivalent check suite (test, lint, security, SAST, coverage, mutation, release)
261265
verify: tools-check fmt swagger-check embed-clean test lint security semgrep codeql coverage-report patch-coverage doc-check dead-code mutate release-check
@@ -291,15 +295,31 @@ frontend-install:
291295
cd $(UI_DIR) && npm ci
292296
@echo "UI dependencies installed."
293297

294-
## frontend-build: Build UI and copy to embed directory
298+
## frontend-build-content-viewer: Build standalone content viewer JS bundle (CSS comes from SPA build)
299+
frontend-build-content-viewer: frontend-install
300+
@echo "Building content viewer (JS only)..."
301+
cd $(UI_DIR) && npx vite build --config vite.content-viewer.config.ts
302+
@mkdir -p $(CV_EMBED_DIR)
303+
@cp $(UI_DIR)/dist-content-viewer/content-viewer.js $(CV_EMBED_DIR)/
304+
@echo "Content viewer JS built and embedded."
305+
306+
## frontend-build: Build SPA first (produces CSS), then content viewer (JS only), copy SPA CSS as content-viewer CSS
295307
frontend-build: frontend-install
296-
@echo "Building UI..."
308+
@echo "Building SPA..."
297309
cd $(UI_DIR) && npm run build
298-
@echo "Copying dist to embed directory..."
310+
@echo "Copying SPA dist to embed directory..."
299311
@rm -rf $(UI_EMBED_DIR)/*
300312
@cp -r $(UI_DIR)/dist/* $(UI_EMBED_DIR)/
301313
@rm -f $(UI_EMBED_DIR)/mockServiceWorker.js
302-
@echo "UI built and embedded."
314+
@echo "SPA built and embedded."
315+
cd $(UI_DIR) && npx vite build --config vite.content-viewer.config.ts
316+
@mkdir -p $(CV_EMBED_DIR)
317+
@cp $(UI_DIR)/dist-content-viewer/content-viewer.js $(CV_EMBED_DIR)/
318+
@echo "Copying SPA CSS as content-viewer CSS..."
319+
@SPA_CSS=$$(find $(UI_DIR)/dist/assets -maxdepth 1 -name '*.css' -print -quit 2>/dev/null); \
320+
if [ -z "$$SPA_CSS" ]; then echo "ERROR: SPA CSS not found in $(UI_DIR)/dist/assets/"; exit 1; fi; \
321+
cp "$$SPA_CSS" $(CV_EMBED_DIR)/content-viewer.css
322+
@echo "Frontend build complete."
303323

304324
## frontend-dev: Run UI dev server (hot reload)
305325
frontend-dev:

cmd/preview-content-viewer/main.go

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
// Quick preview server for the content viewer. Run:
2+
//
3+
// go run ./cmd/preview-content-viewer
4+
//
5+
// Then open http://localhost:9090
6+
package main
7+
8+
import (
9+
"encoding/json"
10+
"fmt"
11+
"html/template"
12+
"log"
13+
"net/http"
14+
15+
"github.com/txn2/mcp-data-platform/internal/contentviewer"
16+
)
17+
18+
const viewerHTML = `<!DOCTYPE html>
19+
<html lang="en" data-theme="light">
20+
<head>
21+
<meta charset="UTF-8">
22+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
23+
<title>{{.Name}}</title>
24+
<style>
25+
:root,[data-theme="light"]{--bg:#f5f5f5;--bg-surface:#fff;--text:#1a1a1a;--text-muted:#6b7280;--page-border:#e5e5e5;--badge-bg:#e5e7eb;--badge-text:#374151;--toggle-hover:#f3f4f6;--background:0 0% 96%;--foreground:0 0% 10%;--card:0 0% 100%;--card-foreground:0 0% 10%;--muted:0 0% 96%;--muted-foreground:220 9% 46%;--border:0 0% 90%;--primary:221.2 83.2% 53.3%;--primary-foreground:210 40% 98%}
26+
[data-theme="dark"]{--bg:#111113;--bg-surface:#1a1a1e;--text:#e4e4e7;--text-muted:#71717a;--page-border:#27272a;--badge-bg:#27272a;--badge-text:#a1a1aa;--toggle-hover:#27272a;--background:240 6% 6%;--foreground:240 5% 90%;--card:240 5% 11%;--card-foreground:240 5% 90%;--muted:240 4% 16%;--muted-foreground:240 5% 48%;--border:240 4% 16%;--primary:217.2 91.2% 59.8%;--primary-foreground:222.2 47.4% 11.2%}
27+
*{margin:0;padding:0;box-sizing:border-box}html,body{height:100%}
28+
body{font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);display:flex;flex-direction:column;min-height:100vh}
29+
.header{background:var(--bg-surface);border-bottom:1px solid var(--page-border);padding:12px 24px;display:flex;align-items:center;gap:12px;flex-shrink:0}
30+
.header h1{font-size:16px;font-weight:600;flex:1}
31+
.badge{font-size:11px;background:var(--badge-bg);color:var(--badge-text);padding:2px 8px;border-radius:10px}
32+
.theme-toggle{background:none;border:1px solid var(--page-border);border-radius:6px;padding:4px 8px;cursor:pointer;color:var(--text-muted)}
33+
.content{width:100%;padding:16px;flex:1;display:flex;flex-direction:column}
34+
.content iframe{flex:1;min-height:60vh}
35+
nav{display:flex;gap:8px;padding:12px 24px;background:var(--bg-surface);border-bottom:1px solid var(--page-border)}
36+
nav a{color:var(--text-muted);text-decoration:none;padding:4px 12px;border-radius:6px;font-size:13px}
37+
nav a:hover{background:var(--toggle-hover)}
38+
nav a.active{background:var(--badge-bg);color:var(--badge-text)}
39+
</style>
40+
<script>
41+
(function(){
42+
var params=new URLSearchParams(location.search);
43+
var forced=params.get("theme");
44+
if(forced==="light"||forced==="dark"){var theme=forced}
45+
else{var saved=null;try{saved=localStorage.getItem("mdp-theme")}catch(e){}
46+
var theme=saved||(window.matchMedia("(prefers-color-scheme:dark)").matches?"dark":"light");}
47+
document.documentElement.setAttribute("data-theme",theme);
48+
document.documentElement.classList.toggle("dark",theme==="dark");
49+
})();
50+
</script>
51+
</head>
52+
<body>
53+
<div class="header">
54+
<h1>{{.Name}}</h1>
55+
<span class="badge">{{.ContentType}}</span>
56+
<button class="theme-toggle" id="tt" onclick="var h=document.documentElement,t=h.getAttribute('data-theme')==='dark'?'light':'dark';h.setAttribute('data-theme',t);h.classList.toggle('dark',t==='dark');try{localStorage.setItem('mdp-theme',t)}catch(e){}">Toggle Theme</button>
57+
</div>
58+
<nav>
59+
<a href="/?type=markdown">Markdown</a>
60+
<a href="/?type=svg">SVG</a>
61+
<a href="/?type=jsx">JSX</a>
62+
<a href="/?type=html">HTML</a>
63+
<a href="/?type=plain">Plain Text</a>
64+
</nav>
65+
<div class="content">
66+
<style>{{.ContentViewerCSS}}</style>
67+
<div id="content-root"><p style="color:var(--text-muted);padding:16px">Loading...</p></div>
68+
<script type="application/json" id="content-data">{{.ContentJSON}}</script>
69+
<script>{{.ContentViewerJS}}</script>
70+
</div>
71+
</body>
72+
</html>`
73+
74+
var samples = map[string][2]string{
75+
"markdown": {"text/markdown", "# Hello World\n\nThis is a **bold** test with GFM:\n\n| Feature | Status |\n|---------|--------|\n| Tables | Working |\n| Strikethrough | ~~yes~~ |\n\n- [x] Task list\n- [ ] Another task\n\n```go\nfunc main() {\n fmt.Println(\"Hello\")\n}\n```\n"},
76+
"svg": {"image/svg+xml", `<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg"><circle cx="100" cy="100" r="80" fill="#3b82f6" opacity="0.8"/><text x="100" y="108" text-anchor="middle" fill="white" font-size="24" font-family="system-ui">SVG</text></svg>`},
77+
"jsx": {"text/jsx", "import { useState } from 'react';\n\nexport default function Counter() {\n const [count, setCount] = useState(0);\n return (\n <div style={{padding: '2rem', fontFamily: 'system-ui'}}>\n <h1>Counter: {count}</h1>\n <button onClick={() => setCount(c => c + 1)}\n style={{padding: '8px 16px', fontSize: '16px', cursor: 'pointer'}}>\n Increment\n </button>\n </div>\n );\n}\n"},
78+
"html": {"text/html", "<!DOCTYPE html>\n<html>\n<head><style>body{font-family:system-ui;padding:2rem}h1{color:#3b82f6}</style></head>\n<body><h1>Hello from HTML</h1><p>This is rendered in a sandboxed iframe.</p></body>\n</html>"},
79+
"plain": {"text/plain", "This is plain text content.\nIt should be displayed in a <pre> block.\n\nSpecial chars: <script>alert('xss')</script> & \"quotes\""},
80+
}
81+
82+
var viewerTpl = template.Must(template.New("viewer").Parse(viewerHTML))
83+
84+
// newHandler returns the HTTP handler that renders the preview page.
85+
// Extracted from main() so it can be tested.
86+
func newHandler() http.HandlerFunc {
87+
return func(w http.ResponseWriter, r *http.Request) {
88+
typ := r.URL.Query().Get("type")
89+
if typ == "" {
90+
typ = "markdown"
91+
}
92+
sample, ok := samples[typ]
93+
if !ok {
94+
sample = samples["markdown"]
95+
}
96+
97+
contentJSON, _ := json.Marshal(map[string]string{ // #nosec G104 -- string map marshaling cannot fail
98+
"contentType": sample[0],
99+
"content": sample[1],
100+
})
101+
102+
w.Header().Set("Content-Type", "text/html; charset=utf-8")
103+
_ = viewerTpl.Execute(w, map[string]any{ // #nosec G104 -- template execution on ResponseWriter; error is logged by http.Server
104+
"Name": fmt.Sprintf("Preview: %s", typ),
105+
"ContentType": sample[0],
106+
"ContentJSON": template.JS(contentJSON), // #nosec G203 -- dev-only preview with static samples
107+
"ContentViewerJS": template.JS(contentviewer.JS), // #nosec G203 -- embedded bundle, not user input
108+
"ContentViewerCSS": template.CSS(contentviewer.CSS), // #nosec G203 -- embedded bundle, not user input
109+
})
110+
}
111+
}
112+
113+
func main() {
114+
http.Handle("/", newHandler())
115+
116+
fmt.Println("Preview server at http://localhost:9090")
117+
fmt.Println(" http://localhost:9090/?type=markdown")
118+
fmt.Println(" http://localhost:9090/?type=svg")
119+
fmt.Println(" http://localhost:9090/?type=jsx")
120+
fmt.Println(" http://localhost:9090/?type=html")
121+
fmt.Println(" http://localhost:9090/?type=plain")
122+
log.Fatal(http.ListenAndServe(":9090", nil)) // #nosec G114 -- dev-only local preview server // nosemgrep: go.lang.security.audit.net.use-tls.use-tls
123+
}
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"net/http"
6+
"net/http/httptest"
7+
"testing"
8+
)
9+
10+
func TestNewHandler(t *testing.T) {
11+
handler := newHandler()
12+
13+
tests := []struct {
14+
name string
15+
query string
16+
wantStatus int
17+
wantStrings []string
18+
}{
19+
{
20+
name: "default type is markdown",
21+
query: "",
22+
wantStatus: http.StatusOK,
23+
wantStrings: []string{
24+
"Preview: markdown",
25+
"text/markdown",
26+
`id="content-data"`,
27+
`id="content-root"`,
28+
},
29+
},
30+
{
31+
name: "explicit markdown",
32+
query: "?type=markdown",
33+
wantStatus: http.StatusOK,
34+
wantStrings: []string{
35+
"Preview: markdown",
36+
"text/markdown",
37+
},
38+
},
39+
{
40+
name: "svg type",
41+
query: "?type=svg",
42+
wantStatus: http.StatusOK,
43+
wantStrings: []string{
44+
"Preview: svg",
45+
"image/svg+xml",
46+
},
47+
},
48+
{
49+
name: "jsx type",
50+
query: "?type=jsx",
51+
wantStatus: http.StatusOK,
52+
wantStrings: []string{
53+
"Preview: jsx",
54+
"text/jsx",
55+
},
56+
},
57+
{
58+
name: "html type",
59+
query: "?type=html",
60+
wantStatus: http.StatusOK,
61+
wantStrings: []string{
62+
"Preview: html",
63+
"text/html",
64+
},
65+
},
66+
{
67+
name: "plain type",
68+
query: "?type=plain",
69+
wantStatus: http.StatusOK,
70+
wantStrings: []string{
71+
"Preview: plain",
72+
"text/plain",
73+
},
74+
},
75+
{
76+
name: "unknown type falls back to markdown",
77+
query: "?type=unknown",
78+
wantStatus: http.StatusOK,
79+
wantStrings: []string{
80+
"Preview: unknown",
81+
"text/markdown",
82+
},
83+
},
84+
}
85+
86+
for _, tt := range tests {
87+
t.Run(tt.name, func(t *testing.T) {
88+
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/"+tt.query, http.NoBody)
89+
w := httptest.NewRecorder()
90+
91+
handler.ServeHTTP(w, req)
92+
93+
if w.Code != tt.wantStatus {
94+
t.Errorf("status = %d, want %d", w.Code, tt.wantStatus)
95+
}
96+
97+
ct := w.Header().Get("Content-Type")
98+
if ct != "text/html; charset=utf-8" {
99+
t.Errorf("Content-Type = %q, want text/html; charset=utf-8", ct)
100+
}
101+
102+
body := w.Body.String()
103+
for _, s := range tt.wantStrings {
104+
if !contains(body, s) {
105+
t.Errorf("body missing %q", s)
106+
}
107+
}
108+
})
109+
}
110+
}
111+
112+
func TestSamplesComplete(t *testing.T) {
113+
expected := []string{"markdown", "svg", "jsx", "html", "plain"}
114+
for _, key := range expected {
115+
sample, ok := samples[key]
116+
if !ok {
117+
t.Errorf("samples missing key %q", key)
118+
continue
119+
}
120+
if sample[0] == "" {
121+
t.Errorf("samples[%q] content type is empty", key)
122+
}
123+
if sample[1] == "" {
124+
t.Errorf("samples[%q] content is empty", key)
125+
}
126+
}
127+
}
128+
129+
func TestViewerTemplateValid(t *testing.T) {
130+
// viewerTpl is parsed at init time; this test verifies it didn't panic.
131+
if viewerTpl == nil {
132+
t.Fatal("viewerTpl is nil — template parsing failed")
133+
}
134+
}
135+
136+
// contains is a simple helper to avoid importing strings in test.
137+
func contains(s, substr string) bool {
138+
return len(s) >= len(substr) && searchString(s, substr)
139+
}
140+
141+
func searchString(s, substr string) bool {
142+
for i := 0; i <= len(s)-len(substr); i++ {
143+
if s[i:i+len(substr)] == substr {
144+
return true
145+
}
146+
}
147+
return false
148+
}

0 commit comments

Comments
 (0)