render.go
152 lines1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
package frontend
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"html"
"html/template"
"net/http"
"os"
"path/filepath"
)
// Script returns the runtime script tags for templates.
// This includes mount orchestration and HMR client in development.
// The nonce parameter is injected into all generated <script> tags for CSP compliance.
func (f *Frontend) Script(nonce string) template.HTML {
nonceAttr := ""
if nonce != "" {
nonceAttr = fmt.Sprintf(` nonce="%s"`, nonce)
}
hmr := ""
if f.DevMode {
hmr = `
// HMR in development
if (location.hostname === 'localhost' || location.hostname === '127.0.0.1') {
const evtSource = new EventSource('/_frontend/hmr');
evtSource.onmessage = () => location.reload();
evtSource.onerror = () => setTimeout(() => location.reload(), 1000);
}
`
}
// Mount orchestration (framework-agnostic)
// Components bundle must provide: __render(el, Component, props), __unmount(el), window.ComponentName
orchestration := fmt.Sprintf(`
<script%s>
window.__renderAll = function() {
document.querySelectorAll('[data-component]').forEach(el => {
const name = el.dataset.component;
const Component = window[name];
if (!Component) {
console.warn('[frontend] Component not found:', name);
return;
}
el.innerHTML = '';
__render(el, Component, JSON.parse(el.dataset.props || '{}'));
});
};
// HTMX integration - use document instead of document.body to avoid null reference
// when script runs in <head> before body exists
document.addEventListener('htmx:beforeSwap', (e) => {
e.detail.target.querySelectorAll('[data-component]').forEach(__unmount);
});
document.addEventListener('htmx:afterSwap', (e) => {
e.detail.target.querySelectorAll('[data-component]').forEach(el => {
const Component = window[el.dataset.component];
if (Component) {
el.innerHTML = '';
__render(el, Component, JSON.parse(el.dataset.props || '{}'));
}
});
});
// Handle full page swaps (hx-boost)
document.addEventListener('htmx:afterSettle', (e) => {
if (e.detail.requestConfig?.boosted) {
__renderAll();
}
});
</script>
`, nonceAttr)
// Cache-busting hash from bundle content
cacheBust := ""
if data, err := os.ReadFile(filepath.Join(f.OutputDir, "components.js")); err == nil {
h := sha256.Sum256(data)
cacheBust = "?v=" + hex.EncodeToString(h[:4])
}
// Components bundle + trigger
bundle := fmt.Sprintf(`<script%s type="module" src="/_frontend/components.js%s"></script>
<script%s type="module">
__renderAll();
%s
</script>`, nonceAttr, cacheBust, nonceAttr, hmr)
return template.HTML(orchestration + bundle)
}
// Render returns an island container for the named component.
// Props are JSON-serialized into data-props attribute.
func (f *Frontend) Render(name string, props any) template.HTML {
propsJSON := "{}"
if props != nil {
data, err := json.Marshal(props)
if err == nil {
propsJSON = string(data)
}
}
// Use a skeleton placeholder while the component loads
htmlOut := fmt.Sprintf(`<div data-component="%s" data-props="%s">
<div class="skeleton h-32 w-full"></div>
</div>`, template.HTMLEscapeString(name), html.EscapeString(propsJSON))
return template.HTML(htmlOut)
}
// handleComponents serves the compiled components bundle.
func (f *Frontend) handleComponents(w http.ResponseWriter, r *http.Request) {
bundlePath := filepath.Join(f.OutputDir, "components.js")
// Check if bundle exists
if _, err := os.Stat(bundlePath); os.IsNotExist(err) {
// Try to build if source exists
if _, srcErr := os.Stat(f.SourceDir); srcErr == nil {
if buildErr := f.Build(); buildErr != nil {
http.Error(w, "Build failed: "+buildErr.Error(), http.StatusInternalServerError)
return
}
} else {
// No source, serve empty module
w.Header().Set("Content-Type", "application/javascript")
w.Write([]byte("// No components found\nwindow.__render = () => {};\nwindow.__unmount = () => {};\n"))
return
}
}
w.Header().Set("Content-Type", "application/javascript")
if f.DevMode {
w.Header().Set("Cache-Control", "no-cache")
} else {
w.Header().Set("Cache-Control", "max-age=31536000")
}
http.ServeFile(w, r, bundlePath)
}
// handleSourceMap serves the source map for debugging.
func (f *Frontend) handleSourceMap(w http.ResponseWriter, r *http.Request) {
mapPath := filepath.Join(f.OutputDir, "components.js.map")
if _, err := os.Stat(mapPath); os.IsNotExist(err) {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
http.ServeFile(w, r, mapPath)
}