render.go

152 lines
1 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)
}