restructure project, add claudemd
This commit is contained in:
147
go/bundler/build.go
Normal file
147
go/bundler/build.go
Normal file
@@ -0,0 +1,147 @@
|
||||
// Package bundler is the frontend build system: it drives esbuild's Go API for
|
||||
// JS bundling, compiles Solid JSX/TSX and Tailwind v4 CSS with Go-native
|
||||
// compilers (no Node, no Babel, no goja on the build path), and bakes the
|
||||
// public-page SSR (which does still use goja to execute components). The
|
||||
// `cmd/bundle` command is a thin CLI wrapper over this package.
|
||||
//
|
||||
// The package also carries the runtime SSR entry (RenderBundleWithData, in
|
||||
// ssr.go/renderer.go) that the server imports for ISR.
|
||||
//
|
||||
// Pipeline stages live in sibling files:
|
||||
//
|
||||
// js.go esbuild-driven JS bundling + sourcemap fixup
|
||||
// export_shim.go default/named export shim plugin
|
||||
// import_check.go .js-extension import validation
|
||||
// css.go Tailwind compile driver + minify
|
||||
// tailwind.go official Tailwind v4 compiler run in goja
|
||||
// candidates.go utility-class candidate scanner (feeds Tailwind's build())
|
||||
// genssr.go public-page SSR bake + Go registry generation
|
||||
// ssr.go/renderer.go the goja SSR engine (also used by the server at runtime)
|
||||
// watch.go poll-and-rebuild watch loop
|
||||
package bundler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const frontendDir = "frontend"
|
||||
const outputDir = "wwwroot"
|
||||
|
||||
type bundleStats struct {
|
||||
files int
|
||||
bytes int
|
||||
}
|
||||
|
||||
// Build runs the full one-shot build: FA icon subset, public-route generation,
|
||||
// the JS/CSS bundles, and the SSR bake, printing a stats summary.
|
||||
func Build() error {
|
||||
// esbuild silently resolves a `.js` import to a sibling `.ts`/`.tsx`/`.jsx`,
|
||||
// which lets misnamed specifiers slip through. Catch them up front so the
|
||||
// import path always names the file that actually exists on disk.
|
||||
if violations := checkImportExtensions(); len(violations) > 0 {
|
||||
reportImportViolations(violations)
|
||||
return fmt.Errorf("%d import-extension violation(s)", len(violations))
|
||||
}
|
||||
|
||||
buildStart := time.Now()
|
||||
|
||||
fmt.Println("Generating FA icon subset...")
|
||||
if err := generateFAIcons(); err != nil {
|
||||
return fmt.Errorf("FA icon generation failed: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Generating public routes...")
|
||||
routesStart := time.Now()
|
||||
if err := generatePublicRoutes(); err != nil {
|
||||
return fmt.Errorf("public route generation failed: %w", err)
|
||||
}
|
||||
routesDur := time.Since(routesStart)
|
||||
|
||||
// The four bundles are independent — distinct output files, shared read-only
|
||||
// inputs plus the generated files above — so run them in parallel. The Go
|
||||
// Solid compiler and Go Tailwind engine are both stateless per call.
|
||||
fmt.Println("Bundling JS + CSS...")
|
||||
results := runBundlesParallel()
|
||||
|
||||
for _, r := range results {
|
||||
if r.err != nil {
|
||||
return fmt.Errorf("%s failed: %w", r.name, r.err)
|
||||
}
|
||||
}
|
||||
|
||||
// One column layout drives header, rows, and footer so every field lines up.
|
||||
// Files is %6d because candidate counts run into the thousands (a %3d would
|
||||
// overflow and shift Size/Time right on the CSS rows). Total width = 55.
|
||||
sep := strings.Repeat("-", 55)
|
||||
fmt.Println()
|
||||
fmt.Printf("%-27s %6s %8s %9s\n", "Bundle", "Files", "Size", "Time")
|
||||
fmt.Println(sep)
|
||||
for _, r := range results {
|
||||
printStats(r.name, r.stats, r.dur)
|
||||
}
|
||||
fmt.Println(sep)
|
||||
// %-46s%9s places the time in the same field (cols 47-55) as the bundle rows.
|
||||
fmt.Printf("%-46s%9s\n", "Public routes (SSR)", formatDuration(routesDur))
|
||||
fmt.Printf("%-46s%9s\n", "Total build time", formatDuration(time.Since(buildStart)))
|
||||
fmt.Println()
|
||||
fmt.Println("Done!")
|
||||
return nil
|
||||
}
|
||||
|
||||
type bundleResult struct {
|
||||
name string
|
||||
stats bundleStats
|
||||
dur time.Duration
|
||||
err error
|
||||
}
|
||||
|
||||
// runBundlesParallel runs the four output bundles concurrently and returns their
|
||||
// results in a stable order. Per-bundle durations overlap, so they won't sum to
|
||||
// wall-clock — that's the point.
|
||||
func runBundlesParallel() []bundleResult {
|
||||
jobs := []struct {
|
||||
name string
|
||||
fn func() (bundleStats, error)
|
||||
}{
|
||||
{"bundle.min.js", bundleJS},
|
||||
{"public.bundle.min.js", bundlePublicJS},
|
||||
{"bundle.min.css", bundleSPACSS},
|
||||
{"public.bundle.min.css", bundlePublicCSS},
|
||||
}
|
||||
results := make([]bundleResult, len(jobs))
|
||||
var wg sync.WaitGroup
|
||||
for i, j := range jobs {
|
||||
wg.Add(1)
|
||||
go func(i int, name string, fn func() (bundleStats, error)) {
|
||||
defer wg.Done()
|
||||
t := time.Now()
|
||||
s, err := fn()
|
||||
results[i] = bundleResult{name: name, stats: s, dur: time.Since(t), err: err}
|
||||
}(i, j.name, j.fn)
|
||||
}
|
||||
wg.Wait()
|
||||
return results
|
||||
}
|
||||
|
||||
func printStats(name string, s bundleStats, d time.Duration) {
|
||||
fmt.Printf("%-27s %6d %8s %9s\n", name, s.files, formatSize(s.bytes), formatDuration(d))
|
||||
}
|
||||
|
||||
// formatDuration renders a build-phase duration compactly: sub-second in ms,
|
||||
// otherwise seconds with two decimals.
|
||||
func formatDuration(d time.Duration) string {
|
||||
if d < time.Second {
|
||||
return fmt.Sprintf("%dms", d.Milliseconds())
|
||||
}
|
||||
return fmt.Sprintf("%.2fs", d.Seconds())
|
||||
}
|
||||
|
||||
func formatSize(bytes int) string {
|
||||
if bytes >= 1024*1024 {
|
||||
return fmt.Sprintf("%.1f MB", float64(bytes)/(1024*1024))
|
||||
}
|
||||
return fmt.Sprintf("%.1f KB", float64(bytes)/1024)
|
||||
}
|
||||
399
go/bundler/compile_solid.go
Normal file
399
go/bundler/compile_solid.go
Normal file
@@ -0,0 +1,399 @@
|
||||
package bundler
|
||||
|
||||
// Go-native Solid JSX compiler (replaces babel-preset-solid running in goja).
|
||||
//
|
||||
// Pipeline: esbuild strips TS types (JSX preserved), then this package parses the
|
||||
// JSX trees out of the JS and rewrites each into Solid's dom-expressions runtime
|
||||
// output (template cloning + fine-grained _$insert/_$effect/_$createComponent).
|
||||
// JS expressions inside `{...}` are captured as opaque strings and, where they
|
||||
// may contain nested JSX, recompiled recursively — so we never need a full JS
|
||||
// parser, only a JSX-aware scanner.
|
||||
//
|
||||
// This file is the PARSER (JSX text -> tree). Codegen lives in compile_solid_gen.go.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type jsxKind int
|
||||
|
||||
const (
|
||||
jsxElement jsxKind = iota // lowercase tag -> real DOM element (templated)
|
||||
jsxComponent // Capitalized/dotted tag -> _$createComponent
|
||||
jsxFragment // <>...</>
|
||||
jsxText // literal character data between tags
|
||||
jsxExpr // {expr} — raw JS, may itself contain JSX
|
||||
)
|
||||
|
||||
type attrKind int
|
||||
|
||||
const (
|
||||
attrStatic attrKind = iota // name="literal" or bare boolean
|
||||
attrExpr // name={expr}
|
||||
attrSpread // {...expr}
|
||||
)
|
||||
|
||||
type jsxAttr struct {
|
||||
kind attrKind
|
||||
name string
|
||||
value string // literal string value (attrStatic with a value)
|
||||
expr string // JS expression (attrExpr) or spread source (attrSpread)
|
||||
boolt bool // bare boolean attribute (attrStatic, no `=`)
|
||||
}
|
||||
|
||||
type jsxNode struct {
|
||||
kind jsxKind
|
||||
tag string
|
||||
attrs []jsxAttr
|
||||
children []jsxNode
|
||||
text string // jsxText
|
||||
expr string // jsxExpr (raw, may contain nested JSX)
|
||||
|
||||
marker bool // codegen: this dynamic child needs a `<!>` insert anchor
|
||||
}
|
||||
|
||||
// parseJSX parses a JSX element/fragment beginning at src[i] == '<'. It returns
|
||||
// the node and the index just past the element's closing `>`.
|
||||
func parseJSX(src string, i int) (jsxNode, int, error) {
|
||||
n := len(src)
|
||||
if i >= n || src[i] != '<' {
|
||||
return jsxNode{}, 0, fmt.Errorf("parseJSX: expected '<' at %d", i)
|
||||
}
|
||||
i++ // consume '<'
|
||||
|
||||
// Fragment: <> ... </>
|
||||
if i < n && src[i] == '>' {
|
||||
i++
|
||||
children, ci, err := parseChildren(src, i)
|
||||
if err != nil {
|
||||
return jsxNode{}, 0, err
|
||||
}
|
||||
i, err = consumeCloseTag(src, ci)
|
||||
if err != nil {
|
||||
return jsxNode{}, 0, err
|
||||
}
|
||||
return jsxNode{kind: jsxFragment, children: children}, i, nil
|
||||
}
|
||||
|
||||
// Tag name.
|
||||
start := i
|
||||
for i < n && isTagChar(src[i]) {
|
||||
i++
|
||||
}
|
||||
if i == start {
|
||||
return jsxNode{}, 0, fmt.Errorf("parseJSX: empty tag name at %d", start)
|
||||
}
|
||||
node := jsxNode{tag: src[start:i]}
|
||||
if isComponentTag(node.tag) {
|
||||
node.kind = jsxComponent
|
||||
} else {
|
||||
node.kind = jsxElement
|
||||
}
|
||||
|
||||
attrs, ai, selfClose, err := parseAttrs(src, i)
|
||||
if err != nil {
|
||||
return jsxNode{}, 0, err
|
||||
}
|
||||
node.attrs = attrs
|
||||
i = ai
|
||||
if selfClose {
|
||||
return node, i, nil
|
||||
}
|
||||
|
||||
children, ci, err := parseChildren(src, i)
|
||||
if err != nil {
|
||||
return jsxNode{}, 0, err
|
||||
}
|
||||
node.children = children
|
||||
i, err = consumeCloseTag(src, ci)
|
||||
if err != nil {
|
||||
return jsxNode{}, 0, err
|
||||
}
|
||||
return node, i, nil
|
||||
}
|
||||
|
||||
// parseAttrs parses attributes after the tag name until `>` or `/>`. It returns
|
||||
// the attrs, the index past the terminator, and whether the tag self-closed.
|
||||
func parseAttrs(src string, i int) ([]jsxAttr, int, bool, error) {
|
||||
n := len(src)
|
||||
var attrs []jsxAttr
|
||||
for i < n {
|
||||
for i < n && isSpace(src[i]) {
|
||||
i++
|
||||
}
|
||||
if i >= n {
|
||||
break
|
||||
}
|
||||
switch {
|
||||
case src[i] == '>':
|
||||
return attrs, i + 1, false, nil
|
||||
case src[i] == '/' && i+1 < n && src[i+1] == '>':
|
||||
return attrs, i + 2, true, nil
|
||||
case src[i] == '{': // {...spread}
|
||||
expr, ni := captureBraces(src, i)
|
||||
e := strings.TrimSpace(expr)
|
||||
e = strings.TrimSpace(strings.TrimPrefix(e, "..."))
|
||||
attrs = append(attrs, jsxAttr{kind: attrSpread, expr: e})
|
||||
i = ni
|
||||
default:
|
||||
ns := i
|
||||
for i < n && isAttrNameChar(src[i]) {
|
||||
i++
|
||||
}
|
||||
if i == ns {
|
||||
return nil, 0, false, fmt.Errorf("parseAttrs: unexpected %q at %d", src[i], i)
|
||||
}
|
||||
name := src[ns:i]
|
||||
for i < n && isSpace(src[i]) {
|
||||
i++
|
||||
}
|
||||
if i < n && src[i] == '=' {
|
||||
i++
|
||||
for i < n && isSpace(src[i]) {
|
||||
i++
|
||||
}
|
||||
if i >= n {
|
||||
return nil, 0, false, fmt.Errorf("parseAttrs: attr value expected")
|
||||
}
|
||||
if src[i] == '{' {
|
||||
expr, ni := captureBraces(src, i)
|
||||
attrs = append(attrs, jsxAttr{kind: attrExpr, name: name, expr: strings.TrimSpace(expr)})
|
||||
i = ni
|
||||
} else if src[i] == '"' || src[i] == '\'' {
|
||||
q := src[i]
|
||||
i++
|
||||
vs := i
|
||||
for i < n && src[i] != q {
|
||||
i++
|
||||
}
|
||||
attrs = append(attrs, jsxAttr{kind: attrStatic, name: name, value: src[vs:i]})
|
||||
if i < n {
|
||||
i++ // closing quote
|
||||
}
|
||||
} else {
|
||||
return nil, 0, false, fmt.Errorf("parseAttrs: bad attr value at %d", i)
|
||||
}
|
||||
} else {
|
||||
attrs = append(attrs, jsxAttr{kind: attrStatic, name: name, boolt: true})
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, 0, false, fmt.Errorf("parseAttrs: unterminated tag")
|
||||
}
|
||||
|
||||
// parseChildren parses child nodes until the matching `</`. It returns the nodes
|
||||
// and the index at the `<` of the closing tag. Adjacent character data becomes a
|
||||
// single jsxText node (JSX whitespace normalization happens in codegen).
|
||||
func parseChildren(src string, i int) ([]jsxNode, int, error) {
|
||||
n := len(src)
|
||||
var nodes []jsxNode
|
||||
var text strings.Builder
|
||||
flush := func() {
|
||||
if text.Len() > 0 {
|
||||
nodes = append(nodes, jsxNode{kind: jsxText, text: text.String()})
|
||||
text.Reset()
|
||||
}
|
||||
}
|
||||
for i < n {
|
||||
c := src[i]
|
||||
switch {
|
||||
case c == '<' && i+1 < n && src[i+1] == '/':
|
||||
flush()
|
||||
return nodes, i, nil
|
||||
case c == '<':
|
||||
flush()
|
||||
child, ni, err := parseJSX(src, i)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
nodes = append(nodes, child)
|
||||
i = ni
|
||||
case c == '{':
|
||||
flush()
|
||||
expr, ni := captureBraces(src, i)
|
||||
nodes = append(nodes, jsxNode{kind: jsxExpr, expr: strings.TrimSpace(expr)})
|
||||
i = ni
|
||||
default:
|
||||
text.WriteByte(c)
|
||||
i++
|
||||
}
|
||||
}
|
||||
return nil, 0, fmt.Errorf("parseChildren: unterminated (missing close tag)")
|
||||
}
|
||||
|
||||
// consumeCloseTag consumes `</name>` (or `</>`) starting at src[i] == '<'.
|
||||
func consumeCloseTag(src string, i int) (int, error) {
|
||||
n := len(src)
|
||||
if i+1 >= n || src[i] != '<' || src[i+1] != '/' {
|
||||
return 0, fmt.Errorf("consumeCloseTag: expected '</' at %d", i)
|
||||
}
|
||||
i += 2
|
||||
for i < n && src[i] != '>' {
|
||||
i++
|
||||
}
|
||||
if i >= n {
|
||||
return 0, fmt.Errorf("consumeCloseTag: unterminated close tag")
|
||||
}
|
||||
return i + 1, nil // past '>'
|
||||
}
|
||||
|
||||
// captureBraces returns the text between a `{` at src[i] and its matching `}`
|
||||
// (exclusive), and the index just past that `}`. It tracks strings, template
|
||||
// literals (with `${}` interpolation), comments, and regex literals so braces
|
||||
// inside them don't miscount — the same lexer the segmenter uses.
|
||||
func captureBraces(src string, i int) (inner string, next int) {
|
||||
n := len(src)
|
||||
start := i + 1
|
||||
i++ // skip opening '{'
|
||||
depth := 0
|
||||
state := stNormal
|
||||
var tmplStack []int
|
||||
var prevSig byte
|
||||
for ; i < n; i++ {
|
||||
c := src[i]
|
||||
switch state {
|
||||
case stNormal:
|
||||
switch c {
|
||||
case '/':
|
||||
if i+1 < n && src[i+1] == '/' {
|
||||
state = stLineComment
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if i+1 < n && src[i+1] == '*' {
|
||||
state = stBlockComment
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if regexAllowed(src, i, prevSig) {
|
||||
state = stRegex
|
||||
prevSig = c
|
||||
continue
|
||||
}
|
||||
prevSig = c
|
||||
case '\'':
|
||||
state = stSingle
|
||||
prevSig = c
|
||||
case '"':
|
||||
state = stDouble
|
||||
prevSig = c
|
||||
case '`':
|
||||
state = stTemplate
|
||||
prevSig = c
|
||||
case '{', '(', '[':
|
||||
depth++
|
||||
prevSig = c
|
||||
case '}':
|
||||
if len(tmplStack) > 0 && depth == tmplStack[len(tmplStack)-1] {
|
||||
tmplStack = tmplStack[:len(tmplStack)-1]
|
||||
depth--
|
||||
state = stTemplate
|
||||
} else if depth > 0 {
|
||||
depth--
|
||||
prevSig = c
|
||||
} else {
|
||||
return src[start:i], i + 1 // matching close of the outer '{'
|
||||
}
|
||||
case ')', ']':
|
||||
if depth > 0 {
|
||||
depth--
|
||||
}
|
||||
prevSig = c
|
||||
case '<':
|
||||
// A `<` in expression position (and followed by a tag/fragment
|
||||
// start) opens nested JSX, not a less-than: skip the whole element
|
||||
// via parseJSX so its `</tag>` slashes and `{}` don't desync the JS
|
||||
// lexer. Otherwise it's the comparison operator.
|
||||
if regexAllowed(src, i, prevSig) && i+1 < n && (isASCIILetter(src[i+1]) || src[i+1] == '>') {
|
||||
if _, ni, err := parseJSX(src, i); err == nil {
|
||||
i = ni - 1 // loop's i++ lands just past the element
|
||||
prevSig = '>'
|
||||
continue
|
||||
}
|
||||
}
|
||||
prevSig = c
|
||||
default:
|
||||
if !isSpace(c) {
|
||||
prevSig = c
|
||||
}
|
||||
}
|
||||
case stLineComment:
|
||||
if c == '\n' {
|
||||
state = stNormal
|
||||
}
|
||||
case stBlockComment:
|
||||
if c == '*' && i+1 < n && src[i+1] == '/' {
|
||||
state = stNormal
|
||||
i++
|
||||
}
|
||||
case stSingle:
|
||||
if c == '\\' {
|
||||
i++
|
||||
} else if c == '\'' {
|
||||
state = stNormal
|
||||
prevSig = c
|
||||
}
|
||||
case stDouble:
|
||||
if c == '\\' {
|
||||
i++
|
||||
} else if c == '"' {
|
||||
state = stNormal
|
||||
prevSig = c
|
||||
}
|
||||
case stTemplate:
|
||||
if c == '\\' {
|
||||
i++
|
||||
} else if c == '`' {
|
||||
state = stNormal
|
||||
prevSig = c
|
||||
} else if c == '$' && i+1 < n && src[i+1] == '{' {
|
||||
depth++
|
||||
tmplStack = append(tmplStack, depth)
|
||||
state = stNormal
|
||||
i++
|
||||
}
|
||||
case stRegex:
|
||||
if c == '\\' {
|
||||
i++
|
||||
} else if c == '[' {
|
||||
for i++; i < n; i++ {
|
||||
if src[i] == '\\' {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if src[i] == ']' {
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if c == '/' {
|
||||
state = stNormal
|
||||
prevSig = c
|
||||
}
|
||||
}
|
||||
}
|
||||
return src[start:], n // unterminated
|
||||
}
|
||||
|
||||
func isComponentTag(tag string) bool {
|
||||
if tag == "" {
|
||||
return false
|
||||
}
|
||||
if strings.ContainsAny(tag, ".") {
|
||||
return true // member expression component, e.g. <Foo.Bar>
|
||||
}
|
||||
c := tag[0]
|
||||
return c >= 'A' && c <= 'Z'
|
||||
}
|
||||
|
||||
func isTagChar(b byte) bool {
|
||||
return isASCIILetter(b) || (b >= '0' && b <= '9') || b == '-' || b == '.' || b == ':' || b == '_'
|
||||
}
|
||||
|
||||
func isAttrNameChar(b byte) bool {
|
||||
return isASCIILetter(b) || (b >= '0' && b <= '9') || b == '-' || b == ':' || b == '_'
|
||||
}
|
||||
|
||||
func isSpace(b byte) bool {
|
||||
return b == ' ' || b == '\t' || b == '\n' || b == '\r'
|
||||
}
|
||||
1038
go/bundler/compile_solid_gen.go
Normal file
1038
go/bundler/compile_solid_gen.go
Normal file
File diff suppressed because it is too large
Load Diff
257
go/bundler/compile_solid_render_test.go
Normal file
257
go/bundler/compile_solid_render_test.go
Normal file
@@ -0,0 +1,257 @@
|
||||
package bundler
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// renderComponent bundles a compiled component module (which must `export const A`)
|
||||
// with a render harness, runs it in the goja SSR engine, and returns the HTML.
|
||||
func renderComponent(t *testing.T, compiledJS string) (string, error) {
|
||||
t.Helper()
|
||||
entry := compiledJS + "\n" +
|
||||
`import { render as _$r, createComponent as _$cc } from "solid-js/web";
|
||||
globalThis.__render = function () {
|
||||
var root = document.createElement("div");
|
||||
var dispose = _$r(function () { return _$cc(A, {}); }, root);
|
||||
var out = globalThis.__serialize(root);
|
||||
dispose();
|
||||
return out;
|
||||
};`
|
||||
bundle, err := BundleEntry(entry, ".")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
eng, err := New()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := eng.LoadBundle(bundle); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return eng.Render()
|
||||
}
|
||||
|
||||
// assertRenderEquivalent compiles src with babel AND the Go compiler, renders
|
||||
// both, and requires identical HTML. This is the compiler's correctness oracle.
|
||||
func assertRenderEquivalent(t *testing.T, name, src string) {
|
||||
t.Helper()
|
||||
root, err := filepath.Abs("../..")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Chdir(root)
|
||||
|
||||
babelJS, err := Compile(src, name+".tsx")
|
||||
if err != nil {
|
||||
t.Fatalf("[%s] babel compile: %v", name, err)
|
||||
}
|
||||
goJS, err := compileSolidGo(src, name+".tsx", false)
|
||||
if err != nil {
|
||||
t.Fatalf("[%s] go compile: %v\n--- go output ---\n%s", name, err, goJS)
|
||||
}
|
||||
|
||||
babelHTML, err := renderComponent(t, babelJS)
|
||||
if err != nil {
|
||||
t.Fatalf("[%s] render babel: %v", name, err)
|
||||
}
|
||||
goHTML, err := renderComponent(t, goJS)
|
||||
if err != nil {
|
||||
t.Fatalf("[%s] render go: %v\n--- go output ---\n%s", name, err, goJS)
|
||||
}
|
||||
|
||||
if babelHTML != goHTML {
|
||||
t.Errorf("[%s] render mismatch:\n babel: %q\n go: %q\n--- go compiled ---\n%s", name, babelHTML, goHTML, goJS)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoCompilerRenderCore(t *testing.T) {
|
||||
cases := []struct{ name, src string }{
|
||||
{"static", `export const A = () => <div class="x">hi</div>;`},
|
||||
{"dyn-text", `export const A = () => { const c = () => 42; return <div>{c()}</div>; };`},
|
||||
{"nested", `export const A = () => { const x = () => "X"; return <div><span>a</span><b>{x()}</b></div>; };`},
|
||||
{"mixed-children", `export const A = () => { const x = () => "X"; const y = () => "Y"; return <div>before {x()} after {y()}</div>; };`},
|
||||
{"dyn-attr", `export const A = () => { const id = () => "foo"; return <div class="s" id={id()}>hi</div>; };`},
|
||||
{"list", `export const A = () => { const items = ["a", "b", "c"]; return <ul>{items.map((i) => <li>{i}</li>)}</ul>; };`},
|
||||
{"deep-static", `export const A = () => <section><header><h1>Title</h1></header><p>body text</p></section>;`},
|
||||
{"multi-attr", `export const A = () => <input type="text" name="q" disabled />;`},
|
||||
}
|
||||
for _, c := range cases {
|
||||
c := c
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
assertRenderEquivalent(t, c.name, c.src)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoCompilerRenderComponents(t *testing.T) {
|
||||
cases := []struct{ name, src string }{
|
||||
{"component-children", `export const A = () => { const Box = (props) => <div class="box">{props.children}</div>; return <Box>hi</Box>; };`},
|
||||
{"component-dyn-prop", `export const A = () => { const Lbl = (props) => <span>{props.text}</span>; const t = () => "yo"; return <Lbl text={t()} />; };`},
|
||||
{"nested-components", `export const A = () => { const Row = (props) => <li>{props.children}</li>; return <ul><Row>one</Row><Row>two</Row></ul>; };`},
|
||||
{"show-true", `import { Show } from "solid-js"; export const A = () => <div><Show when={true} fallback={<p>no</p>}>yes</Show></div>;`},
|
||||
{"show-false", `import { Show } from "solid-js"; export const A = () => <div><Show when={false} fallback={<p>no</p>}>yes</Show></div>;`},
|
||||
{"for", `import { For } from "solid-js"; export const A = () => <ul><For each={[1, 2, 3]}>{(n) => <li>{n}</li>}</For></ul>;`},
|
||||
{"fragment", `export const A = () => { const a = () => "A"; const b = () => "B"; return <div>{a()}{b()}</div>; };`},
|
||||
}
|
||||
for _, c := range cases {
|
||||
c := c
|
||||
t.Run(c.name, func(t *testing.T) { assertRenderEquivalent(t, c.name, c.src) })
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoCompilerRenderSpreadRef(t *testing.T) {
|
||||
cases := []struct{ name, src string }{
|
||||
{"spread", `export const A = () => { const p = { id: "pid", title: "t" }; return <div {...p} class="x">hi</div>; };`},
|
||||
{"spread-override", `export const A = () => { const p = { class: "from-p" }; return <div {...p} class="from-attr">hi</div>; };`},
|
||||
{"ref", `export const A = () => { let r; return <div ref={r}>hi</div>; };`},
|
||||
}
|
||||
for _, c := range cases {
|
||||
c := c
|
||||
t.Run(c.name, func(t *testing.T) { assertRenderEquivalent(t, c.name, c.src) })
|
||||
}
|
||||
}
|
||||
|
||||
// Compile every real .tsx with the Go compiler and assert it produces parseable
|
||||
// output. This surfaces constructs the codebase uses that the codegen doesn't
|
||||
// handle yet (the failures ARE the milestone-3/4 gap list).
|
||||
func TestGoCompilerCompilesRealFiles(t *testing.T) {
|
||||
files := walkRepoTSX(t)
|
||||
var failed, ok int
|
||||
for _, f := range files {
|
||||
data, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out, err := compileSolidGo(string(data), f, false)
|
||||
if err != nil {
|
||||
failed++
|
||||
t.Logf("COMPILE-ERR %s: %v", f, err)
|
||||
continue
|
||||
}
|
||||
if err := validateJS(out); err != nil {
|
||||
failed++
|
||||
t.Logf("PARSE-ERR %s: %v", f, err)
|
||||
continue
|
||||
}
|
||||
ok++
|
||||
}
|
||||
t.Logf("Go compiler: %d/%d real .tsx produced parseable output (%d failed)", ok, len(files), failed)
|
||||
}
|
||||
|
||||
// M5: dev mode wraps components with solid-refresh. Verify the shape and that
|
||||
// every real file still produces parseable output with instrumentation on.
|
||||
func TestGoCompilerRefreshInstrumentation(t *testing.T) {
|
||||
src := `export function Counter() { const c = () => 1; return <div>{c()}</div>; }
|
||||
export const Banner = () => <span>hi</span>;
|
||||
const NOT_A_COMPONENT = 42;
|
||||
function helper() { return 5; }`
|
||||
out, err := compileSolidGo(src, "Refresh.tsx", true)
|
||||
if err != nil {
|
||||
t.Fatalf("dev compile: %v", err)
|
||||
}
|
||||
if err := validateJS(out); err != nil {
|
||||
t.Fatalf("dev output does not parse: %v\n%s", err, out)
|
||||
}
|
||||
for _, want := range []string{
|
||||
`from "solid-refresh"`,
|
||||
"const _REGISTRY = _$$registry();",
|
||||
`_$$component(_REGISTRY, "Counter", function Counter`,
|
||||
`_$$component(_REGISTRY, "Banner",`,
|
||||
`if (import.meta.hot) { _$$refresh("esm", import.meta.hot, _REGISTRY); }`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("dev output missing %q\n--- output ---\n%s", want, out)
|
||||
}
|
||||
}
|
||||
// Non-components must NOT be wrapped.
|
||||
if strings.Contains(out, `"NOT_A_COMPONENT"`) || strings.Contains(out, `"helper"`) {
|
||||
t.Errorf("non-component was wrapped:\n%s", out)
|
||||
}
|
||||
// Prod mode must have no refresh instrumentation.
|
||||
prod, _ := compileSolidGo(src, "Refresh.tsx", false)
|
||||
if strings.Contains(prod, "solid-refresh") {
|
||||
t.Errorf("prod output leaked refresh instrumentation:\n%s", prod)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoCompilerDevCompilesRealFiles(t *testing.T) {
|
||||
files := walkRepoTSX(t)
|
||||
var failed, ok int
|
||||
for _, f := range files {
|
||||
data, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out, err := compileSolidGo(string(data), f, true)
|
||||
if err != nil {
|
||||
failed++
|
||||
t.Logf("DEV-COMPILE-ERR %s: %v", f, err)
|
||||
continue
|
||||
}
|
||||
if err := validateJS(out); err != nil {
|
||||
failed++
|
||||
t.Logf("DEV-PARSE-ERR %s: %v", f, err)
|
||||
continue
|
||||
}
|
||||
ok++
|
||||
}
|
||||
t.Logf("Go compiler (dev): %d/%d real .tsx parseable with refresh (%d failed)", ok, len(files), failed)
|
||||
}
|
||||
|
||||
// Regression: a context provider's JSX children must evaluate lazily (inside the
|
||||
// provider), or a consumer reads the context before it's set. Eager `children:`
|
||||
// makes Consumer throw "no ctx"; lazy `get children()` renders correctly.
|
||||
func TestGoCompilerContextChildren(t *testing.T) {
|
||||
src := `import { createContext, useContext } from "solid-js";
|
||||
const Ctx = createContext();
|
||||
function Provider(props) { return <Ctx.Provider value="ok">{props.children}</Ctx.Provider>; }
|
||||
function Consumer() { const v = useContext(Ctx); if (!v) throw new Error("no ctx"); return <span>{v}</span>; }
|
||||
export const A = () => <Provider><Consumer /></Provider>;`
|
||||
root, _ := filepath.Abs("../..")
|
||||
t.Chdir(root)
|
||||
goJS, err := compileSolidGo(src, "Ctx.tsx", false)
|
||||
if err != nil {
|
||||
t.Fatalf("compile: %v", err)
|
||||
}
|
||||
html, err := renderComponent(t, goJS)
|
||||
if err != nil {
|
||||
t.Fatalf("render (context leaked?): %v\n--- compiled ---\n%s", err, goJS)
|
||||
}
|
||||
if !strings.Contains(html, "ok") {
|
||||
t.Errorf("expected context value in output, got: %q", html)
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: object `style` must go through _$style (setProperty per key), not
|
||||
// setAttribute (which stringifies to "[object Object]" and breaks positioning);
|
||||
// innerHTML must be a property assignment (setAttribute is a no-op → empty icons).
|
||||
func TestGoCompilerStyleAndInnerHTML(t *testing.T) {
|
||||
root, _ := filepath.Abs("../..")
|
||||
t.Chdir(root)
|
||||
cases := []struct{ name, src, want, absent string }{
|
||||
{"object-style", `export const A = () => { const s = () => ({ top: "10px", left: "20px" }); return <div style={s()}>x</div>; };`, "10px", "[object Object]"},
|
||||
{"innerHTML", "export const A = () => { const h = () => '<path d=\"M1 2\"/>'; return <svg innerHTML={h()}></svg>; };", "<path", "innerHTML="},
|
||||
}
|
||||
for _, c := range cases {
|
||||
c := c
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
out, err := compileSolidGo(c.src, c.name+".tsx", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
html, err := renderComponent(t, out)
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v\n%s", err, out)
|
||||
}
|
||||
if !strings.Contains(html, c.want) {
|
||||
t.Errorf("output %q missing %q\ncompiled:\n%s", html, c.want, out)
|
||||
}
|
||||
if strings.Contains(html, c.absent) {
|
||||
t.Errorf("output %q still contains broken %q", html, c.absent)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
116
go/bundler/compile_solid_test.go
Normal file
116
go/bundler/compile_solid_test.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package bundler
|
||||
|
||||
import "testing"
|
||||
|
||||
// parseOne parses a single JSX element starting at the first '<'.
|
||||
func parseOne(t *testing.T, src string) jsxNode {
|
||||
t.Helper()
|
||||
i := 0
|
||||
for i < len(src) && src[i] != '<' {
|
||||
i++
|
||||
}
|
||||
node, next, err := parseJSX(src, i)
|
||||
if err != nil {
|
||||
t.Fatalf("parseJSX(%q): %v", src, err)
|
||||
}
|
||||
if next != len(src) {
|
||||
t.Fatalf("parseJSX(%q): consumed to %d, want %d (trailing %q)", src, next, len(src), src[next:])
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
func TestParseStaticElement(t *testing.T) {
|
||||
n := parseOne(t, `<div class="x">hi</div>`)
|
||||
if n.kind != jsxElement || n.tag != "div" {
|
||||
t.Fatalf("kind/tag = %d/%q", n.kind, n.tag)
|
||||
}
|
||||
if len(n.attrs) != 1 || n.attrs[0].kind != attrStatic || n.attrs[0].name != "class" || n.attrs[0].value != "x" {
|
||||
t.Fatalf("attrs = %+v", n.attrs)
|
||||
}
|
||||
if len(n.children) != 1 || n.children[0].kind != jsxText || n.children[0].text != "hi" {
|
||||
t.Fatalf("children = %+v", n.children)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseComponent(t *testing.T) {
|
||||
n := parseOne(t, `<Foo bar={1}>child</Foo>`)
|
||||
if n.kind != jsxComponent || n.tag != "Foo" {
|
||||
t.Fatalf("kind/tag = %d/%q", n.kind, n.tag)
|
||||
}
|
||||
if len(n.attrs) != 1 || n.attrs[0].kind != attrExpr || n.attrs[0].name != "bar" || n.attrs[0].expr != "1" {
|
||||
t.Fatalf("attrs = %+v", n.attrs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNested(t *testing.T) {
|
||||
n := parseOne(t, `<div><span>a</span><b>{x()}</b></div>`)
|
||||
if len(n.children) != 2 {
|
||||
t.Fatalf("want 2 element children, got %d: %+v", len(n.children), n.children)
|
||||
}
|
||||
if n.children[0].tag != "span" || n.children[1].tag != "b" {
|
||||
t.Fatalf("child tags = %q, %q", n.children[0].tag, n.children[1].tag)
|
||||
}
|
||||
b := n.children[1]
|
||||
if len(b.children) != 1 || b.children[0].kind != jsxExpr || b.children[0].expr != "x()" {
|
||||
t.Fatalf("b children = %+v", b.children)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFragment(t *testing.T) {
|
||||
n := parseOne(t, `<>{a()}{b()}</>`)
|
||||
if n.kind != jsxFragment || len(n.children) != 2 {
|
||||
t.Fatalf("fragment: kind=%d children=%d", n.kind, len(n.children))
|
||||
}
|
||||
if n.children[0].expr != "a()" || n.children[1].expr != "b()" {
|
||||
t.Fatalf("fragment children = %+v", n.children)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSelfCloseAndBool(t *testing.T) {
|
||||
n := parseOne(t, `<input disabled type="text" />`)
|
||||
if n.tag != "input" || len(n.children) != 0 {
|
||||
t.Fatalf("input: tag=%q children=%d", n.tag, len(n.children))
|
||||
}
|
||||
if len(n.attrs) != 2 || !n.attrs[0].boolt || n.attrs[0].name != "disabled" {
|
||||
t.Fatalf("attrs = %+v", n.attrs)
|
||||
}
|
||||
if n.attrs[1].name != "type" || n.attrs[1].value != "text" {
|
||||
t.Fatalf("type attr = %+v", n.attrs[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSpread(t *testing.T) {
|
||||
n := parseOne(t, `<div {...p} class="x">hi</div>`)
|
||||
if len(n.attrs) != 2 || n.attrs[0].kind != attrSpread || n.attrs[0].expr != "p" {
|
||||
t.Fatalf("attrs = %+v", n.attrs)
|
||||
}
|
||||
}
|
||||
|
||||
// The hardest case: an expression child containing nested JSX AND braces inside
|
||||
// strings/templates. The parser must capture the whole expression opaquely.
|
||||
func TestParseExprWithNestedJSX(t *testing.T) {
|
||||
n := parseOne(t, "<ul>{items.map((x) => <li>{x}</li>)}</ul>")
|
||||
if len(n.children) != 1 || n.children[0].kind != jsxExpr {
|
||||
t.Fatalf("children = %+v", n.children)
|
||||
}
|
||||
if n.children[0].expr != "items.map((x) => <li>{x}</li>)" {
|
||||
t.Fatalf("expr = %q", n.children[0].expr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptureBracesLexer(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{`{a + "}" + b}`, `a + "}" + b`},
|
||||
{"{`a${nested}b`}", "`a${nested}b`"},
|
||||
{`{ {k: 1} }`, ` {k: 1} `},
|
||||
{`{f(/[}]/)}`, `f(/[}]/)`},
|
||||
{`{x} rest`, `x`},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, next := captureBraces(c.in, 0)
|
||||
if got != c.want {
|
||||
t.Errorf("captureBraces(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
_ = next
|
||||
}
|
||||
}
|
||||
91
go/bundler/css.go
Normal file
91
go/bundler/css.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package bundler
|
||||
|
||||
// CSS pipeline: compiles the Tailwind entry stylesheet (frontend/css/style.css)
|
||||
// with the native Go Tailwind v4 engine (tailwind.go — twCompile/scanSources, no
|
||||
// goja), feeding it the utility-class candidates scanned from source files, then
|
||||
// minifies via tdewolff/minify. style.css is the single entry/config for both
|
||||
// bundles — they differ only in which source files are scanned for candidates.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/tdewolff/minify/v2"
|
||||
mincss "github.com/tdewolff/minify/v2/css"
|
||||
)
|
||||
|
||||
// Tailwind source patterns - configured here rather than in CSS so each bundle
|
||||
// scans only the files it actually needs. The scanner filters by the suffix of
|
||||
// each pattern, so separate entries are needed for .js and .ts source files.
|
||||
var twSourcesSPA = []string{
|
||||
"../src/**/*.js",
|
||||
"../src/**/*.ts",
|
||||
"../src/**/*.jsx",
|
||||
"../src/**/*.tsx",
|
||||
"../../internal/handlers/templates/**/*.html",
|
||||
}
|
||||
var twSourcesPublic = []string{
|
||||
"../../internal/handlers/templates/**/*.html",
|
||||
// Public pages authored as Solid components in .tsx (SSR'd via goja).
|
||||
"../src/pages/public/**/*.tsx",
|
||||
"../src/pages/public/**/*.jsx",
|
||||
"../src/pages/public/**/*.ts",
|
||||
"../src/pages/public/**/*.js",
|
||||
// Shared UI component library. If a public page renders any ui/ component
|
||||
// (data tables, tabs, icons, etc.), its classes must be in this bundle too,
|
||||
// so scan the whole library rather than just the env badge / layout.
|
||||
"../src/ui/**/*.ts",
|
||||
"../src/ui/**/*.js",
|
||||
}
|
||||
|
||||
// styleEntry is the Tailwind entry/config, relative to frontendDir. Both the SPA
|
||||
// and public bundles compile it; include.css (a former one-line passthrough) is gone.
|
||||
const styleEntry = "css/style.css"
|
||||
|
||||
var m *minify.M
|
||||
|
||||
func init() {
|
||||
m = minify.New()
|
||||
m.AddFunc("text/css", mincss.Minify)
|
||||
}
|
||||
|
||||
func bundleSPACSS() (bundleStats, error) {
|
||||
return compileCSSBundle("SPA", twSourcesSPA, "bundle.min.css")
|
||||
}
|
||||
|
||||
func bundlePublicCSS() (bundleStats, error) {
|
||||
return compileCSSBundle("public", twSourcesPublic, "public.bundle.min.css")
|
||||
}
|
||||
|
||||
// compileCSSBundle scans twSources for candidates, compiles style.css with the
|
||||
// official Tailwind compiler, minifies, and writes outName to wwwroot. It prints
|
||||
// a timing line for the Tailwind step so the compile can be profiled.
|
||||
func compileCSSBundle(label string, twSources []string, outName string) (bundleStats, error) {
|
||||
entryPath := filepath.Join(frontendDir, styleEntry)
|
||||
src, err := os.ReadFile(entryPath)
|
||||
if err != nil {
|
||||
return bundleStats{}, fmt.Errorf("reading %s: %w", styleEntry, err)
|
||||
}
|
||||
cssDir := filepath.Dir(entryPath)
|
||||
|
||||
candidates := scanSources(cssDir, twSources)
|
||||
|
||||
compiled, count, err := twCompile(string(src), cssDir, candidates)
|
||||
if err != nil {
|
||||
return bundleStats{}, fmt.Errorf("tailwind compile (%s): %w", label, err)
|
||||
}
|
||||
fmt.Printf(" Tailwind (%s): %d candidates, %d utilities compiled\n",
|
||||
label, len(candidates), count)
|
||||
|
||||
minified, err := m.String("text/css", compiled)
|
||||
if err != nil {
|
||||
return bundleStats{}, fmt.Errorf("minifying %s CSS: %w", label, err)
|
||||
}
|
||||
|
||||
outPath := filepath.Join(outputDir, outName)
|
||||
if err := os.WriteFile(outPath, []byte(minified), 0644); err != nil {
|
||||
return bundleStats{}, err
|
||||
}
|
||||
return bundleStats{files: len(candidates), bytes: len(minified)}, nil
|
||||
}
|
||||
89
go/bundler/export_shim.go
Normal file
89
go/bundler/export_shim.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package bundler
|
||||
|
||||
// defaultExportShimPlugin bridges two ESM-strictness mismatches that
|
||||
// existed in the previous custom bundler:
|
||||
//
|
||||
// 1. `export default function Name` also emits `export { Name }` so
|
||||
// callers using `import { Name } from "..."` resolve correctly.
|
||||
// 2. If the source declares `export function/class/const Name` (or
|
||||
// `Name`) where Name matches the file's basename, synthesize
|
||||
// `export { Name as default }` so callers using `import Name from
|
||||
// "..."` resolve correctly. The old bundler made every IIFE
|
||||
// return value available as both the named symbol AND the default
|
||||
// import; standard ESM does not, so esbuild needs the shim until
|
||||
// callers migrate to named imports throughout.
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
esbuild "github.com/evanw/esbuild/pkg/api"
|
||||
)
|
||||
|
||||
func defaultExportShimPlugin() esbuild.Plugin {
|
||||
return esbuild.Plugin{
|
||||
Name: "default-export-shim",
|
||||
Setup: func(b esbuild.PluginBuild) {
|
||||
b.OnLoad(esbuild.OnLoadOptions{Filter: `\.(js|ts)$`}, func(args esbuild.OnLoadArgs) (esbuild.OnLoadResult, error) {
|
||||
raw, err := os.ReadFile(args.Path)
|
||||
if err != nil {
|
||||
return esbuild.OnLoadResult{}, err
|
||||
}
|
||||
out := addNamedExportForDefault(string(raw))
|
||||
out = addDefaultExportForFilenameMatch(out, args.Path)
|
||||
loader := esbuild.LoaderJS
|
||||
if strings.HasSuffix(args.Path, ".ts") {
|
||||
loader = esbuild.LoaderTS
|
||||
}
|
||||
return esbuild.OnLoadResult{Contents: &out, Loader: loader}, nil
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
var reFilenameMatchExport = regexp.MustCompile(`(?m)^export\s+(?:async\s+)?(?:function|class|const|let|var)\s+(\w+)`)
|
||||
var reExistingDefault = regexp.MustCompile(`(?m)^export\s+default\b`)
|
||||
|
||||
// addDefaultExportForFilenameMatch synthesizes `export { Name as
|
||||
// default }` if the source declares a top-level export whose name
|
||||
// matches the file's basename and the file does not already have a
|
||||
// default export. This preserves the old bundler's behavior where
|
||||
// `import Foo from "./Foo.js"` resolved against `export function
|
||||
// Foo(...)`.
|
||||
func addDefaultExportForFilenameMatch(src, path string) string {
|
||||
if reExistingDefault.MatchString(src) {
|
||||
return src
|
||||
}
|
||||
base := filepath.Base(path)
|
||||
base = strings.TrimSuffix(base, filepath.Ext(base))
|
||||
if base == "" {
|
||||
return src
|
||||
}
|
||||
for _, m := range reFilenameMatchExport.FindAllStringSubmatch(src, -1) {
|
||||
if m[1] == base {
|
||||
return src + "\nexport { " + base + " as default };\n"
|
||||
}
|
||||
}
|
||||
return src
|
||||
}
|
||||
|
||||
var reExportDefaultDecl = regexp.MustCompile(`(?m)^export\s+default\s+(?:async\s+)?(?:function|class)\s+(\w+)`)
|
||||
|
||||
func addNamedExportForDefault(src string) string {
|
||||
matches := reExportDefaultDecl.FindAllStringSubmatch(src, -1)
|
||||
if len(matches) == 0 {
|
||||
return src
|
||||
}
|
||||
names := make([]string, 0, len(matches))
|
||||
seen := map[string]bool{}
|
||||
for _, m := range matches {
|
||||
if seen[m[1]] {
|
||||
continue
|
||||
}
|
||||
seen[m[1]] = true
|
||||
names = append(names, m[1])
|
||||
}
|
||||
return src + "\nexport { " + strings.Join(names, ", ") + " };\n"
|
||||
}
|
||||
201
go/bundler/faicons.go
Normal file
201
go/bundler/faicons.go
Normal file
@@ -0,0 +1,201 @@
|
||||
package bundler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Tree-shaken FontAwesome. Instead of shipping the 41.5 MB `all.min.js` kit and
|
||||
// looking icons up by runtime string, we scan the app for the icon names it
|
||||
// actually references and emit a registry of just those icons' SVG data, pulled
|
||||
// from the FontAwesome SVGs under frontend/icons/. Icons.tsx looks up that
|
||||
// registry exactly like it used to call FontAwesome.findIconDefinition.
|
||||
|
||||
// FA prefix -> frontend/icons/<dir>. cdrateline renders classic far/fas; a Sharp
|
||||
// project (fasr/fass) would add "fasr": "sharp-regular", "fass": "sharp-solid".
|
||||
var faStyleDirs = map[string]string{
|
||||
"far": "regular",
|
||||
"fas": "solid",
|
||||
}
|
||||
|
||||
// faIconsDir holds the FontAwesome SVGs (the kit's svgs-full/, relocated here),
|
||||
// grouped by style. Only the styles in faStyleDirs are read; the rest are unused.
|
||||
const faIconsDir = "frontend/icons"
|
||||
|
||||
var faOutFile = filepath.Join(frontendDir, "src", "ui", "generated", "faIcons.ts")
|
||||
|
||||
var (
|
||||
// icon="name" / icon: "name"
|
||||
reIconAttr = regexp.MustCompile(`\bicon\s*(?:=|:)\s*"([a-z0-9][a-z0-9-]*)"`)
|
||||
// icon={ ... } — dynamic expressions; pull any string literals (ternaries etc.)
|
||||
reIconBrace = regexp.MustCompile(`\bicon\s*=\s*\{([^}]*)\}`)
|
||||
reStrLit = regexp.MustCompile(`"([a-z0-9][a-z0-9-]*)"`)
|
||||
reViewBox = regexp.MustCompile(`viewBox="0 0 ([0-9.]+) ([0-9.]+)"`)
|
||||
rePathD = regexp.MustCompile(`<path[^>]*\bd="([^"]+)"`)
|
||||
// registerIcon("name", …) — custom (non-FontAwesome) icons defined in-app.
|
||||
reRegisterIcon = regexp.MustCompile(`registerIcon\(\s*"([a-z0-9][a-z0-9-]*)"`)
|
||||
)
|
||||
|
||||
// generateFAIcons regenerates the icon registry from the FontAwesome kit. It's a
|
||||
// no-op when the kit isn't present (CI builds use the committed registry).
|
||||
func generateFAIcons() error {
|
||||
if _, err := os.Stat(faIconsDir); err != nil {
|
||||
return nil // SVGs absent — keep the committed registry
|
||||
}
|
||||
|
||||
names, custom, err := scanIconNames(filepath.Join(frontendDir, "src"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("scanning icon names: %w", err)
|
||||
}
|
||||
|
||||
type entry struct {
|
||||
key, x, y, w, h, path string
|
||||
}
|
||||
var entries []entry
|
||||
var missing []string
|
||||
for _, name := range names {
|
||||
found := false
|
||||
for prefix, dir := range faStyleDirs {
|
||||
svg, err := os.ReadFile(filepath.Join(faIconsDir, dir, name+".svg"))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
x, y, w, h, d, ok := parseFASvg(string(svg))
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, entry{prefix + ":" + name, x, y, w, h, d})
|
||||
found = true
|
||||
}
|
||||
if !found {
|
||||
missing = append(missing, name)
|
||||
}
|
||||
}
|
||||
sort.Slice(entries, func(i, j int) bool { return entries[i].key < entries[j].key })
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("// AUTO-GENERATED by cmd/bundle (generateFAIcons) — do not edit.\n")
|
||||
b.WriteString("// A tree-shaken subset of FontAwesome: only the icons this app references,\n")
|
||||
b.WriteString("// as [x, y, width, height, svgPath] keyed by \"prefix:name\" (viewBox inset to FA's\n")
|
||||
b.WriteString("// 512 design box within the 640 kit canvas). Regenerated each build while\n")
|
||||
b.WriteString("// frontend/icons/ is present; committed so CI needs no SVGs.\n")
|
||||
b.WriteString("export const FA_ICONS: Record<string, readonly [number, number, number, number, string]> = {\n")
|
||||
for _, e := range entries {
|
||||
b.WriteString(fmt.Sprintf(" %q: [%s, %s, %s, %s, %q],\n", e.key, e.x, e.y, e.w, e.h, e.path))
|
||||
}
|
||||
b.WriteString("};\n")
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(faOutFile), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(faOutFile, []byte(b.String()), 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// A referenced name that's not in the FA kit is either a registered custom icon
|
||||
// (expected) or an unknown name — almost always a typo (report separately).
|
||||
var customUsed, unknown []string
|
||||
for _, name := range missing {
|
||||
if custom[name] {
|
||||
customUsed = append(customUsed, name)
|
||||
} else {
|
||||
unknown = append(unknown, name)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf(" FA icons: %d defs for %d names", len(entries), len(names))
|
||||
if len(customUsed) > 0 {
|
||||
sort.Strings(customUsed)
|
||||
fmt.Printf(" (%d custom: %s)", len(customUsed), strings.Join(customUsed, ", "))
|
||||
}
|
||||
if len(unknown) > 0 {
|
||||
sort.Strings(unknown)
|
||||
fmt.Printf(" (%d unknown, likely typos: %s)", len(unknown), strings.Join(unknown, ", "))
|
||||
}
|
||||
fmt.Println()
|
||||
return nil
|
||||
}
|
||||
|
||||
// scanIconNames walks dir once and returns two things: the sorted list of icon
|
||||
// names referenced anywhere (static `icon="x"`/`icon: "x"` plus string literals
|
||||
// inside `icon={...}` expressions), and the set of custom icon names registered
|
||||
// via registerIcon("name", …). The latter lets the caller tell a legitimate
|
||||
// custom icon apart from a typo when a referenced name isn't in the FA kit.
|
||||
func scanIconNames(dir string) (names []string, custom map[string]bool, err error) {
|
||||
set := map[string]bool{}
|
||||
custom = map[string]bool{}
|
||||
err = filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return err
|
||||
}
|
||||
switch filepath.Ext(path) {
|
||||
case ".ts", ".tsx", ".js", ".jsx":
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s := string(data)
|
||||
for _, m := range reIconAttr.FindAllStringSubmatch(s, -1) {
|
||||
set[m[1]] = true
|
||||
}
|
||||
for _, bm := range reIconBrace.FindAllStringSubmatch(s, -1) {
|
||||
for _, sm := range reStrLit.FindAllStringSubmatch(bm[1], -1) {
|
||||
set[sm[1]] = true
|
||||
}
|
||||
}
|
||||
for _, m := range reRegisterIcon.FindAllStringSubmatch(s, -1) {
|
||||
custom[m[1]] = true
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
names = make([]string, 0, len(set))
|
||||
for n := range set {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names, custom, nil
|
||||
}
|
||||
|
||||
// parseFASvg pulls the concatenated path data (solid/regular icons are
|
||||
// single-path; joining is safe) and a viewBox out of a FontAwesome kit SVG.
|
||||
//
|
||||
// The kit's "full" SVGs keep FA's 512-unit icon design centred inside a 640x640
|
||||
// canvas — a uniform 10% margin (square-full/circle/bars all span 64..576). We
|
||||
// inset the viewBox to that 512 design box so icons render at their intended
|
||||
// (FA6-equivalent) size instead of ~20% small on the padded canvas. The inset is
|
||||
// UNIFORM across every icon, so proportions are preserved — a caret stays a small
|
||||
// glyph. (A per-glyph bounding-box crop was wrong: it can't tell an icon meant to
|
||||
// fill its box from one deliberately padded, so it blew small glyphs up to fill it.)
|
||||
func parseFASvg(svg string) (x, y, w, h, path string, ok bool) {
|
||||
var ds []string
|
||||
for _, m := range rePathD.FindAllStringSubmatch(svg, -1) {
|
||||
ds = append(ds, m[1])
|
||||
}
|
||||
if len(ds) == 0 {
|
||||
return "", "", "", "", "", false
|
||||
}
|
||||
vb := reViewBox.FindStringSubmatch(svg)
|
||||
if vb == nil {
|
||||
return "", "", "", "", "", false
|
||||
}
|
||||
vw, e1 := strconv.ParseFloat(vb[1], 64)
|
||||
vh, e2 := strconv.ParseFloat(vb[2], 64)
|
||||
if e1 != nil || e2 != nil {
|
||||
return "0", "0", vb[1], vb[2], strings.Join(ds, " "), true // unparseable dims — use as-is
|
||||
}
|
||||
mx, my := vw/10, vh/10
|
||||
return numStr(mx), numStr(my), numStr(vw-2*mx), numStr(vh-2*my), strings.Join(ds, " "), true
|
||||
}
|
||||
|
||||
func numStr(f float64) string { return strconv.FormatFloat(f, 'f', -1, 64) }
|
||||
138
go/bundler/genroutes.go
Normal file
138
go/bundler/genroutes.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package bundler
|
||||
|
||||
// Public-route code generation. The single source of truth is the TypeScript
|
||||
// manifest frontend/src/pages/public/pages.ts. This reads it (via esbuild +
|
||||
// goja, so it's real evaluation, not fragile text parsing) and generates:
|
||||
//
|
||||
// - internal/handlers/public_pages.gen.go Go registry: routes + <title> +
|
||||
// the page's pre-rendered body (baked here, see genssr.go: writeGoRegistry)
|
||||
// - frontend/src/pages/public/routes.gen.ts client router maps: route → body
|
||||
// component (publicRoutes) and route → <title> (publicTitles)
|
||||
//
|
||||
// Generated files are committed like the other build artifacts; regenerate by
|
||||
// running the bundler.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
esbuild "github.com/evanw/esbuild/pkg/api"
|
||||
)
|
||||
|
||||
const pagesManifest = "src/pages/public/pages.ts" // relative to frontendDir
|
||||
|
||||
type pageDef struct {
|
||||
Path string `json:"path"`
|
||||
Module string `json:"module"` // relative to frontend/src
|
||||
Component string `json:"component"`
|
||||
Title string `json:"title"`
|
||||
Dynamic bool `json:"dynamic"` // ISR page: also bake its render JS for request-time data rendering
|
||||
}
|
||||
|
||||
func generatePublicRoutes() error {
|
||||
defs, err := loadPageDefs()
|
||||
if err != nil {
|
||||
return fmt.Errorf("loading page manifest: %w", err)
|
||||
}
|
||||
if err := writeGoRegistry(defs); err != nil {
|
||||
return fmt.Errorf("writing Go registry: %w", err)
|
||||
}
|
||||
if err := writeClientRoutes(defs); err != nil {
|
||||
return fmt.Errorf("writing client routes: %w", err)
|
||||
}
|
||||
fmt.Printf(" Public routes: %d page(s) generated\n", len(defs))
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadPageDefs bundles and evaluates the TS manifest to get the page list.
|
||||
func loadPageDefs() ([]pageDef, error) {
|
||||
absCwd, err := filepath.Abs(".")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pagesAbs := filepath.ToSlash(filepath.Join(absCwd, frontendDir, pagesManifest))
|
||||
entry := fmt.Sprintf("import { publicPages } from %q;\nglobalThis.__PAGES__ = JSON.stringify(publicPages);", pagesAbs)
|
||||
|
||||
res := esbuild.Build(esbuild.BuildOptions{
|
||||
Stdin: &esbuild.StdinOptions{
|
||||
Contents: entry,
|
||||
ResolveDir: absCwd,
|
||||
Sourcefile: "pages-manifest.js",
|
||||
Loader: esbuild.LoaderTS,
|
||||
},
|
||||
Bundle: true,
|
||||
Format: esbuild.FormatIIFE,
|
||||
Target: esbuild.ES2017,
|
||||
Platform: esbuild.PlatformNeutral,
|
||||
LogLevel: esbuild.LogLevelSilent,
|
||||
Write: false,
|
||||
})
|
||||
if len(res.Errors) > 0 {
|
||||
msgs := esbuild.FormatMessages(res.Errors, esbuild.FormatMessagesOptions{})
|
||||
return nil, fmt.Errorf("esbuild: %s", strings.Join(msgs, "\n"))
|
||||
}
|
||||
|
||||
vm := goja.New()
|
||||
if _, err := vm.RunString(string(res.OutputFiles[0].Contents)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw := vm.Get("__PAGES__")
|
||||
if raw == nil {
|
||||
return nil, fmt.Errorf("manifest did not export publicPages")
|
||||
}
|
||||
var defs []pageDef
|
||||
if err := json.Unmarshal([]byte(raw.String()), &defs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return defs, nil
|
||||
}
|
||||
|
||||
// goRoute maps a URL pathname to a Go ServeMux pattern. Root needs "/{$}" so
|
||||
// it matches exactly instead of as a catch-all subtree.
|
||||
func goRoute(pathname string) string {
|
||||
if pathname == "/" {
|
||||
return "/{$}"
|
||||
}
|
||||
return pathname
|
||||
}
|
||||
|
||||
func writeClientRoutes(defs []pageDef) error {
|
||||
// Dedupe imports by component name (a component may back several routes).
|
||||
seen := map[string]bool{}
|
||||
var imports, entries, titles strings.Builder
|
||||
for _, d := range defs {
|
||||
if !seen[d.Component] {
|
||||
seen[d.Component] = true
|
||||
rel, err := filepath.Rel("pages/public", filepath.FromSlash(d.Module))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
imp := "./" + filepath.ToSlash(rel)
|
||||
fmt.Fprintf(&imports, "import { %s } from %q;\n", d.Component, imp)
|
||||
}
|
||||
fmt.Fprintf(&entries, " %q: %s,\n", d.Path, d.Component)
|
||||
fmt.Fprintf(&titles, " %q: %q,\n", d.Path, d.Title)
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("// Code generated by cmd/bundle; DO NOT EDIT.\n")
|
||||
b.WriteString("// Source: frontend/src/pages/public/pages.ts\n\n")
|
||||
b.WriteString("import { JSXElement } from \"solid-js\";\n")
|
||||
b.WriteString(imports.String())
|
||||
b.WriteString("\n// Body component for each public route, keyed by URL pathname. The client\n")
|
||||
b.WriteString("// router (public.ts) renders these when navigating without a full reload.\n")
|
||||
b.WriteString("export const publicRoutes: Record<string, () => JSXElement> = {\n")
|
||||
b.WriteString(entries.String())
|
||||
b.WriteString("};\n")
|
||||
b.WriteString("\n// <title> for each public route, applied by the client router on navigation\n")
|
||||
b.WriteString("// (the first load gets its title from the server-rendered shell).\n")
|
||||
b.WriteString("export const publicTitles: Record<string, string> = {\n")
|
||||
b.WriteString(titles.String())
|
||||
b.WriteString("};\n")
|
||||
|
||||
return os.WriteFile(filepath.Join(frontendDir, "src", "pages", "public", "routes.gen.ts"), []byte(b.String()), 0644)
|
||||
}
|
||||
154
go/bundler/genssr.go
Normal file
154
go/bundler/genssr.go
Normal file
@@ -0,0 +1,154 @@
|
||||
package bundler
|
||||
|
||||
// Public-page SSR code generation. For each page in the manifest
|
||||
// (frontend/src/pages/public/pages.ts) this renders the solid-js/html component
|
||||
// to its data-free skeleton HTML — at build time, via the same in-package goja
|
||||
// engine (ssr.go/renderer.go) the server re-runs for ISR — and bakes it into the
|
||||
// Go registry:
|
||||
//
|
||||
// - internal/handlers/public_pages.gen.go routes + <title> + rendered body
|
||||
//
|
||||
// The server then just wraps each baked body in the document shell and serves
|
||||
// it (internal/handlers/public_ssr.go); the browser bundle takes over on load.
|
||||
//
|
||||
// The env badge is intentionally absent from the rendered markup: this SSR
|
||||
// render doesn't define esbuild's __ENV_TYPE__ (see ssr.go), so env.ts reads ""
|
||||
// and the badge renders nothing here. The client takeover bundle bakes the real
|
||||
// compile-time environment, so the badge appears after takeover.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// writeGoRegistry renders each page's SSR skeleton and writes the Go registry
|
||||
// (internal/handlers/public_pages.gen.go). Rendering reads frontend/src and
|
||||
// wwwroot/vendor relative to the project root, so the bundler must run from
|
||||
// there (it always does).
|
||||
//
|
||||
// Rendering is cached per page (tmp/ssr-cache.json) keyed by the page's entry
|
||||
// source + the content hashes of every file esbuild bundled into it, so a build
|
||||
// only re-renders pages whose sources actually changed. Cache misses render
|
||||
// concurrently (one goja runtime per worker). See ssrcache.go.
|
||||
func writeGoRegistry(defs []pageDef) error {
|
||||
start := time.Now()
|
||||
engine := EngineHash()
|
||||
prev := loadSSRCache()
|
||||
reuse := prev.Engine == engine // an engine change invalidates every page
|
||||
hasher := newFileHasher()
|
||||
next := ssrCache{Engine: engine, Pages: make(map[string]ssrCacheEntry, len(defs))}
|
||||
|
||||
bodies := make([]string, len(defs))
|
||||
renderJSs := make([]string, len(defs)) // bundled render entry, baked for dynamic (ISR) pages
|
||||
entries := make([]string, len(defs))
|
||||
cached := make([]bool, len(defs))
|
||||
|
||||
// First pass (cheap, serial): reuse unchanged pages, collect the rest.
|
||||
var misses []renderJob
|
||||
for i, d := range defs {
|
||||
module := filepath.ToSlash(path.Join("frontend", "src", d.Module))
|
||||
entries[i] = ssrEntrySolid(module, d.Component, d.Path)
|
||||
if reuse {
|
||||
if ce, ok := prev.Pages[d.Path]; ok && ce.EntryHash == pageEntryHash(entries[i], d.Dynamic) && inputsUnchanged(ce.Inputs, hasher) {
|
||||
bodies[i] = ce.HTML
|
||||
renderJSs[i] = ce.RenderJS
|
||||
cached[i] = true
|
||||
next.Pages[d.Path] = ce // carry the fingerprint forward
|
||||
continue
|
||||
}
|
||||
}
|
||||
misses = append(misses, renderJob{idx: i, path: d.Path, component: d.Component, entry: entries[i]})
|
||||
}
|
||||
|
||||
// Second pass (parallel): render the misses, then fingerprint their inputs.
|
||||
results, err := renderMisses(misses)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, r := range results {
|
||||
bodies[r.idx] = r.html
|
||||
// Bake the bundled render entry only for dynamic pages — the server
|
||||
// re-runs it with data at request time (ISR), so no esbuild or source
|
||||
// files are needed at runtime.
|
||||
js := ""
|
||||
if defs[r.idx].Dynamic {
|
||||
js = r.js
|
||||
}
|
||||
renderJSs[r.idx] = js
|
||||
next.Pages[r.path] = ssrCacheEntry{
|
||||
EntryHash: pageEntryHash(entries[r.idx], defs[r.idx].Dynamic),
|
||||
Inputs: hashInputs(r.inputs, hasher),
|
||||
HTML: r.html,
|
||||
RenderJS: js,
|
||||
}
|
||||
}
|
||||
|
||||
// Emit the registry in manifest order.
|
||||
var b strings.Builder
|
||||
b.WriteString("// Code generated by cmd/bundle; DO NOT EDIT.\n")
|
||||
b.WriteString("// Source: frontend/src/pages/public/pages.ts\n")
|
||||
b.WriteString("//\n")
|
||||
b.WriteString("// Each html field is the page's data-free SSR skeleton, rendered from its\n")
|
||||
b.WriteString("// Solid component at bundle time. The browser bundle re-renders it on load.\n\n")
|
||||
b.WriteString("package handlers\n\n")
|
||||
b.WriteString("var publicPages = []publicPage{\n")
|
||||
for i, d := range defs {
|
||||
fmt.Fprintf(&b, "\t{route: %q, title: %q, module: %q, component: %q, html: %q, renderJS: %q},\n", goRoute(d.Path), d.Title, d.Module, d.Component, bodies[i], renderJSs[i])
|
||||
status := "rendered"
|
||||
if cached[i] {
|
||||
status = "cached"
|
||||
}
|
||||
fmt.Printf(" %-20s %s (%s, %d bytes)\n", d.Path, d.Component, status, len(bodies[i]))
|
||||
}
|
||||
b.WriteString("}\n")
|
||||
|
||||
if err := os.WriteFile(filepath.Join("internal", "handlers", "public_pages.gen.go"), []byte(b.String()), 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
saveSSRCache(next)
|
||||
fmt.Printf(" SSR: %d rendered, %d cached in %s\n", len(misses), len(defs)-len(misses), time.Since(start).Round(time.Millisecond))
|
||||
return nil
|
||||
}
|
||||
|
||||
// pageEntryHash keys the render cache. It folds in the page's `dynamic` flag so
|
||||
// toggling ISR on/off re-renders the page (a dynamic page also bakes its render
|
||||
// JS, which an entry-only hash wouldn't notice changed).
|
||||
func pageEntryHash(entry string, dynamic bool) string {
|
||||
if dynamic {
|
||||
return hashString(entry + "\x00dynamic")
|
||||
}
|
||||
return hashString(entry)
|
||||
}
|
||||
|
||||
// ssrEntrySolid builds the goja entry for one page: import its body component,
|
||||
// wrap it in PublicLayout (currentPath = the page's path), render into a detached
|
||||
// DOM-shim root with solid-js/web's render, and serialize. Mirrors the client
|
||||
// takeover (public.tsx), which wraps the same body in the same layout — so the
|
||||
// server markup and post-takeover markup match.
|
||||
//
|
||||
// The entry is written as plain JS using createComponent (what compiled Solid JSX
|
||||
// would emit) rather than JSX, so it needs no transform; the imported .tsx page +
|
||||
// layout ARE Solid-compiled by the Go-native compiler (Plugin).
|
||||
//
|
||||
// esbuild's __ENV_TYPE__ define is not applied to this SSR build, so env.ts
|
||||
// yields "" and the EnvBadge in PublicLayout renders nothing during SSR; the
|
||||
// client takeover bundle carries the baked value on load.
|
||||
func ssrEntrySolid(module, component, currentPath string) string {
|
||||
return fmt.Sprintf("import { render, createComponent } from \"solid-js/web\";\n"+
|
||||
"import { PublicLayout } from \"./frontend/src/pages/public/PublicLayout.tsx\";\n"+
|
||||
"import { %[1]s } from \"./%[2]s\";\n"+
|
||||
"globalThis.__render = function () {\n"+
|
||||
"\tconst root = document.createElement(\"div\");\n"+
|
||||
"\tconst dispose = render(function () {\n"+
|
||||
"\t\treturn createComponent(PublicLayout, { currentPath: %[3]q, get children() { return createComponent(%[1]s, {}); } });\n"+
|
||||
"\t}, root);\n"+
|
||||
"\tconst out = globalThis.__serialize(root);\n"+
|
||||
"\tdispose();\n"+
|
||||
"\treturn out;\n"+
|
||||
"};", component, module, currentPath)
|
||||
}
|
||||
201
go/bundler/hmr_browser_test.go
Normal file
201
go/bundler/hmr_browser_test.go
Normal file
@@ -0,0 +1,201 @@
|
||||
//go:build dev
|
||||
|
||||
package bundler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// chromePath returns a headless-capable Chrome binary, or "" if none is found.
|
||||
func chromePath() string {
|
||||
candidates := []string{
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
||||
}
|
||||
for _, name := range []string{"google-chrome", "chromium", "chromium-browser", "google-chrome-stable"} {
|
||||
if p, err := exec.LookPath(name); err == nil {
|
||||
candidates = append(candidates, p)
|
||||
}
|
||||
}
|
||||
for _, c := range candidates {
|
||||
if _, err := os.Stat(c); err == nil {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// TestBrowserNativeESMRenders proves the whole native-ESM path works in a real
|
||||
// browser: the import map resolves solid-js to one vendored instance, the module
|
||||
// server transforms + serves the entry and a .tsx component (solid-refresh
|
||||
// compiled), and Solid mounts it reactively. The page reports its rendered text
|
||||
// back to the test via a beacon (robust against headless Chrome's one-shot exit
|
||||
// quirks). It does NOT assert the interactive hot-swap — that needs a persistent
|
||||
// CDP session and is the final manual check — but removes the largest
|
||||
// browser-integration risk.
|
||||
func TestBrowserNativeESMRenders(t *testing.T) {
|
||||
chrome := chromePath()
|
||||
if chrome == "" {
|
||||
t.Skip("no headless Chrome/Chromium available")
|
||||
}
|
||||
if runtime.GOOS != "darwin" && runtime.GOOS != "linux" {
|
||||
t.Skip("browser smoke test only wired for macOS/Linux")
|
||||
}
|
||||
|
||||
root, err := filepath.Abs("../..")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Chdir(root)
|
||||
frontendAbs, _ := filepath.Abs(frontendDir)
|
||||
|
||||
// Fixtures in a temp src root so the real source tree (and Tailwind scan /
|
||||
// route gen) is untouched. The component uses JSX (→ solid-refresh) and a
|
||||
// signal (→ real reactivity); the entry imports it via a `.js` specifier
|
||||
// (→ .tsx resolution), then beacons back the rendered text.
|
||||
tmpSrc := evalSymlinks(t.TempDir())
|
||||
writeFile(t, filepath.Join(tmpSrc, "Widget.tsx"),
|
||||
`import { createSignal } from "solid-js";
|
||||
export default function Widget() {
|
||||
const [msg] = createSignal("hello-hmr-rendered");
|
||||
return <div id="w">{msg()}</div>;
|
||||
}
|
||||
`)
|
||||
writeFile(t, filepath.Join(tmpSrc, "entry.tsx"),
|
||||
`import { render } from "solid-js/web";
|
||||
import Widget from "./Widget.js";
|
||||
render(() => <Widget/>, document.getElementById("app"));
|
||||
setTimeout(() => {
|
||||
const el = document.getElementById("w");
|
||||
fetch("/__result?text=" + encodeURIComponent(el ? el.textContent : "EMPTY"));
|
||||
}, 0);
|
||||
`)
|
||||
|
||||
eps, err := loadVendorManifest(filepath.Join(frontendAbs, "vendor"))
|
||||
if err != nil {
|
||||
t.Fatalf("vendor manifest: %v", err)
|
||||
}
|
||||
d := &devServer{
|
||||
hub: newHub(),
|
||||
frontend: frontendAbs,
|
||||
srcRoot: tmpSrc,
|
||||
vendor: filepath.Join(frontendAbs, "vendor"),
|
||||
graph: newModuleGraph(),
|
||||
vendorCache: map[string][]byte{},
|
||||
cssTrigger: make(chan struct{}, 1),
|
||||
vendorEntrypoints: eps,
|
||||
}
|
||||
d.importMapJSON = d.buildImportMap()
|
||||
|
||||
rendered := make(chan string, 1)
|
||||
var diagMu sync.Mutex
|
||||
var diag []string
|
||||
mux := http.NewServeMux()
|
||||
d.register(mux)
|
||||
mux.HandleFunc("/__result", func(w http.ResponseWriter, r *http.Request) {
|
||||
select {
|
||||
case rendered <- r.URL.Query().Get("text"):
|
||||
default:
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
mux.HandleFunc("/__diag", func(w http.ResponseWriter, r *http.Request) {
|
||||
diagMu.Lock()
|
||||
diag = append(diag, r.URL.Query().Get("text"))
|
||||
diagMu.Unlock()
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprintf(w, `<!DOCTYPE html><html><head>
|
||||
<script>
|
||||
function beacon(p,t){fetch(p+'?text='+encodeURIComponent(t));}
|
||||
window.addEventListener('error', function(e){beacon('/__diag','ERR:'+((e.error&&e.error.stack)||e.message));});
|
||||
window.addEventListener('unhandledrejection', function(e){beacon('/__diag','REJ:'+((e.reason&&e.reason.stack)||String(e.reason)));});
|
||||
window.addEventListener('DOMContentLoaded', function(){beacon('/__diag','DOMCONTENTLOADED');});
|
||||
</script>
|
||||
<script type="importmap">%s</script>
|
||||
</head><body><div id="app"></div>
|
||||
<script type="module" src="/@src/entry.tsx"></script>
|
||||
</body></html>`, d.importMapJSON)
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
// Sanity-check that every module the page needs serves (200) before the browser.
|
||||
for _, m := range []string{"/@src/entry.tsx", "/@src/Widget.tsx", "/@hmr/client",
|
||||
vendorURLPrefix + "solid-js.js", vendorURLPrefix + "solid-js/web.js"} {
|
||||
resp, err := http.Get(srv.URL + m)
|
||||
if err != nil {
|
||||
t.Fatalf("GET %s: %v", m, err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("GET %s -> %d", m, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Launch headless Chrome to load the page; wait for the render beacon rather
|
||||
// than for Chrome to exit (new headless doesn't reliably one-shot-exit here).
|
||||
// Use a best-effort temp profile dir (not t.TempDir): Chrome's detached helper
|
||||
// processes may still be writing to it at cleanup time, and t.TempDir's strict
|
||||
// RemoveAll would then fail the test.
|
||||
userDir, _ := os.MkdirTemp("", "hmr-chrome-*")
|
||||
defer os.RemoveAll(userDir)
|
||||
devNull, _ := os.Open(os.DevNull)
|
||||
defer devNull.Close()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cmd := exec.CommandContext(ctx, chrome,
|
||||
"--headless=new", "--disable-gpu", "--no-sandbox", "--no-first-run",
|
||||
"--user-data-dir="+userDir,
|
||||
"--disable-background-networking", "--disable-component-update",
|
||||
"--disable-default-apps", "--disable-sync", "--no-default-browser-check",
|
||||
srv.URL,
|
||||
)
|
||||
cmd.Stdout = devNull
|
||||
cmd.Stderr = devNull
|
||||
if err := cmd.Start(); err != nil {
|
||||
cancel()
|
||||
t.Fatalf("start chrome: %v", err)
|
||||
}
|
||||
defer func() { cancel(); cmd.Wait() }()
|
||||
|
||||
select {
|
||||
case text := <-rendered:
|
||||
if text != "hello-hmr-rendered" {
|
||||
t.Fatalf("browser rendered %q, want %q", text, "hello-hmr-rendered")
|
||||
}
|
||||
t.Log("native-ESM app rendered in headless Chrome (import map + single solid-js + solid-refresh component)")
|
||||
case <-time.After(25 * time.Second):
|
||||
diagMu.Lock()
|
||||
msgs := strings.Join(diag, "\n ")
|
||||
diagMu.Unlock()
|
||||
t.Fatalf("timed out waiting for the browser render beacon.\nbrowser diagnostics:\n %s", msgs)
|
||||
}
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
279
go/bundler/hmr_client.go
Normal file
279
go/bundler/hmr_client.go
Normal file
@@ -0,0 +1,279 @@
|
||||
//go:build dev
|
||||
|
||||
package bundler
|
||||
|
||||
import "net/http"
|
||||
|
||||
// hmrUpdate names one boundary module to re-import at a given version.
|
||||
type hmrUpdate struct {
|
||||
Path string `json:"path"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
// hmrError describes a compile/transform failure surfaced to the browser as a
|
||||
// full-screen error overlay (Vite-style). Message is the full formatted error
|
||||
// text (esbuild carries a code frame; the Solid compiler a plain message).
|
||||
type hmrError struct {
|
||||
Message string `json:"message"`
|
||||
File string `json:"file,omitempty"`
|
||||
}
|
||||
|
||||
// hmrMessage is the WebSocket payload pushed to the browser.
|
||||
type hmrMessage struct {
|
||||
Type string `json:"type"` // "update" | "full-reload" | "css-update" | "error"
|
||||
Updates []hmrUpdate `json:"updates,omitempty"`
|
||||
Path string `json:"path,omitempty"` // css-update: the stylesheet path
|
||||
Err *hmrError `json:"err,omitempty"` // error: the compile failure to display
|
||||
}
|
||||
|
||||
// serveClient serves the HMR client runtime as an ES module.
|
||||
func (d *devServer) serveClient(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Write([]byte(hmrClientJS))
|
||||
}
|
||||
|
||||
// hmrClientJS is the browser runtime: it owns the WebSocket, exposes
|
||||
// createHotContext (the import.meta.hot the transformed modules bind), and
|
||||
// applies updates. The accept/dispose/data protocol mirrors Vite's so
|
||||
// solid-refresh's `esm` path (hot.data + hot.accept(cb) + hot.invalidate) works
|
||||
// unchanged — a changed boundary is re-imported, and the PREVIOUS instance's
|
||||
// accept callback runs with the new module namespace, patching the live registry.
|
||||
//
|
||||
// It also exports showErrorOverlay/clearErrorOverlay and renders a Vite-style
|
||||
// full-screen compile-error overlay. A module that fails to transform is served
|
||||
// as a tiny stub that imports showErrorOverlay and calls it (see errorModule in
|
||||
// hmr_server.go), so the overlay pops the instant a broken module is imported —
|
||||
// on first load or on a hot re-import. The overlay auto-clears once a hot-update
|
||||
// batch completes without any module surfacing an error (see applyUpdates).
|
||||
const hmrClientJS = `
|
||||
// --- module-level HMR state, keyed by base module URL --------------------------
|
||||
const hotModulesMap = new Map(); // id -> { id, callbacks: [{fn}] }
|
||||
const dataMap = new Map(); // id -> persistent data object (survives reloads)
|
||||
const disposeMap = new Map(); // id -> dispose callback
|
||||
const declined = new Set(); // ids that opted out of HMR
|
||||
|
||||
export function createHotContext(id) {
|
||||
if (!dataMap.has(id)) dataMap.set(id, {});
|
||||
const existing = hotModulesMap.get(id);
|
||||
// A fresh instance of this module is registering; clear its accept callbacks
|
||||
// (applyUpdate has already snapshotted the previous instance's).
|
||||
if (existing) existing.callbacks = [];
|
||||
|
||||
function pushAccept(fn) {
|
||||
let mod = hotModulesMap.get(id);
|
||||
if (!mod) { mod = { id, callbacks: [] }; hotModulesMap.set(id, mod); }
|
||||
mod.callbacks.push({ fn });
|
||||
}
|
||||
|
||||
return {
|
||||
get data() { return dataMap.get(id); },
|
||||
accept(deps, cb) {
|
||||
// accept() | accept(fn) | accept(deps, fn) — self-accept in every form we use.
|
||||
if (typeof deps === 'function' || deps == null) pushAccept(deps);
|
||||
else pushAccept(cb);
|
||||
},
|
||||
dispose(cb) { disposeMap.set(id, cb); },
|
||||
prune(cb) { disposeMap.set(id, cb); },
|
||||
invalidate() { fullReload(); },
|
||||
decline() { declined.add(id); },
|
||||
on() {}, off() {}, send() {},
|
||||
};
|
||||
}
|
||||
|
||||
// --- recompile indicator -------------------------------------------------------
|
||||
// A small, non-blocking badge so a recompile doesn't look like a frozen page:
|
||||
// shown the moment an update arrives and hidden once the new module is imported
|
||||
// and applied. The
|
||||
// await below yields the event loop, so the spinner paints and animates while the
|
||||
// server compiles.
|
||||
let hmrBusy = 0;
|
||||
let hmrEl = null;
|
||||
function hmrIndicator() {
|
||||
if (hmrEl || typeof document === 'undefined') return hmrEl;
|
||||
const head = document.head || document.documentElement;
|
||||
const style = document.createElement('style');
|
||||
style.textContent = '@keyframes hmr-spin{to{transform:rotate(360deg)}}';
|
||||
head.appendChild(style);
|
||||
hmrEl = document.createElement('div');
|
||||
hmrEl.setAttribute('style',
|
||||
'position:fixed;bottom:14px;right:14px;z-index:2147483647;display:none;' +
|
||||
'align-items:center;gap:8px;padding:7px 12px;border-radius:9px;' +
|
||||
'font:600 12px/1 ui-monospace,SFMono-Regular,Menlo,monospace;color:#e5e7eb;' +
|
||||
'background:rgba(17,24,39,.92);box-shadow:0 6px 18px rgba(0,0,0,.35);' +
|
||||
'pointer-events:none;user-select:none');
|
||||
hmrEl.innerHTML =
|
||||
'<span style="width:11px;height:11px;border-radius:50%;display:inline-block;' +
|
||||
'border:2px solid rgba(148,163,184,.4);border-top-color:#60a5fa;' +
|
||||
'animation:hmr-spin .6s linear infinite"></span><span data-hmr-label></span>';
|
||||
(document.body || document.documentElement).appendChild(hmrEl);
|
||||
return hmrEl;
|
||||
}
|
||||
function hmrShow(label) {
|
||||
const el = hmrIndicator();
|
||||
if (!el) return;
|
||||
const l = el.querySelector('[data-hmr-label]');
|
||||
if (l) l.textContent = label || 'recompiling…';
|
||||
el.style.display = 'flex';
|
||||
}
|
||||
function hmrBusyStart() { hmrBusy++; hmrShow('recompiling…'); }
|
||||
function hmrBusyEnd() { hmrBusy = Math.max(0, hmrBusy - 1); if (hmrBusy === 0 && hmrEl) hmrEl.style.display = 'none'; }
|
||||
|
||||
// --- compile-error overlay -----------------------------------------------------
|
||||
// A Vite-style full-screen overlay for compile/transform failures. errorEpoch is
|
||||
// bumped every time an error surfaces; applyUpdates snapshots it around a hot
|
||||
// batch and clears the overlay only if the batch introduced no new error, so a
|
||||
// fixed file dismisses the overlay automatically. The overlay lives in a shadow
|
||||
// root so the app's stylesheet (Tailwind reset et al.) can't restyle it.
|
||||
let overlayEl = null;
|
||||
let errorEpoch = 0;
|
||||
const HMR_OVERLAY_ID = '__hmr-error-overlay';
|
||||
|
||||
function escapeHTML(s) {
|
||||
return String(s).replace(/[&<>]/g, function (c) {
|
||||
return c === '&' ? '&' : c === '<' ? '<' : '>';
|
||||
});
|
||||
}
|
||||
|
||||
function overlayHTML(message, file) {
|
||||
const css =
|
||||
':host{all:initial}' +
|
||||
'.backdrop{position:fixed;inset:0;z-index:2147483647;background:rgba(0,0,0,.66);' +
|
||||
'display:flex;align-items:flex-start;justify-content:center;overflow:auto;padding:6vh 20px;' +
|
||||
'font:14px/1.55 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}' +
|
||||
'.panel{width:100%;max-width:min(1000px,92vw);margin:auto 0;background:#1b1b1f;color:#e6e6e6;' +
|
||||
'border:1px solid #ff5555;border-radius:10px;box-shadow:0 20px 60px rgba(0,0,0,.5);overflow:hidden}' +
|
||||
'.head{display:flex;align-items:center;gap:10px;padding:12px 14px;background:#2a1416;' +
|
||||
'border-bottom:1px solid rgba(255,85,85,.35)}' +
|
||||
'.badge{color:#ff6b6b;font-weight:700;letter-spacing:.03em;text-transform:uppercase;font-size:12px}' +
|
||||
'.file{color:#9aa0a6;font-size:12.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}' +
|
||||
'.close{margin-left:auto;background:transparent;border:0;color:#9aa0a6;cursor:pointer;' +
|
||||
'font-size:16px;line-height:1;padding:4px 7px;border-radius:6px}' +
|
||||
'.close:hover{color:#fff;background:rgba(255,255,255,.08)}' +
|
||||
'.body{margin:0;padding:16px;white-space:pre-wrap;word-break:break-word;color:#ffb4b4;' +
|
||||
'font-size:13px;max-height:62vh;overflow:auto}' +
|
||||
'.hint{padding:10px 14px;border-top:1px solid rgba(255,255,255,.06);color:#7c828a;font-size:12px}';
|
||||
return '<style>' + css + '</style>' +
|
||||
'<div class="backdrop">' +
|
||||
'<div class="panel">' +
|
||||
'<div class="head">' +
|
||||
'<span class="badge">Compile Error</span>' +
|
||||
(file ? '<span class="file">' + escapeHTML(file) + '</span>' : '') +
|
||||
'<button class="close" title="Dismiss (Esc)">✕</button>' +
|
||||
'</div>' +
|
||||
'<pre class="body">' + escapeHTML(message) + '</pre>' +
|
||||
'<div class="hint">Fix the error and save — this overlay clears automatically.</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
export function showErrorOverlay(err) {
|
||||
errorEpoch++;
|
||||
if (typeof document === 'undefined') return;
|
||||
const message = (err && (err.message || err.msg)) || String(err || 'Unknown error');
|
||||
const file = (err && err.file) || '';
|
||||
console.error('[hmr] compile error' + (file ? ' in ' + file : '') + '\n' + message);
|
||||
clearErrorOverlay();
|
||||
const host = document.createElement('div');
|
||||
host.id = HMR_OVERLAY_ID;
|
||||
const root = host.attachShadow ? host.attachShadow({ mode: 'open' }) : host;
|
||||
root.innerHTML = overlayHTML(message, file);
|
||||
const closeBtn = root.querySelector('.close');
|
||||
if (closeBtn) closeBtn.addEventListener('click', clearErrorOverlay);
|
||||
(document.body || document.documentElement).appendChild(host);
|
||||
overlayEl = host;
|
||||
}
|
||||
|
||||
export function clearErrorOverlay() {
|
||||
if (overlayEl && overlayEl.parentNode) overlayEl.parentNode.removeChild(overlayEl);
|
||||
overlayEl = null;
|
||||
}
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape' && overlayEl) clearErrorOverlay();
|
||||
});
|
||||
}
|
||||
|
||||
async function applyUpdate(update) {
|
||||
const id = update.path;
|
||||
hmrBusyStart();
|
||||
try {
|
||||
if (declined.has(id)) return fullReload();
|
||||
const mod = hotModulesMap.get(id);
|
||||
if (!mod) return fullReload(); // module not tracked yet — reload to be safe
|
||||
|
||||
const callbacks = mod.callbacks; // the live instance's accept callbacks
|
||||
const disposer = disposeMap.get(id);
|
||||
if (disposer) { try { await disposer(dataMap.get(id)); } catch (e) { console.error(e); } }
|
||||
|
||||
let newMod;
|
||||
try {
|
||||
newMod = await import(id + (id.includes('?') ? '&' : '?') + 't=' + update.timestamp);
|
||||
} catch (e) {
|
||||
console.error('[hmr] failed to re-import', id, e);
|
||||
return fullReload();
|
||||
}
|
||||
for (const cb of callbacks) {
|
||||
if (cb.fn) { try { cb.fn(newMod); } catch (e) { console.error(e); } }
|
||||
}
|
||||
console.log('[hmr] updated', id);
|
||||
} finally {
|
||||
hmrBusyEnd();
|
||||
}
|
||||
}
|
||||
|
||||
function updateCSS(path) {
|
||||
const links = document.querySelectorAll('link[rel="stylesheet"]');
|
||||
for (const link of links) {
|
||||
const url = new URL(link.href, location.href);
|
||||
if (url.pathname === path) {
|
||||
const next = link.cloneNode();
|
||||
next.href = url.pathname + '?t=' + Date.now();
|
||||
next.onload = () => link.remove();
|
||||
link.after(next);
|
||||
console.log('[hmr] css updated', path);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// The stylesheet isn't linked on this page — the dev server broadcasts CSS
|
||||
// updates for both the SPA (/bundle.min.css) and public (/public.bundle.min.css)
|
||||
// bundles to every client, but each page carries only one. An update for the
|
||||
// other bundle is simply not applicable here, so ignore it. (Forcing a full
|
||||
// reload instead would defeat the .tsx component HMR that ran moments earlier.)
|
||||
}
|
||||
|
||||
function fullReload() { hmrShow('reloading…'); location.reload(); }
|
||||
|
||||
// applyUpdates runs a hot-update batch, then clears the error overlay iff no
|
||||
// module surfaced a compile error while importing (errorEpoch unchanged). Module
|
||||
// evaluation is synchronous within an import(), so a broken module's
|
||||
// showErrorOverlay() has already run by the time its applyUpdate resolves — the
|
||||
// check is race-free.
|
||||
async function applyUpdates(updates) {
|
||||
const before = errorEpoch;
|
||||
for (const u of updates) { await applyUpdate(u); }
|
||||
if (errorEpoch === before) clearErrorOverlay();
|
||||
}
|
||||
|
||||
function handle(raw) {
|
||||
let msg;
|
||||
try { msg = JSON.parse(raw); } catch { return; }
|
||||
switch (msg.type) {
|
||||
case 'update': applyUpdates(msg.updates || []); break;
|
||||
case 'css-update': updateCSS(msg.path); break;
|
||||
case 'full-reload': fullReload(); break;
|
||||
case 'error': showErrorOverlay(msg.err || {}); break;
|
||||
}
|
||||
}
|
||||
|
||||
function connect() {
|
||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const ws = new WebSocket(proto + '://' + location.host + '/@hmr/ws');
|
||||
ws.addEventListener('message', (e) => handle(e.data));
|
||||
ws.addEventListener('open', () => console.log('[hmr] connected'));
|
||||
ws.addEventListener('close', () => { console.log('[hmr] connection lost, retrying...'); setTimeout(connect, 1000); });
|
||||
ws.addEventListener('error', () => ws.close());
|
||||
}
|
||||
connect();
|
||||
`
|
||||
32
go/bundler/hmr_client_test.go
Normal file
32
go/bundler/hmr_client_test.go
Normal file
@@ -0,0 +1,32 @@
|
||||
//go:build dev
|
||||
|
||||
package bundler
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
esbuild "github.com/evanw/esbuild/pkg/api"
|
||||
)
|
||||
|
||||
// hmrClientJS is a hand-written JS blob served to the browser; a syntax slip in it
|
||||
// silently breaks every hot update, and the headless-browser smoke test is skipped
|
||||
// off Linux/macOS. Parse it as an ES module here so a bad edit fails at test time,
|
||||
// and assert the recompile-indicator hooks are wired.
|
||||
func TestHMRClientJSValid(t *testing.T) {
|
||||
res := esbuild.Transform(hmrClientJS, esbuild.TransformOptions{
|
||||
Loader: esbuild.LoaderJS,
|
||||
Format: esbuild.FormatESModule,
|
||||
LogLevel: esbuild.LogLevelSilent,
|
||||
})
|
||||
if len(res.Errors) > 0 {
|
||||
msgs := esbuild.FormatMessages(res.Errors, esbuild.FormatMessagesOptions{})
|
||||
t.Fatalf("HMR client JS failed to parse:\n%s", strings.Join(msgs, "\n"))
|
||||
}
|
||||
for _, want := range []string{"createHotContext", "hmrBusyStart", "hmrBusyEnd", "recompiling",
|
||||
"showErrorOverlay", "clearErrorOverlay", "errorEpoch"} {
|
||||
if !strings.Contains(hmrClientJS, want) {
|
||||
t.Errorf("HMR client missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
188
go/bundler/hmr_e2e_test.go
Normal file
188
go/bundler/hmr_e2e_test.go
Normal file
@@ -0,0 +1,188 @@
|
||||
//go:build dev
|
||||
|
||||
package bundler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A `?url` import must resolve to a shim module whose default export is the raw
|
||||
// /@fs/ file URL — not the file itself (which would fail with "no default export",
|
||||
// as the pdfjs worker did and cascaded into a stuck loading spinner).
|
||||
func TestDevServerAssetURL(t *testing.T) {
|
||||
root, err := filepath.Abs("../..")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Chdir(root)
|
||||
frontendAbs, _ := filepath.Abs(frontendDir)
|
||||
|
||||
tmpSrc := evalSymlinks(t.TempDir())
|
||||
os.WriteFile(filepath.Join(tmpSrc, "uses-worker.tsx"),
|
||||
[]byte(`import u from "pdfjs-dist/build/pdf.worker.min.mjs?url";
|
||||
export const url = u;`), 0o644)
|
||||
|
||||
eps, _ := loadVendorManifest(filepath.Join(frontendAbs, "vendor"))
|
||||
d := &devServer{hub: newHub(), frontend: frontendAbs, srcRoot: tmpSrc,
|
||||
vendor: filepath.Join(frontendAbs, "vendor"), graph: newModuleGraph(),
|
||||
vendorCache: map[string][]byte{}, cssTrigger: make(chan struct{}, 1), vendorEntrypoints: eps}
|
||||
|
||||
out, err := d.transformModule(filepath.Join(tmpSrc, "uses-worker.tsx"))
|
||||
if err != nil {
|
||||
t.Fatalf("transform: %v", err)
|
||||
}
|
||||
const shimURL = "/@url/vendor/pdfjs-dist/build/pdf.worker.min.mjs"
|
||||
if !strings.Contains(string(out), shimURL) {
|
||||
t.Fatalf("?url import not rewritten to the shim path %q:\n%s", shimURL, out)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", shimURL, nil)
|
||||
w := httptest.NewRecorder()
|
||||
d.serveAssetURL(w, req)
|
||||
body := w.Body.String()
|
||||
const want = `export default "/@fs/vendor/pdfjs-dist/build/pdf.worker.min.mjs"`
|
||||
if w.Code != 200 || !strings.Contains(body, want) {
|
||||
t.Fatalf("shim module = %q (status %d), want default export of the /@fs/ URL", body, w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// resolveSource must mirror esbuild's resolution: a `.js` specifier for a sibling
|
||||
// .ts/.tsx, extensionless specifiers, and directory index files.
|
||||
func TestResolveSourceExtensions(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
write := func(rel string) {
|
||||
p := filepath.Join(dir, rel)
|
||||
os.MkdirAll(filepath.Dir(p), 0o755)
|
||||
os.WriteFile(p, []byte("export default 1;"), 0o644)
|
||||
}
|
||||
write("Foo.tsx")
|
||||
write("Bar.ts")
|
||||
write("baz/index.ts")
|
||||
write("Real.js")
|
||||
|
||||
cases := []struct{ spec, want string }{
|
||||
{"./Foo.js", "Foo.tsx"}, // .js specifier -> sibling .tsx
|
||||
{"./Foo.tsx", "Foo.tsx"}, // exact
|
||||
{"./Bar", "Bar.ts"}, // extensionless
|
||||
{"./baz", "baz/index.ts"}, // directory index
|
||||
{"./Real.js", "Real.js"}, // real .js wins
|
||||
{"./missing.js", ""}, // dangling
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := resolveSource(dir, c.spec)
|
||||
want := ""
|
||||
if c.want != "" {
|
||||
want = filepath.Join(dir, filepath.FromSlash(c.want))
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("resolveSource(%q) = %q, want %q", c.spec, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// End-to-end over the dev server's HTTP surface: the SPA entry, a .tsx component,
|
||||
// a vendor bundle, the client runtime, the import map, and graph-driven update vs.
|
||||
// full-reload decisions — all without a database or the full server.
|
||||
func TestDevServerEndToEnd(t *testing.T) {
|
||||
root, err := filepath.Abs("../..")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Chdir(root)
|
||||
|
||||
d, err := newDevServer()
|
||||
if err != nil {
|
||||
t.Fatalf("newDevServer: %v", err)
|
||||
}
|
||||
|
||||
get := func(path string) (int, string) {
|
||||
req := httptest.NewRequest("GET", path, nil)
|
||||
w := httptest.NewRecorder()
|
||||
switch {
|
||||
case strings.HasPrefix(path, srcURLPrefix):
|
||||
d.serveModule(w, req)
|
||||
case strings.HasPrefix(path, vendorURLPrefix):
|
||||
d.serveVendor(w, req)
|
||||
case path == "/@hmr/client":
|
||||
d.serveClient(w, req)
|
||||
}
|
||||
return w.Code, w.Body.String()
|
||||
}
|
||||
|
||||
// --- SPA entry: hot bootstrap + bare imports kept + relative imports rewritten
|
||||
code, app := get(srcURLPrefix + "app.ts")
|
||||
if code != 200 {
|
||||
t.Fatalf("app.ts status %d:\n%s", code, app)
|
||||
}
|
||||
for _, want := range []string{
|
||||
`__createHotContext("/@src/app.ts")`, // hot bootstrap
|
||||
`/@hmr/client`, // client import injected
|
||||
`"solid-js/web"`, // bare specifier preserved for import map
|
||||
`"@solidjs/router"`, // bare specifier preserved
|
||||
`/@src/routes/app-routes.ts`, // relative import rewritten
|
||||
`/@src/layouts/AppLayout.ts`, // relative import rewritten
|
||||
} {
|
||||
if !strings.Contains(app, want) {
|
||||
t.Errorf("app.ts missing %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
// --- a .tsx component: Solid + solid-refresh instrumentation
|
||||
code, alerts := get(srcURLPrefix + "ui/Alerts.tsx")
|
||||
if code != 200 {
|
||||
t.Fatalf("Alerts.tsx status %d:\n%s", code, alerts)
|
||||
}
|
||||
for _, want := range []string{
|
||||
`__createHotContext("/@src/ui/Alerts.tsx")`,
|
||||
`solid-refresh`, // runtime import
|
||||
"import.meta.hot", // esm HMR accept
|
||||
} {
|
||||
if !strings.Contains(alerts, want) {
|
||||
t.Errorf("Alerts.tsx missing %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
// --- vendor: solid-js/web must import "solid-js" externally (single instance)
|
||||
code, web := get(vendorURLPrefix + "solid-js/web.js")
|
||||
if code != 200 {
|
||||
t.Fatalf("vendor solid-js/web status %d", code)
|
||||
}
|
||||
if !strings.Contains(web, `"solid-js"`) {
|
||||
t.Errorf("solid-js/web should keep a bare `solid-js` import (shared instance)")
|
||||
}
|
||||
|
||||
// --- client runtime is an ES module exporting createHotContext
|
||||
code, client := get("/@hmr/client")
|
||||
if code != 200 || !strings.Contains(client, "export function createHotContext") {
|
||||
t.Errorf("client runtime missing createHotContext (status %d)", code)
|
||||
}
|
||||
|
||||
// --- import map maps the key vendored specifiers to /@vendor/ URLs
|
||||
var im struct {
|
||||
Imports map[string]string `json:"imports"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(d.importMapJSON), &im); err != nil {
|
||||
t.Fatalf("import map JSON: %v", err)
|
||||
}
|
||||
for _, spec := range []string{"solid-js", "solid-js/web", "@solidjs/router", "solid-refresh"} {
|
||||
if got := im.Imports[spec]; got != vendorURLPrefix+spec+".js" {
|
||||
t.Errorf("import map[%q] = %q, want %q", spec, got, vendorURLPrefix+spec+".js")
|
||||
}
|
||||
}
|
||||
|
||||
// --- graph: editing a routes table (non-boundary, reachable from the entry)
|
||||
// forces a full reload; editing a .tsx component is a hot update.
|
||||
routes := filepath.Join(d.srcRoot, "routes", "app-routes.ts")
|
||||
if _, _, reload := d.graph.invalidate(routes); !reload {
|
||||
t.Errorf("changing app-routes.ts should force a full reload")
|
||||
}
|
||||
alertsPath := filepath.Join(d.srcRoot, "ui", "Alerts.tsx")
|
||||
if boundaries, _, reload := d.graph.invalidate(alertsPath); reload || len(boundaries) == 0 {
|
||||
t.Errorf("changing Alerts.tsx should be a hot update, got reload=%v boundaries=%v", reload, boundaries)
|
||||
}
|
||||
}
|
||||
78
go/bundler/hmr_error_test.go
Normal file
78
go/bundler/hmr_error_test.go
Normal file
@@ -0,0 +1,78 @@
|
||||
//go:build dev
|
||||
|
||||
package bundler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
esbuild "github.com/evanw/esbuild/pkg/api"
|
||||
)
|
||||
|
||||
// errorModule must emit a valid ES module that imports the client's
|
||||
// showErrorOverlay and calls it with the error text + file, so a failed
|
||||
// transform pops the compile-error overlay instead of just logging.
|
||||
func TestErrorModuleShape(t *testing.T) {
|
||||
src := errorModule("ui/Broken.tsx", errors.New("Unexpected \"}\" at line 3"))
|
||||
|
||||
for _, want := range []string{
|
||||
`from "/@hmr/client"`,
|
||||
"showErrorOverlay",
|
||||
"__hmrShowError(",
|
||||
"ui/Broken.tsx",
|
||||
`Unexpected`,
|
||||
} {
|
||||
if !strings.Contains(src, want) {
|
||||
t.Errorf("errorModule() missing %q:\n%s", want, src)
|
||||
}
|
||||
}
|
||||
|
||||
// It must parse as an ES module (the payload is embedded JSON-as-JS).
|
||||
res := esbuild.Transform(src, esbuild.TransformOptions{
|
||||
Loader: esbuild.LoaderJS, Format: esbuild.FormatESModule, LogLevel: esbuild.LogLevelSilent,
|
||||
})
|
||||
if len(res.Errors) > 0 {
|
||||
msgs := esbuild.FormatMessages(res.Errors, esbuild.FormatMessagesOptions{})
|
||||
t.Fatalf("errorModule() is not valid JS:\n%s\n--- source ---\n%s", strings.Join(msgs, "\n"), src)
|
||||
}
|
||||
}
|
||||
|
||||
// A source file that fails to compile must be served as the overlay stub (HTTP
|
||||
// 200, not a 500 that would tear the module graph), so the browser shows the
|
||||
// error and the next save can recover.
|
||||
func TestServeModuleCompileErrorServesOverlay(t *testing.T) {
|
||||
root, err := filepath.Abs("../..")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Chdir(root)
|
||||
frontendAbs, _ := filepath.Abs(frontendDir)
|
||||
|
||||
tmpSrc := evalSymlinks(t.TempDir())
|
||||
// Deliberately broken JSX: an unclosed tag the compiler/esbuild must reject.
|
||||
os.WriteFile(filepath.Join(tmpSrc, "Broken.tsx"),
|
||||
[]byte("export default function Broken() {\n return <div><span></div>;\n}\n"), 0o644)
|
||||
|
||||
eps, _ := loadVendorManifest(filepath.Join(frontendAbs, "vendor"))
|
||||
d := &devServer{hub: newHub(), frontend: frontendAbs, srcRoot: tmpSrc,
|
||||
vendor: filepath.Join(frontendAbs, "vendor"), graph: newModuleGraph(),
|
||||
vendorCache: map[string][]byte{}, cssTrigger: make(chan struct{}, 1), vendorEntrypoints: eps}
|
||||
|
||||
req := httptest.NewRequest("GET", srcURLPrefix+"Broken.tsx", nil)
|
||||
w := httptest.NewRecorder()
|
||||
d.serveModule(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("broken module served status %d, want 200 (overlay stub)", w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
for _, want := range []string{"showErrorOverlay", `from "/@hmr/client"`, "Broken.tsx"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("overlay stub missing %q:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
497
go/bundler/hmr_server.go
Normal file
497
go/bundler/hmr_server.go
Normal file
@@ -0,0 +1,497 @@
|
||||
//go:build dev
|
||||
|
||||
package bundler
|
||||
|
||||
// The development HMR server: it serves the SPA source tree as unbundled native
|
||||
// ES modules (transformed on the fly), tracks the module import graph, watches
|
||||
// the filesystem, and pushes hot-update / reload messages to the browser over a
|
||||
// WebSocket. Editing a .tsx component swaps it in place (solid-refresh); editing
|
||||
// a non-boundary module bubbles up to a full page reload.
|
||||
//
|
||||
// This is the reason internal/bundler grew a `dev` build tag: the whole HMR
|
||||
// subsystem (this file, hmr_ws.go, hmr_vendor.go, hmr_client.go, hmr_watch.go,
|
||||
// and the solid-refresh bits of jsx.go) compiles only under `-tags dev`, so the
|
||||
// production server and the plain `cmd/bundle` build carry none of it.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
esbuild "github.com/evanw/esbuild/pkg/api"
|
||||
)
|
||||
|
||||
// devURLPrefix roots the served source tree: GET /@src/<rel> serves the
|
||||
// transformed module frontend/src/<rel>.
|
||||
const (
|
||||
srcURLPrefix = "/@src/" // transformed source modules
|
||||
assetURLPrefix = "/@url/" // `?url` shim: a module whose default export is the file URL
|
||||
fsURLPrefix = "/@fs/" // raw file passthrough (the URL the shim points at)
|
||||
)
|
||||
|
||||
type devServer struct {
|
||||
hub *hub
|
||||
frontend string // abs frontend/
|
||||
srcRoot string // abs frontend/src
|
||||
vendor string // abs frontend/vendor
|
||||
output string // abs wwwroot/
|
||||
|
||||
graph *moduleGraph
|
||||
|
||||
importMapJSON string
|
||||
vendorEntrypoints map[string]string
|
||||
|
||||
vendorMu sync.Mutex
|
||||
vendorCache map[string][]byte // /@vendor/<spec>.js -> bundled bytes
|
||||
|
||||
cssTrigger chan struct{} // coalesced CSS rebuild requests (buffered, size 1)
|
||||
}
|
||||
|
||||
// StartDevHMR registers the dev routes on mux, launches the filesystem watcher,
|
||||
// and returns the import map (JSON object body) the SPA shell must inline so bare
|
||||
// specifiers resolve to the single vendored copies. Called only from cmd/server's
|
||||
// `dev`-tagged shim.
|
||||
func StartDevHMR(mux *http.ServeMux) (importMap string, err error) {
|
||||
d, err := newDevServer()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
d.register(mux)
|
||||
go d.watch()
|
||||
|
||||
fmt.Println("HMR dev server: serving native-ESM source from /@src/, WebSocket at /@hmr/ws")
|
||||
return d.importMapJSON, nil
|
||||
}
|
||||
|
||||
// newDevServer constructs the dev server (abs paths, vendor manifest, import map)
|
||||
// without registering routes or starting the watcher — the split lets tests drive
|
||||
// the handlers directly.
|
||||
func newDevServer() (*devServer, error) {
|
||||
frontendAbs, err := filepath.Abs(frontendDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
outputAbs, err := filepath.Abs(outputDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// esbuild reports realpaths (symlinks resolved), so the roots must be too or
|
||||
// the containment/URL math breaks on symlinked trees (e.g. macOS /var→/private/var).
|
||||
frontendAbs = evalSymlinks(frontendAbs)
|
||||
outputAbs = evalSymlinks(outputAbs)
|
||||
d := &devServer{
|
||||
hub: newHub(),
|
||||
frontend: frontendAbs,
|
||||
srcRoot: filepath.Join(frontendAbs, "src"),
|
||||
vendor: filepath.Join(frontendAbs, "vendor"),
|
||||
output: outputAbs,
|
||||
graph: newModuleGraph(),
|
||||
vendorCache: map[string][]byte{},
|
||||
cssTrigger: make(chan struct{}, 1),
|
||||
}
|
||||
|
||||
eps, err := loadVendorManifest(d.vendor)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("loading vendor manifest: %w", err)
|
||||
}
|
||||
d.vendorEntrypoints = eps
|
||||
d.importMapJSON = d.buildImportMap()
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (d *devServer) register(mux *http.ServeMux) {
|
||||
mux.HandleFunc(srcURLPrefix, d.serveModule)
|
||||
mux.HandleFunc(vendorURLPrefix, d.serveVendor)
|
||||
mux.HandleFunc(assetURLPrefix, d.serveAssetURL)
|
||||
mux.HandleFunc(fsURLPrefix, d.serveFS)
|
||||
mux.HandleFunc("/@hmr/client", d.serveClient)
|
||||
mux.HandleFunc("/@hmr/ws", d.hub.ServeWS)
|
||||
}
|
||||
|
||||
// serveModule transforms and serves one source module as native ESM.
|
||||
func (d *devServer) serveModule(w http.ResponseWriter, r *http.Request) {
|
||||
rel := strings.TrimPrefix(r.URL.Path, srcURLPrefix)
|
||||
abs := filepath.Join(d.srcRoot, filepath.FromSlash(rel))
|
||||
// Contain the request to the source tree.
|
||||
if !within(d.srcRoot, abs) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if _, statErr := os.Stat(abs); statErr != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
code, err := d.transformModule(abs)
|
||||
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
if err != nil {
|
||||
// Surface the failure as a browser overlay rather than a hard 500 that
|
||||
// would break the module graph; the next save re-runs the transform.
|
||||
fmt.Fprintf(os.Stderr, "HMR transform %s: %v\n", rel, err)
|
||||
w.Write([]byte(errorModule(rel, err)))
|
||||
return
|
||||
}
|
||||
w.Write(code)
|
||||
}
|
||||
|
||||
// transformModule runs esbuild over a single entry file with all imports marked
|
||||
// external (so only this file is transformed) and rewritten to dev URLs, then
|
||||
// prepends the import.meta.hot bootstrap. .tsx/.jsx are Solid+refresh compiled
|
||||
// via the goja pipeline first.
|
||||
func (d *devServer) transformModule(abs string) ([]byte, error) {
|
||||
result := esbuild.Build(esbuild.BuildOptions{
|
||||
EntryPoints: []string{abs},
|
||||
Bundle: true,
|
||||
Write: false,
|
||||
Format: esbuild.FormatESModule,
|
||||
Platform: esbuild.PlatformBrowser,
|
||||
Target: esbuild.ES2022,
|
||||
Sourcemap: esbuild.SourceMapInline,
|
||||
SourcesContent: esbuild.SourcesContentInclude,
|
||||
LogLevel: esbuild.LogLevelSilent,
|
||||
// Same compile-time env define as the production bundle, so env.ts reads
|
||||
// the baked value under HMR too (see esbuildDefine).
|
||||
Define: esbuildDefine(),
|
||||
// Order matters: the Solid/JSX loader and the export shim handle the entry
|
||||
// file's contents; the external-rewrite resolver must be able to see every
|
||||
// import, so it runs last (its OnResolve filter is `.*`).
|
||||
Plugins: []esbuild.Plugin{
|
||||
d.solidRefreshLoadPlugin(),
|
||||
defaultExportShimPlugin(),
|
||||
d.externalRewritePlugin(),
|
||||
},
|
||||
})
|
||||
if len(result.Errors) > 0 {
|
||||
msgs := esbuild.FormatMessages(result.Errors, esbuild.FormatMessagesOptions{})
|
||||
return nil, fmt.Errorf("%s", strings.Join(msgs, "\n"))
|
||||
}
|
||||
if len(result.OutputFiles) == 0 {
|
||||
return nil, fmt.Errorf("no output")
|
||||
}
|
||||
|
||||
selfURL := srcURLPrefix + filepath.ToSlash(mustRel(d.srcRoot, abs))
|
||||
prelude := "import { createHotContext as __createHotContext } from \"/@hmr/client\";\n" +
|
||||
"import.meta.hot = __createHotContext(" + jsString(selfURL) + ");\n"
|
||||
return append([]byte(prelude), result.OutputFiles[0].Contents...), nil
|
||||
}
|
||||
|
||||
// solidRefreshLoadPlugin Solid-compiles .tsx/.jsx via the goja pipeline WITH
|
||||
// solid-refresh instrumentation, passing the src-relative path as the stable id
|
||||
// solid-refresh keys its HMR registry on (so it survives across recompiles).
|
||||
func (d *devServer) solidRefreshLoadPlugin() esbuild.Plugin {
|
||||
return esbuild.Plugin{
|
||||
Name: "dev-solid-refresh",
|
||||
Setup: func(b esbuild.PluginBuild) {
|
||||
b.OnLoad(esbuild.OnLoadOptions{Filter: `\.(tsx|jsx)$`}, func(a esbuild.OnLoadArgs) (esbuild.OnLoadResult, error) {
|
||||
data, err := os.ReadFile(a.Path)
|
||||
if err != nil {
|
||||
return esbuild.OnLoadResult{}, err
|
||||
}
|
||||
id := filepath.ToSlash(mustRel(d.srcRoot, a.Path))
|
||||
out, err := CompileDev(string(data), id)
|
||||
if err != nil {
|
||||
return esbuild.OnLoadResult{}, err
|
||||
}
|
||||
// Load as TS, not JS: esbuild only re-emits (and thus rewrites this
|
||||
// module's external import paths to our /@src/ URLs) when it has to
|
||||
// transpile. A JS loader leaves the already-JS CompileDev output
|
||||
// untouched, leaking the raw ./x.js specifiers to the browser. esbuild
|
||||
// chains solid-refresh's inline sourcemap into its own, so the browser
|
||||
// still debugs against the original .tsx.
|
||||
loader := esbuild.LoaderTS
|
||||
dir := filepath.Dir(a.Path)
|
||||
return esbuild.OnLoadResult{Contents: &out, Loader: loader, ResolveDir: dir}, nil
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// externalRewritePlugin marks every non-entry import external and rewrites its
|
||||
// path to a dev URL: relative/absolute → /@src/<resolved> (recording a graph
|
||||
// edge), `?url` → /@fs/<file> raw passthrough, bare → left as-is for the import
|
||||
// map. esbuild emits the returned path verbatim, so we control the URL.
|
||||
func (d *devServer) externalRewritePlugin() esbuild.Plugin {
|
||||
return esbuild.Plugin{
|
||||
Name: "dev-external-rewrite",
|
||||
Setup: func(b esbuild.PluginBuild) {
|
||||
b.OnResolve(esbuild.OnResolveOptions{Filter: `.*`}, func(a esbuild.OnResolveArgs) (esbuild.OnResolveResult, error) {
|
||||
if a.Kind == esbuild.ResolveEntryPoint {
|
||||
return esbuild.OnResolveResult{}, nil // let esbuild load the entry
|
||||
}
|
||||
spec := a.Path
|
||||
|
||||
// `import x from "...?url"` — emit the referenced file as a raw asset.
|
||||
if strings.HasSuffix(spec, "?url") {
|
||||
real := strings.TrimSuffix(spec, "?url")
|
||||
if target := d.resolveAsset(a.ResolveDir, real); target != "" {
|
||||
return esbuild.OnResolveResult{Path: target, External: true}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Bare specifier → import map (single vendored copy).
|
||||
if !strings.HasPrefix(spec, ".") && !filepath.IsAbs(spec) {
|
||||
return esbuild.OnResolveResult{Path: spec, External: true}, nil
|
||||
}
|
||||
|
||||
// Relative/absolute → resolve to the real source file and rewrite to
|
||||
// its /@src/ URL, recording the importer→import edge for HMR.
|
||||
resolved := resolveSource(a.ResolveDir, spec)
|
||||
if resolved == "" || !within(d.srcRoot, resolved) {
|
||||
// Unknown relative import: leave it and let the browser 404 loudly.
|
||||
return esbuild.OnResolveResult{Path: spec, External: true}, nil
|
||||
}
|
||||
if a.Importer != "" {
|
||||
d.graph.recordEdge(a.Importer, resolved)
|
||||
}
|
||||
url := srcURLPrefix + filepath.ToSlash(mustRel(d.srcRoot, resolved))
|
||||
if v := d.graph.versionOf(resolved); v > 0 {
|
||||
url += fmt.Sprintf("?t=%d", v)
|
||||
}
|
||||
return esbuild.OnResolveResult{Path: url, External: true}, nil
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// resolveAsset resolves a `?url` target (relative to importer, or a vendored
|
||||
// subpath) to a /@url/ shim-module path rooted at the frontend dir. The shim's
|
||||
// default export is the raw /@fs/ URL — matching esbuild's file-loader `?url`
|
||||
// semantics, where `import u from "x?url"` binds u to the asset's URL string, not
|
||||
// the module's exports.
|
||||
func (d *devServer) resolveAsset(resolveDir, spec string) string {
|
||||
var abs string
|
||||
if strings.HasPrefix(spec, ".") || filepath.IsAbs(spec) {
|
||||
abs = filepath.Join(resolveDir, spec)
|
||||
} else {
|
||||
abs = filepath.Join(d.vendor, filepath.FromSlash(spec)) // vendored subpath
|
||||
}
|
||||
if !within(d.frontend, abs) || !fileExists(abs) {
|
||||
return ""
|
||||
}
|
||||
return assetURLPrefix + filepath.ToSlash(mustRel(d.frontend, abs))
|
||||
}
|
||||
|
||||
// serveAssetURL serves the `?url` shim module: `export default "<raw file URL>"`.
|
||||
func (d *devServer) serveAssetURL(w http.ResponseWriter, r *http.Request) {
|
||||
rel := strings.TrimPrefix(r.URL.Path, assetURLPrefix)
|
||||
abs := filepath.Join(d.frontend, filepath.FromSlash(rel))
|
||||
if !within(d.frontend, abs) || !fileExists(abs) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
fmt.Fprintf(w, "export default %s;\n", jsString(fsURLPrefix+rel))
|
||||
}
|
||||
|
||||
// serveFS serves a raw file under the frontend dir (the target of a `?url` shim,
|
||||
// e.g. the pdfjs worker, which must load from a real URL for import.meta.url).
|
||||
func (d *devServer) serveFS(w http.ResponseWriter, r *http.Request) {
|
||||
rel := strings.TrimPrefix(r.URL.Path, fsURLPrefix)
|
||||
abs := filepath.Join(d.frontend, filepath.FromSlash(rel))
|
||||
if !within(d.frontend, abs) || !fileExists(abs) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
// Pin the JS MIME so module workers (.mjs) aren't rejected for a text/plain type.
|
||||
switch strings.ToLower(filepath.Ext(abs)) {
|
||||
case ".mjs", ".js":
|
||||
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
http.ServeFile(w, r, abs)
|
||||
}
|
||||
|
||||
// ---- module graph ----------------------------------------------------------
|
||||
|
||||
// moduleGraph tracks importer→import edges and per-module versions so a change
|
||||
// can be propagated to the nearest self-accepting boundary (a .tsx/.jsx compiled
|
||||
// with solid-refresh) or, failing that, a full reload. Keys are absolute paths.
|
||||
type moduleGraph struct {
|
||||
mu sync.Mutex
|
||||
importers map[string]map[string]bool // module -> set of modules that import it
|
||||
version map[string]int64 // module -> current version (0 = never changed)
|
||||
counter int64
|
||||
}
|
||||
|
||||
func newModuleGraph() *moduleGraph {
|
||||
return &moduleGraph{importers: map[string]map[string]bool{}, version: map[string]int64{}}
|
||||
}
|
||||
|
||||
func (g *moduleGraph) recordEdge(importer, imported string) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
set := g.importers[imported]
|
||||
if set == nil {
|
||||
set = map[string]bool{}
|
||||
g.importers[imported] = set
|
||||
}
|
||||
set[importer] = true
|
||||
}
|
||||
|
||||
func (g *moduleGraph) versionOf(module string) int64 {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
return g.version[module]
|
||||
}
|
||||
|
||||
// invalidate walks up from the changed file to the nearest HMR boundaries,
|
||||
// bumping the version of every module on the path (so a re-imported boundary
|
||||
// re-fetches the changed dep, not a cached copy). It returns the boundary URLs to
|
||||
// re-import and the shared version, or fullReload if the change reaches a
|
||||
// non-accepting root (the entry, a routes table).
|
||||
func (g *moduleGraph) invalidate(changed string) (boundaries []string, version int64, fullReload bool) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
g.counter++
|
||||
v := g.counter
|
||||
visited := map[string]bool{}
|
||||
queue := []string{changed}
|
||||
for len(queue) > 0 {
|
||||
m := queue[0]
|
||||
queue = queue[1:]
|
||||
if visited[m] {
|
||||
continue
|
||||
}
|
||||
visited[m] = true
|
||||
g.version[m] = v
|
||||
|
||||
if isHMRBoundary(m) {
|
||||
boundaries = append(boundaries, m)
|
||||
continue // don't climb past a self-accepting boundary
|
||||
}
|
||||
imps := g.importers[m]
|
||||
if len(imps) == 0 {
|
||||
fullReload = true // reached a root that can't accept -> reload
|
||||
continue
|
||||
}
|
||||
for imp := range imps {
|
||||
queue = append(queue, imp)
|
||||
}
|
||||
}
|
||||
if fullReload {
|
||||
return nil, v, true
|
||||
}
|
||||
return boundaries, v, false
|
||||
}
|
||||
|
||||
// isHMRBoundary reports whether a module can self-accept — only solid-refresh
|
||||
// -instrumented JSX (.tsx/.jsx). .ts/.js (incl. the solid-js/html app pages)
|
||||
// bubble to a full reload.
|
||||
func isHMRBoundary(path string) bool {
|
||||
switch filepath.Ext(path) {
|
||||
case ".tsx", ".jsx":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ---- helpers ---------------------------------------------------------------
|
||||
|
||||
// resolveSource replicates esbuild's resolution of a relative import to a real
|
||||
// source file, including the project's `.js`-specifier-for-a-.ts-file convention
|
||||
// (see import_check.go) and extensionless / index resolution.
|
||||
func resolveSource(resolveDir, spec string) string {
|
||||
base := filepath.Join(resolveDir, filepath.FromSlash(spec))
|
||||
if fileExists(base) {
|
||||
return base
|
||||
}
|
||||
// `./Foo.js` may name a sibling .ts/.tsx/.jsx (esbuild resolves it silently).
|
||||
if strings.HasSuffix(base, ".js") {
|
||||
stem := strings.TrimSuffix(base, ".js")
|
||||
for _, ext := range []string{".ts", ".tsx", ".jsx"} {
|
||||
if fileExists(stem + ext) {
|
||||
return stem + ext
|
||||
}
|
||||
}
|
||||
}
|
||||
// Extensionless specifier.
|
||||
for _, ext := range []string{".ts", ".tsx", ".jsx", ".js"} {
|
||||
if fileExists(base + ext) {
|
||||
return base + ext
|
||||
}
|
||||
}
|
||||
// Directory index.
|
||||
for _, ext := range []string{".ts", ".tsx", ".jsx", ".js"} {
|
||||
if idx := filepath.Join(base, "index"+ext); fileExists(idx) {
|
||||
return idx
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func fileExists(p string) bool {
|
||||
info, err := os.Stat(p)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
||||
// evalSymlinks resolves symlinks so paths compare equal to esbuild's realpaths;
|
||||
// returns the input unchanged if it can't be resolved (e.g. doesn't exist yet).
|
||||
func evalSymlinks(p string) string {
|
||||
if r, err := filepath.EvalSymlinks(p); err == nil {
|
||||
return r
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// within reports whether abs is inside root (after cleaning), guarding against
|
||||
// `..` traversal out of the served tree.
|
||||
func within(root, abs string) bool {
|
||||
rel, err := filepath.Rel(root, abs)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
|
||||
}
|
||||
|
||||
func mustRel(root, abs string) string {
|
||||
rel, err := filepath.Rel(root, abs)
|
||||
if err != nil {
|
||||
return abs
|
||||
}
|
||||
return rel
|
||||
}
|
||||
|
||||
// jsString renders s as a double-quoted JS string literal.
|
||||
func jsString(s string) string {
|
||||
b := strings.Builder{}
|
||||
b.WriteByte('"')
|
||||
for _, r := range s {
|
||||
switch r {
|
||||
case '"':
|
||||
b.WriteString(`\"`)
|
||||
case '\\':
|
||||
b.WriteString(`\\`)
|
||||
case '\n':
|
||||
b.WriteString(`\n`)
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
b.WriteByte('"')
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// errorModule returns an ES module that surfaces a compile/transform failure as
|
||||
// a full-screen overlay in the browser (Vite-style) instead of breaking the page
|
||||
// hard. It imports the HMR client's showErrorOverlay and calls it with the error
|
||||
// text; because the stub evaluates synchronously as part of the importing
|
||||
// boundary's module graph, the overlay appears the moment a broken module is
|
||||
// (re-)imported — on first load or on a hot re-import. A later successful hot
|
||||
// update clears it (see applyUpdates in hmr_client.go). rel is the src-relative
|
||||
// path of the failing module, shown in the overlay header.
|
||||
func errorModule(rel string, err error) string {
|
||||
payload, jerr := json.Marshal(hmrError{Message: err.Error(), File: rel})
|
||||
if jerr != nil {
|
||||
return "console.error(" + jsString("[hmr] build error:\n"+err.Error()) + ");\n"
|
||||
}
|
||||
return "import { showErrorOverlay as __hmrShowError } from \"/@hmr/client\";\n" +
|
||||
"__hmrShowError(" + string(payload) + ");\n"
|
||||
}
|
||||
118
go/bundler/hmr_vendor.go
Normal file
118
go/bundler/hmr_vendor.go
Normal file
@@ -0,0 +1,118 @@
|
||||
//go:build dev
|
||||
|
||||
package bundler
|
||||
|
||||
// Vendor handling for the dev server. Bare specifiers (solid-js, @solidjs/router,
|
||||
// solid-refresh, …) are served as single pre-bundled ESM files under /@vendor/,
|
||||
// wired up by an import map the SPA shell inlines. Because each specifier maps to
|
||||
// exactly one URL, and every vendor bundle re-externalizes the OTHER vendored
|
||||
// specifiers (rather than inlining them), solid-js stays a single runtime
|
||||
// instance across the whole app — the invariant the prod bundler also guards.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
esbuild "github.com/evanw/esbuild/pkg/api"
|
||||
)
|
||||
|
||||
const vendorURLPrefix = "/@vendor/"
|
||||
|
||||
// buildImportMap returns the JSON `{"imports": {...}}` body the SPA shell inlines
|
||||
// in <script type="importmap">, mapping every vendored bare specifier to its
|
||||
// /@vendor/ URL.
|
||||
func (d *devServer) buildImportMap() string {
|
||||
imports := make(map[string]string, len(d.vendorEntrypoints))
|
||||
for spec := range d.vendorEntrypoints {
|
||||
imports[spec] = vendorURLPrefix + spec + ".js"
|
||||
}
|
||||
body, err := json.MarshalIndent(map[string]any{"imports": imports}, "", " ")
|
||||
if err != nil {
|
||||
return `{"imports":{}}`
|
||||
}
|
||||
return string(body)
|
||||
}
|
||||
|
||||
// serveVendor serves a pre-bundled vendored package (lazily built + cached).
|
||||
func (d *devServer) serveVendor(w http.ResponseWriter, r *http.Request) {
|
||||
spec := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, vendorURLPrefix), ".js")
|
||||
relFile, ok := d.vendorEntrypoints[spec]
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
d.vendorMu.Lock()
|
||||
bundled, cached := d.vendorCache[r.URL.Path]
|
||||
d.vendorMu.Unlock()
|
||||
|
||||
if !cached {
|
||||
b, err := d.bundleVendor(relFile)
|
||||
if err != nil {
|
||||
http.Error(w, "vendor bundle failed: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
d.vendorMu.Lock()
|
||||
d.vendorCache[r.URL.Path] = b
|
||||
d.vendorMu.Unlock()
|
||||
bundled = b
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
|
||||
// Vendor rarely changes; let the browser cache within a session.
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
w.Write(bundled)
|
||||
}
|
||||
|
||||
// bundleVendor bundles one vendored package entrypoint into a single ESM file,
|
||||
// re-externalizing the OTHER vendored specifiers so they resolve (once) through
|
||||
// the import map. Mirrors the prod vendor resolution (NodePaths + development
|
||||
// condition) so the dev copy matches the shipped one.
|
||||
func (d *devServer) bundleVendor(relFile string) ([]byte, error) {
|
||||
entry := filepath.Join(d.vendor, filepath.FromSlash(relFile))
|
||||
result := esbuild.Build(esbuild.BuildOptions{
|
||||
EntryPoints: []string{entry},
|
||||
Bundle: true,
|
||||
Write: false,
|
||||
Format: esbuild.FormatESModule,
|
||||
Target: esbuild.ES2022,
|
||||
Platform: esbuild.PlatformBrowser,
|
||||
Conditions: []string{"development"},
|
||||
NodePaths: []string{d.vendor},
|
||||
Sourcemap: esbuild.SourceMapInline,
|
||||
LogLevel: esbuild.LogLevelSilent,
|
||||
Plugins: []esbuild.Plugin{d.vendorSharedExternalPlugin()},
|
||||
})
|
||||
if len(result.Errors) > 0 {
|
||||
msgs := esbuild.FormatMessages(result.Errors, esbuild.FormatMessagesOptions{})
|
||||
return nil, fmt.Errorf("%s", strings.Join(msgs, "\n"))
|
||||
}
|
||||
if len(result.OutputFiles) == 0 {
|
||||
return nil, fmt.Errorf("no output")
|
||||
}
|
||||
return result.OutputFiles[0].Contents, nil
|
||||
}
|
||||
|
||||
// vendorSharedExternalPlugin marks a bare import external ONLY when it's an exact
|
||||
// vendored entrypoint (thus resolvable via the import map). That keeps shared
|
||||
// singletons — above all solid-js — as one instance across every vendor bundle,
|
||||
// while deep subpaths and non-vendored transitive deps get bundled in.
|
||||
func (d *devServer) vendorSharedExternalPlugin() esbuild.Plugin {
|
||||
return esbuild.Plugin{
|
||||
Name: "dev-vendor-shared-external",
|
||||
Setup: func(b esbuild.PluginBuild) {
|
||||
b.OnResolve(esbuild.OnResolveOptions{Filter: `^[^./]`}, func(a esbuild.OnResolveArgs) (esbuild.OnResolveResult, error) {
|
||||
if a.Kind == esbuild.ResolveEntryPoint {
|
||||
return esbuild.OnResolveResult{}, nil
|
||||
}
|
||||
if _, ok := d.vendorEntrypoints[a.Path]; ok {
|
||||
return esbuild.OnResolveResult{Path: a.Path, External: true}, nil
|
||||
}
|
||||
return esbuild.OnResolveResult{}, nil // bundle via NodePaths
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
158
go/bundler/hmr_watch.go
Normal file
158
go/bundler/hmr_watch.go
Normal file
@@ -0,0 +1,158 @@
|
||||
//go:build dev
|
||||
|
||||
package bundler
|
||||
|
||||
// The dev watcher: a lightweight mtime poll over frontend/src and frontend/css
|
||||
// (no external dependency). A source-module change is turned into an HMR update
|
||||
// or a full reload via the module graph; a CSS/Tailwind change triggers a
|
||||
// (coalesced) stylesheet rebuild and hot-swap. The public-route manifest is
|
||||
// regenerated on edit, then the page reloads.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// pollInterval is the mtime scan cadence. Kept tight so save→update latency isn't
|
||||
// dominated by detection lag: the WalkDir+Stat over frontend/src+css is sub-ms, so
|
||||
// scanning this often is cheap. (For zero detection lag, swap the poll for native
|
||||
// FS events — deliberately avoided to keep the watcher dependency-free.)
|
||||
const pollInterval = 50 * time.Millisecond
|
||||
|
||||
func (d *devServer) watch() {
|
||||
go d.cssLoop()
|
||||
|
||||
roots := []string{d.srcRoot, filepath.Join(d.frontend, "css")}
|
||||
mtimes := map[string]time.Time{}
|
||||
d.scan(roots, mtimes, nil) // seed: record current state, emit nothing
|
||||
|
||||
for {
|
||||
time.Sleep(pollInterval)
|
||||
var changed []string
|
||||
d.scan(roots, mtimes, func(p string) { changed = append(changed, p) })
|
||||
if len(changed) > 0 {
|
||||
d.handleChanges(changed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// scan walks roots, calling onchange for every file whose mtime advanced since
|
||||
// the last scan (or is new). mtimes is updated in place.
|
||||
func (d *devServer) scan(roots []string, mtimes map[string]time.Time, onchange func(string)) {
|
||||
for _, root := range roots {
|
||||
filepath.WalkDir(root, func(p string, entry os.DirEntry, err error) error {
|
||||
if err != nil || entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
switch filepath.Ext(p) {
|
||||
case ".ts", ".tsx", ".js", ".jsx", ".css":
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
mt := info.ModTime()
|
||||
if prev, ok := mtimes[p]; !ok || mt.After(prev) {
|
||||
mtimes[p] = mt
|
||||
// onchange is nil on the seed pass, so nothing is reported until the
|
||||
// baseline is recorded; afterwards every new/changed file is reported.
|
||||
if onchange != nil {
|
||||
onchange(p)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (d *devServer) handleChanges(changed []string) {
|
||||
pagesManifestAbs := filepath.Join(d.frontend, filepath.FromSlash(pagesManifest))
|
||||
cssDirty := false
|
||||
srcDirty := false
|
||||
|
||||
for _, p := range changed {
|
||||
base := filepath.Base(p)
|
||||
// Generated files are written by the tools below; ignore to avoid loops.
|
||||
if strings.HasSuffix(base, ".gen.ts") || strings.HasSuffix(base, ".gen.go") {
|
||||
continue
|
||||
}
|
||||
|
||||
if p == pagesManifestAbs {
|
||||
// The public-page manifest drives generated route tables — regenerate,
|
||||
// then reload (these pages are live-reload scope, not component HMR).
|
||||
if err := generatePublicRoutes(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[hmr] regenerating public routes: %v\n", err)
|
||||
}
|
||||
d.hub.broadcastJSON(hmrMessage{Type: "full-reload"})
|
||||
return
|
||||
}
|
||||
|
||||
switch filepath.Ext(p) {
|
||||
case ".css":
|
||||
cssDirty = true
|
||||
case ".ts", ".tsx", ".js", ".jsx":
|
||||
d.hmrJS(p)
|
||||
cssDirty = true // a class may have been added/removed
|
||||
srcDirty = true
|
||||
}
|
||||
}
|
||||
|
||||
if cssDirty {
|
||||
select {
|
||||
case d.cssTrigger <- struct{}{}:
|
||||
default: // a rebuild is already queued
|
||||
}
|
||||
}
|
||||
if srcDirty {
|
||||
// Proactively re-render the public pages' SSR so the no-JS ("static")
|
||||
// version served on the next reload is already current, not rendered lazily.
|
||||
RewarmDevPublicPages()
|
||||
}
|
||||
}
|
||||
|
||||
// hmrJS turns one changed source module into a hot update or a full reload.
|
||||
func (d *devServer) hmrJS(abs string) {
|
||||
rel := filepath.ToSlash(mustRel(d.srcRoot, abs))
|
||||
boundaries, version, reload := d.graph.invalidate(abs)
|
||||
if reload {
|
||||
fmt.Printf("[hmr] full reload (%s)\n", rel)
|
||||
d.hub.broadcastJSON(hmrMessage{Type: "full-reload"})
|
||||
return
|
||||
}
|
||||
if len(boundaries) == 0 {
|
||||
return // not on the current page's graph — nothing to do
|
||||
}
|
||||
|
||||
updates := make([]hmrUpdate, 0, len(boundaries))
|
||||
for _, b := range boundaries {
|
||||
updates = append(updates, hmrUpdate{
|
||||
Path: srcURLPrefix + filepath.ToSlash(mustRel(d.srcRoot, b)),
|
||||
Timestamp: version,
|
||||
})
|
||||
}
|
||||
fmt.Printf("[hmr] update %s -> %d boundary(ies)\n", rel, len(boundaries))
|
||||
d.hub.broadcastJSON(hmrMessage{Type: "update", Updates: updates})
|
||||
}
|
||||
|
||||
// cssLoop serializes and coalesces CSS rebuilds, hot-swapping the stylesheet when
|
||||
// each completes.
|
||||
func (d *devServer) cssLoop() {
|
||||
for range d.cssTrigger {
|
||||
if _, err := bundleSPACSS(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[hmr] CSS rebuild failed: %v\n", err)
|
||||
} else {
|
||||
d.hub.broadcastJSON(hmrMessage{Type: "css-update", Path: "/bundle.min.css"})
|
||||
}
|
||||
// Public pages hot-reload too, off their own stylesheet.
|
||||
if _, err := bundlePublicCSS(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[hmr] public CSS rebuild failed: %v\n", err)
|
||||
} else {
|
||||
d.hub.broadcastJSON(hmrMessage{Type: "css-update", Path: "/public.bundle.min.css"})
|
||||
}
|
||||
}
|
||||
}
|
||||
235
go/bundler/hmr_ws.go
Normal file
235
go/bundler/hmr_ws.go
Normal file
@@ -0,0 +1,235 @@
|
||||
//go:build dev
|
||||
|
||||
package bundler
|
||||
|
||||
// A minimal RFC 6455 WebSocket server — just enough for one-way server→browser
|
||||
// push of HMR messages. We hand-roll it (rather than add a dependency) because
|
||||
// the surface we need is tiny: the handshake, unmasked server text frames, and a
|
||||
// read loop that answers pings and notices close. No per-message compression, no
|
||||
// fragmentation, no client→server application data. All of internal/bundler's
|
||||
// HMR support is behind `//go:build dev`, so prod builds compile none of it.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/sha1"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// wsGUID is the RFC 6455 magic value concatenated with Sec-WebSocket-Key to
|
||||
// derive the accept token.
|
||||
const wsGUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
||||
|
||||
type hub struct {
|
||||
mu sync.Mutex
|
||||
clients map[*wsClient]struct{}
|
||||
}
|
||||
|
||||
func newHub() *hub { return &hub{clients: map[*wsClient]struct{}{}} }
|
||||
|
||||
type wsClient struct {
|
||||
conn net.Conn
|
||||
brw *bufio.ReadWriter
|
||||
wmu sync.Mutex // serialize frame writes across broadcast + pong
|
||||
}
|
||||
|
||||
// ServeWS upgrades an HTTP/1.1 request to a WebSocket and registers the client.
|
||||
func (h *hub) ServeWS(w http.ResponseWriter, r *http.Request) {
|
||||
key := r.Header.Get("Sec-WebSocket-Key")
|
||||
if key == "" {
|
||||
http.Error(w, "expected a WebSocket handshake", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
hj, ok := w.(http.Hijacker)
|
||||
if !ok {
|
||||
http.Error(w, "connection does not support hijacking", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
conn, brw, err := hj.Hijack()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
accept := computeAccept(key)
|
||||
if _, err := brw.WriteString(
|
||||
"HTTP/1.1 101 Switching Protocols\r\n" +
|
||||
"Upgrade: websocket\r\n" +
|
||||
"Connection: Upgrade\r\n" +
|
||||
"Sec-WebSocket-Accept: " + accept + "\r\n\r\n",
|
||||
); err != nil {
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
if err := brw.Flush(); err != nil {
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
|
||||
c := &wsClient{conn: conn, brw: brw}
|
||||
h.mu.Lock()
|
||||
h.clients[c] = struct{}{}
|
||||
h.mu.Unlock()
|
||||
|
||||
go c.readLoop(h)
|
||||
}
|
||||
|
||||
// broadcast writes a text frame to every connected client, dropping any that
|
||||
// error (disconnected tab).
|
||||
func (h *hub) broadcast(payload []byte) {
|
||||
h.mu.Lock()
|
||||
clients := make([]*wsClient, 0, len(h.clients))
|
||||
for c := range h.clients {
|
||||
clients = append(clients, c)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
for _, c := range clients {
|
||||
if err := c.writeText(payload); err != nil {
|
||||
h.drop(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// broadcastJSON marshals v and broadcasts it as a text frame.
|
||||
func (h *hub) broadcastJSON(v any) {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "devhmr: marshal ws message: %v\n", err)
|
||||
return
|
||||
}
|
||||
h.broadcast(b)
|
||||
}
|
||||
|
||||
func (h *hub) drop(c *wsClient) {
|
||||
h.mu.Lock()
|
||||
if _, ok := h.clients[c]; ok {
|
||||
delete(h.clients, c)
|
||||
c.conn.Close()
|
||||
}
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
// clientCount reports how many browsers are currently connected.
|
||||
func (h *hub) clientCount() int {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return len(h.clients)
|
||||
}
|
||||
|
||||
// readLoop consumes client frames only to answer pings and to notice a close or
|
||||
// dead connection, at which point the client is dropped. Application data from
|
||||
// the client is ignored — this channel is server→browser only.
|
||||
func (c *wsClient) readLoop(h *hub) {
|
||||
defer h.drop(c)
|
||||
for {
|
||||
op, payload, err := readFrame(c.brw.Reader)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
switch op {
|
||||
case opClose:
|
||||
c.writeFrame(opClose, payload)
|
||||
return
|
||||
case opPing:
|
||||
if c.writeFrame(opPong, payload) != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *wsClient) writeText(payload []byte) error { return c.writeFrame(opText, payload) }
|
||||
|
||||
// opcodes we handle.
|
||||
const (
|
||||
opText byte = 0x1
|
||||
opClose byte = 0x8
|
||||
opPing byte = 0x9
|
||||
opPong byte = 0xA
|
||||
)
|
||||
|
||||
// writeFrame writes a single unmasked, unfragmented frame (server frames must
|
||||
// not be masked). Writes are serialized so a broadcast and a pong can't interleave.
|
||||
func (c *wsClient) writeFrame(opcode byte, payload []byte) error {
|
||||
c.wmu.Lock()
|
||||
defer c.wmu.Unlock()
|
||||
|
||||
header := make([]byte, 0, 10)
|
||||
header = append(header, 0x80|opcode) // FIN + opcode
|
||||
n := len(payload)
|
||||
switch {
|
||||
case n <= 125:
|
||||
header = append(header, byte(n))
|
||||
case n <= 0xFFFF:
|
||||
header = append(header, 126, byte(n>>8), byte(n))
|
||||
default:
|
||||
header = append(header, 127)
|
||||
for i := 7; i >= 0; i-- {
|
||||
header = append(header, byte(n>>(8*i)))
|
||||
}
|
||||
}
|
||||
if _, err := c.brw.Write(header); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := c.brw.Write(payload); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.brw.Flush()
|
||||
}
|
||||
|
||||
// readFrame reads one frame, unmasking the client payload (client→server frames
|
||||
// are always masked). Returns the opcode and payload.
|
||||
func readFrame(r *bufio.Reader) (opcode byte, payload []byte, err error) {
|
||||
var h [2]byte
|
||||
if _, err = io.ReadFull(r, h[:]); err != nil {
|
||||
return
|
||||
}
|
||||
opcode = h[0] & 0x0F
|
||||
masked := h[1]&0x80 != 0
|
||||
n := int(h[1] & 0x7F)
|
||||
switch n {
|
||||
case 126:
|
||||
var ext [2]byte
|
||||
if _, err = io.ReadFull(r, ext[:]); err != nil {
|
||||
return
|
||||
}
|
||||
n = int(ext[0])<<8 | int(ext[1])
|
||||
case 127:
|
||||
var ext [8]byte
|
||||
if _, err = io.ReadFull(r, ext[:]); err != nil {
|
||||
return
|
||||
}
|
||||
n = 0
|
||||
for _, b := range ext {
|
||||
n = n<<8 | int(b)
|
||||
}
|
||||
}
|
||||
var mask [4]byte
|
||||
if masked {
|
||||
if _, err = io.ReadFull(r, mask[:]); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
payload = make([]byte, n)
|
||||
if _, err = io.ReadFull(r, payload); err != nil {
|
||||
return
|
||||
}
|
||||
if masked {
|
||||
for i := range payload {
|
||||
payload[i] ^= mask[i%4]
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// computeAccept derives the Sec-WebSocket-Accept response header from the key.
|
||||
func computeAccept(key string) string {
|
||||
s := sha1.Sum([]byte(key + wsGUID))
|
||||
return base64.StdEncoding.EncodeToString(s[:])
|
||||
}
|
||||
88
go/bundler/hmr_ws_integration_test.go
Normal file
88
go/bundler/hmr_ws_integration_test.go
Normal file
@@ -0,0 +1,88 @@
|
||||
//go:build dev
|
||||
|
||||
package bundler
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A real TCP client completes the WebSocket handshake against the hub (exercising
|
||||
// the http.Hijacker path a recorder can't), then receives a broadcast frame. This
|
||||
// covers the end-to-end push channel the browser HMR client relies on.
|
||||
func TestWebSocketHandshakeAndBroadcast(t *testing.T) {
|
||||
h := newHub()
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/@hmr/ws", h.ServeWS)
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
conn, err := net.Dial("tcp", strings.TrimPrefix(srv.URL, "http://"))
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
_, err = conn.Write([]byte(
|
||||
"GET /@hmr/ws HTTP/1.1\r\n" +
|
||||
"Host: " + srv.Listener.Addr().String() + "\r\n" +
|
||||
"Upgrade: websocket\r\n" +
|
||||
"Connection: Upgrade\r\n" +
|
||||
"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" +
|
||||
"Sec-WebSocket-Version: 13\r\n\r\n",
|
||||
))
|
||||
if err != nil {
|
||||
t.Fatalf("write handshake: %v", err)
|
||||
}
|
||||
|
||||
br := bufio.NewReader(conn)
|
||||
status, err := br.ReadString('\n')
|
||||
if err != nil || !strings.Contains(status, "101") {
|
||||
t.Fatalf("expected 101 Switching Protocols, got %q (err %v)", status, err)
|
||||
}
|
||||
var acceptOK bool
|
||||
for {
|
||||
line, err := br.ReadString('\n')
|
||||
if err != nil {
|
||||
t.Fatalf("reading headers: %v", err)
|
||||
}
|
||||
if strings.HasPrefix(line, "Sec-WebSocket-Accept:") &&
|
||||
strings.Contains(line, "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=") {
|
||||
acceptOK = true
|
||||
}
|
||||
if line == "\r\n" {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !acceptOK {
|
||||
t.Fatal("missing/incorrect Sec-WebSocket-Accept header")
|
||||
}
|
||||
|
||||
// The client must be registered before we broadcast.
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for h.clientCount() == 0 && time.Now().Before(deadline) {
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
if h.clientCount() != 1 {
|
||||
t.Fatalf("hub client count = %d, want 1", h.clientCount())
|
||||
}
|
||||
|
||||
h.broadcastJSON(hmrMessage{Type: "full-reload"})
|
||||
|
||||
conn.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
op, payload, err := readFrame(br)
|
||||
if err != nil {
|
||||
t.Fatalf("reading broadcast frame: %v", err)
|
||||
}
|
||||
if op != opText {
|
||||
t.Errorf("broadcast opcode = %#x, want text", op)
|
||||
}
|
||||
if !strings.Contains(string(payload), `"full-reload"`) {
|
||||
t.Errorf("broadcast payload = %q, want full-reload message", payload)
|
||||
}
|
||||
}
|
||||
67
go/bundler/hmr_ws_test.go
Normal file
67
go/bundler/hmr_ws_test.go
Normal file
@@ -0,0 +1,67 @@
|
||||
//go:build dev
|
||||
|
||||
package bundler
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The canonical handshake vector from RFC 6455 §1.3.
|
||||
func TestComputeAccept(t *testing.T) {
|
||||
got := computeAccept("dGhlIHNhbXBsZSBub25jZQ==")
|
||||
const want = "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="
|
||||
if got != want {
|
||||
t.Fatalf("computeAccept = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A masked client frame round-trips through readFrame (payload unmasked, opcode
|
||||
// preserved) — the path exercised when the browser sends a ping or close.
|
||||
func TestReadFrameMaskedText(t *testing.T) {
|
||||
payload := []byte("hello hmr")
|
||||
mask := [4]byte{0x12, 0x34, 0x56, 0x78}
|
||||
|
||||
var buf bytes.Buffer
|
||||
buf.WriteByte(0x80 | opText) // FIN + text
|
||||
buf.WriteByte(0x80 | byte(len(payload))) // MASK + len
|
||||
buf.Write(mask[:])
|
||||
for i, b := range payload {
|
||||
buf.WriteByte(b ^ mask[i%4])
|
||||
}
|
||||
|
||||
op, got, err := readFrame(bufio.NewReader(&buf))
|
||||
if err != nil {
|
||||
t.Fatalf("readFrame: %v", err)
|
||||
}
|
||||
if op != opText {
|
||||
t.Errorf("opcode = %#x, want %#x", op, opText)
|
||||
}
|
||||
if !bytes.Equal(got, payload) {
|
||||
t.Errorf("payload = %q, want %q", got, payload)
|
||||
}
|
||||
}
|
||||
|
||||
// A server frame is written unmasked and parses back to the same payload.
|
||||
func TestWriteFrameRoundTrip(t *testing.T) {
|
||||
payload := bytes.Repeat([]byte("x"), 300) // exercises the 16-bit length path
|
||||
var raw bytes.Buffer
|
||||
c := &wsClient{brw: bufio.NewReadWriter(bufio.NewReader(nil), bufio.NewWriter(&raw))}
|
||||
if err := c.writeText(payload); err != nil {
|
||||
t.Fatalf("writeText: %v", err)
|
||||
}
|
||||
if raw.Bytes()[0] != (0x80 | opText) {
|
||||
t.Fatalf("first byte = %#x, want %#x", raw.Bytes()[0], 0x80|opText)
|
||||
}
|
||||
if raw.Bytes()[1]&0x80 != 0 {
|
||||
t.Fatalf("server frame must not set the mask bit")
|
||||
}
|
||||
op, got, err := readFrame(bufio.NewReader(&raw))
|
||||
if err != nil {
|
||||
t.Fatalf("readFrame: %v", err)
|
||||
}
|
||||
if op != opText || !bytes.Equal(got, payload) {
|
||||
t.Errorf("round-trip mismatch: op=%#x len=%d", op, len(got))
|
||||
}
|
||||
}
|
||||
87
go/bundler/import_check.go
Normal file
87
go/bundler/import_check.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package bundler
|
||||
|
||||
// esbuild silently resolves a `.js` import to a sibling `.ts`/`.tsx`/`.jsx`,
|
||||
// which lets misnamed specifiers slip through. This check catches them up
|
||||
// front so every import path names the file that actually exists on disk.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type importViolation struct {
|
||||
file string
|
||||
line int
|
||||
spec string // the written specifier, e.g. "./Icons.js"
|
||||
actual string // the corrected specifier, e.g. "./Icons.ts"
|
||||
}
|
||||
|
||||
// reImportSpec matches a quoted relative module specifier ending in ".js".
|
||||
var reImportSpec = regexp.MustCompile(`(["'])((?:\.\.?/)[^"']*?\.js)["']`)
|
||||
|
||||
// tsExtensions are the non-.js source extensions a .js specifier may
|
||||
// actually resolve to, in esbuild's resolution order.
|
||||
var tsExtensions = []string{".ts", ".tsx", ".jsx"}
|
||||
|
||||
// checkImportExtensions scans every JS/TS source under frontend/src for
|
||||
// relative import specifiers written with a ".js" extension whose literal
|
||||
// target does not exist but a sibling ".ts"/".tsx"/".jsx" does. esbuild
|
||||
// resolves these transparently, so they bundle fine — but the import path
|
||||
// lies about the file it points to. Specifiers that resolve to a real ".js"
|
||||
// file, and dangling specifiers with no sibling at all, are left for esbuild.
|
||||
func checkImportExtensions() []importViolation {
|
||||
var violations []importViolation
|
||||
srcRoot := filepath.Join(frontendDir, "src")
|
||||
|
||||
filepath.WalkDir(srcRoot, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return err
|
||||
}
|
||||
switch filepath.Ext(path) {
|
||||
case ".js", ".ts", ".tsx", ".jsx":
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dir := filepath.Dir(path)
|
||||
|
||||
for i, line := range strings.Split(string(raw), "\n") {
|
||||
for _, m := range reImportSpec.FindAllStringSubmatch(line, -1) {
|
||||
spec := m[2]
|
||||
if _, statErr := os.Stat(filepath.Join(dir, spec)); statErr == nil {
|
||||
continue // resolves to a real .js file — fine
|
||||
}
|
||||
base := strings.TrimSuffix(spec, ".js")
|
||||
for _, ext := range tsExtensions {
|
||||
if _, statErr := os.Stat(filepath.Join(dir, base+ext)); statErr == nil {
|
||||
violations = append(violations, importViolation{
|
||||
file: path,
|
||||
line: i + 1,
|
||||
spec: spec,
|
||||
actual: base + ext,
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
return violations
|
||||
}
|
||||
|
||||
func reportImportViolations(violations []importViolation) {
|
||||
fmt.Fprintf(os.Stderr, "\nImport extension check failed: %d import(s) use a .js extension for a non-.js file.\n", len(violations))
|
||||
for _, v := range violations {
|
||||
fmt.Fprintf(os.Stderr, " %s:%d: %q should be %q\n", v.file, v.line, v.spec, v.actual)
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "Rename each specifier to match the file's real extension.")
|
||||
}
|
||||
198
go/bundler/js.go
Normal file
198
go/bundler/js.go
Normal file
@@ -0,0 +1,198 @@
|
||||
package bundler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"kjol/appenv"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
esbuild "github.com/evanw/esbuild/pkg/api"
|
||||
)
|
||||
|
||||
// esbuildDefine returns the compile-time constants substituted into every JS
|
||||
// bundle — both the production bundles here and the dev HMR per-module
|
||||
// transforms (hmr_server.go). __ENV_TYPE__ carries the Go compile-time
|
||||
// deployment environment (appenv.Environment) into the JS, where
|
||||
// frontend/src/env.ts reads it. The build-time SSR render deliberately does NOT
|
||||
// define it (see internal/bundler/ssr.go), so the env badge renders only after
|
||||
// the client takeover.
|
||||
func esbuildDefine() map[string]string {
|
||||
return map[string]string{
|
||||
"__ENV_TYPE__": strconv.Quote(appenv.Environment),
|
||||
}
|
||||
}
|
||||
|
||||
// resolveEntryPoint returns the path (relative to frontendDir) of the
|
||||
// SPA entry point, preferring app.ts over app.js.
|
||||
func resolveEntryPoint() string {
|
||||
if _, err := os.Stat(filepath.Join(frontendDir, "src/app.ts")); err == nil {
|
||||
return "src/app.ts"
|
||||
}
|
||||
return "src/app.js"
|
||||
}
|
||||
|
||||
// bundleJS bundles the SPA entry (app.ts/app.js) into bundle.min.js.
|
||||
func bundleJS() (bundleStats, error) {
|
||||
return bundleJSEntry(filepath.Join(frontendDir, resolveEntryPoint()), "bundle.min.js")
|
||||
}
|
||||
|
||||
// bundlePublicJS bundles the public-page takeover entry into
|
||||
// public.bundle.min.js. This is the client half of the SSR'd public pages:
|
||||
// it re-renders the Solid tree the server emitted (re-render takeover).
|
||||
func bundlePublicJS() (bundleStats, error) {
|
||||
return bundleJSEntry(filepath.Join(frontendDir, "src/public.tsx"), "public.bundle.min.js")
|
||||
}
|
||||
|
||||
// bundleJSEntry runs esbuild with the default-export shim plugin for one entry
|
||||
// point. Vendor modules (anything not a relative import) are kept external so
|
||||
// the import map resolves them at runtime. .tsx/.jsx files are Solid-compiled by
|
||||
// the Go-native compiler (Plugin); the SPA entry is solid-js/html tagged
|
||||
// templates (no JSX) and is unaffected.
|
||||
func bundleJSEntry(entry, outName string) (bundleStats, error) {
|
||||
// Keep `debugger` statements in development so they can be hit when
|
||||
// DevTools is attached; strip them in staging/production builds.
|
||||
var drop esbuild.Drop
|
||||
if appenv.Environment != appenv.EnvTypeDevelopment {
|
||||
drop = esbuild.DropDebugger
|
||||
}
|
||||
|
||||
vendorDir := filepath.Join(frontendDir, "vendor")
|
||||
|
||||
// Every bundled vendored package's entrypoint is declared in vendor.json rather
|
||||
// than discovered from each package's `exports` — those mis-resolve (e.g.
|
||||
// solid-js's bare core to its SSR build, dist/server.js, where createEffect/
|
||||
// onMount are no-ops, so the DOM renders but every effect is silently dead).
|
||||
entrypoints, err := loadVendorManifest(vendorDir)
|
||||
if err != nil {
|
||||
return bundleStats{}, fmt.Errorf("loading vendor manifest: %w", err)
|
||||
}
|
||||
|
||||
// The Go-native Solid compiler (Plugin) sits between the export shim and the
|
||||
// vendor resolvers so it compiles .tsx/.jsx before they're resolved.
|
||||
plugins := []esbuild.Plugin{defaultExportShimPlugin(), Plugin()}
|
||||
plugins = append(plugins, assetURLPlugin(), vendorManifestPlugin(vendorDir, entrypoints))
|
||||
|
||||
result := esbuild.Build(esbuild.BuildOptions{
|
||||
EntryPoints: []string{entry},
|
||||
Outfile: filepath.Join(outputDir, outName),
|
||||
Bundle: true,
|
||||
Write: true,
|
||||
Format: esbuild.FormatESModule,
|
||||
Target: esbuild.ES2022,
|
||||
Sourcemap: esbuild.SourceMapLinked,
|
||||
SourceRoot: "./",
|
||||
Outbase: "frontend",
|
||||
SourcesContent: esbuild.SourcesContentInclude,
|
||||
MinifyWhitespace: true,
|
||||
MinifyIdentifiers: true,
|
||||
MinifySyntax: true,
|
||||
Drop: drop,
|
||||
Define: esbuildDefine(),
|
||||
// Vendored packages live under frontend/vendor/<pkg> (npm-pack layout).
|
||||
// NodePaths makes esbuild resolve bare imports there and bundle + tree-shake
|
||||
// them like source (no node_modules). Bundling solid-js from a single vendored
|
||||
// copy — rather than leaving it external for the import map — keeps ONE reactive
|
||||
// runtime instance; a split instance silently breaks effect flushing (onMount
|
||||
// never fires). Browser + development conditions pick each package's DOM dev
|
||||
// build over any SSR entry its `module` field points at. Packages whose default
|
||||
// entry pulls an un-bundled dep tree point their own package.json at a
|
||||
// self-contained dist (e.g. pdf-lib's `browser` field) — config lives with the
|
||||
// package, not here. vendorResolvePlugin marks bare imports NOT under
|
||||
// frontend/vendor as external (import-map resolved), so the vendor set stays
|
||||
// data-driven with no per-package list in this source.
|
||||
Platform: esbuild.PlatformBrowser,
|
||||
Conditions: []string{"development"},
|
||||
NodePaths: []string{vendorDir},
|
||||
// Assets emitted by assetURLPlugin (e.g. the pdfjs worker, which can't be
|
||||
// inlined because it reads import.meta.url) land in wwwroot/vendor and are
|
||||
// referenced by a root-absolute URL.
|
||||
AssetNames: "vendor/[name]",
|
||||
PublicPath: "/",
|
||||
LogLevel: esbuild.LogLevelWarning,
|
||||
Plugins: plugins,
|
||||
})
|
||||
|
||||
if len(result.Errors) > 0 {
|
||||
for _, e := range result.Errors {
|
||||
loc := ""
|
||||
if e.Location != nil {
|
||||
loc = fmt.Sprintf("%s:%d:%d ", e.Location.File, e.Location.Line, e.Location.Column)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, " %s%s\n", loc, e.Text)
|
||||
}
|
||||
return bundleStats{}, fmt.Errorf("esbuild produced %d error(s)", len(result.Errors))
|
||||
}
|
||||
|
||||
jsPath := filepath.Join(outputDir, outName)
|
||||
if err := stripSourcemapParentPrefix(jsPath + ".map"); err != nil {
|
||||
return bundleStats{}, fmt.Errorf("rewriting sourcemap paths: %w", err)
|
||||
}
|
||||
info, err := os.Stat(jsPath)
|
||||
if err != nil {
|
||||
return bundleStats{}, err
|
||||
}
|
||||
return bundleStats{files: countSources(result), bytes: int(info.Size())}, nil
|
||||
}
|
||||
|
||||
// stripSourcemapParentPrefix rewrites the sourcemap's `sources` entries to
|
||||
// drop leading `../` segments. esbuild writes paths relative to the output
|
||||
// file's directory; since the bundle lives in wwwroot/ and the sources live
|
||||
// in frontend/, every entry starts with `../frontend/`. Stripping the prefix
|
||||
// yields project-rooted paths like `frontend/src/app.ts`.
|
||||
func stripSourcemapParentPrefix(mapPath string) error {
|
||||
data, err := os.ReadFile(mapPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var m map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
return err
|
||||
}
|
||||
raw, ok := m["sources"]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var sources []string
|
||||
if err := json.Unmarshal(raw, &sources); err != nil {
|
||||
return err
|
||||
}
|
||||
for i, s := range sources {
|
||||
for strings.HasPrefix(s, "../") {
|
||||
s = s[3:]
|
||||
}
|
||||
sources[i] = s
|
||||
}
|
||||
newRaw, err := json.Marshal(sources)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m["sources"] = newRaw
|
||||
out, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(mapPath, out, 0644)
|
||||
}
|
||||
|
||||
// countSources returns the number of input files that contributed to
|
||||
// the bundle, derived from esbuild's metafile. When the metafile is
|
||||
// not requested, falls back to the entry-count.
|
||||
func countSources(result esbuild.BuildResult) int {
|
||||
if result.Metafile == "" {
|
||||
return 1
|
||||
}
|
||||
// Each " \"path\":" line in the inputs section counts as a file.
|
||||
// Cheap heuristic — avoids pulling in encoding/json for a stat.
|
||||
idx := strings.Index(result.Metafile, `"inputs":{`)
|
||||
if idx < 0 {
|
||||
return 1
|
||||
}
|
||||
end := strings.Index(result.Metafile[idx:], `},"outputs"`)
|
||||
if end < 0 {
|
||||
return 1
|
||||
}
|
||||
return strings.Count(result.Metafile[idx:idx+end], `":{`)
|
||||
}
|
||||
352
go/bundler/js/ssr/dom.js
Normal file
352
go/bundler/js/ssr/dom.js
Normal file
@@ -0,0 +1,352 @@
|
||||
// Minimal server-side DOM for running solid-js/html + solid-js/web inside
|
||||
// goja. It implements only the surface Solid's client runtime actually
|
||||
// touches (enumerated from wwwroot/vendor/solid-js-web.js and
|
||||
// solid-js-html.js): linked-list tree mutation, template.innerHTML/.content,
|
||||
// element/text/comment creation, attributes, className/textContent, and
|
||||
// no-op event wiring.
|
||||
//
|
||||
// The tree is the source of truth as a doubly-linked list (firstChild,
|
||||
// nextSibling, ...) which is how the real DOM models it and what Solid's
|
||||
// clone-walk assumes. childNodes is a derived snapshot array so Solid's
|
||||
// `[...el.childNodes]` spreads work.
|
||||
//
|
||||
// HTML *parsing* (innerHTML setter) is the one genuinely hard operation, so
|
||||
// it is delegated to Go via __parseHTML (x/net/html) which returns a JSON
|
||||
// tree. Serialization back to a string is straightforward and lives here.
|
||||
// -mta
|
||||
|
||||
(function (global) {
|
||||
"use strict";
|
||||
|
||||
var ELEMENT_NODE = 1, TEXT_NODE = 3, COMMENT_NODE = 8, FRAGMENT_NODE = 11;
|
||||
|
||||
var VOID = {
|
||||
area: 1, base: 1, br: 1, col: 1, embed: 1, hr: 1, img: 1, input: 1,
|
||||
keygen: 1, link: 1, meta: 1, param: 1, source: 1, track: 1, wbr: 1,
|
||||
};
|
||||
|
||||
var SVG_NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
function escapeText(s) {
|
||||
return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
function escapeAttr(s) {
|
||||
return String(s).replace(/&/g, "&").replace(/"/g, """);
|
||||
}
|
||||
|
||||
// ---- Node ----------------------------------------------------------
|
||||
|
||||
class Node {
|
||||
constructor(type) {
|
||||
this.nodeType = type;
|
||||
this.parentNode = null;
|
||||
this.firstChild = null;
|
||||
this.lastChild = null;
|
||||
this.previousSibling = null;
|
||||
this.nextSibling = null;
|
||||
this._$host = null;
|
||||
this.host = null;
|
||||
}
|
||||
|
||||
get childNodes() {
|
||||
var out = [], n = this.firstChild;
|
||||
while (n) { out.push(n); n = n.nextSibling; }
|
||||
return out;
|
||||
}
|
||||
|
||||
appendChild(child) {
|
||||
detach(child);
|
||||
child.parentNode = this;
|
||||
child.previousSibling = this.lastChild;
|
||||
child.nextSibling = null;
|
||||
if (this.lastChild) this.lastChild.nextSibling = child;
|
||||
else this.firstChild = child;
|
||||
this.lastChild = child;
|
||||
return child;
|
||||
}
|
||||
|
||||
insertBefore(child, ref) {
|
||||
if (ref == null) return this.appendChild(child);
|
||||
if (ref.parentNode !== this) throw new Error("insertBefore: ref not a child");
|
||||
detach(child);
|
||||
child.parentNode = this;
|
||||
child.nextSibling = ref;
|
||||
child.previousSibling = ref.previousSibling;
|
||||
if (ref.previousSibling) ref.previousSibling.nextSibling = child;
|
||||
else this.firstChild = child;
|
||||
ref.previousSibling = child;
|
||||
return child;
|
||||
}
|
||||
|
||||
removeChild(child) {
|
||||
if (child.parentNode !== this) throw new Error("removeChild: not a child");
|
||||
detach(child);
|
||||
return child;
|
||||
}
|
||||
|
||||
replaceChild(newNode, oldNode) {
|
||||
this.insertBefore(newNode, oldNode);
|
||||
this.removeChild(oldNode);
|
||||
return oldNode;
|
||||
}
|
||||
|
||||
remove() { detach(this); }
|
||||
|
||||
replaceWith() {
|
||||
var args = Array.prototype.slice.call(arguments);
|
||||
var parent = this.parentNode, ref = this.nextSibling;
|
||||
if (!parent) return;
|
||||
detach(this);
|
||||
for (var i = 0; i < args.length; i++) {
|
||||
var a = args[i];
|
||||
if (typeof a === "string") a = new Text(a);
|
||||
parent.insertBefore(a, ref);
|
||||
}
|
||||
}
|
||||
|
||||
cloneNode(deep) {
|
||||
var copy = this._shallowClone();
|
||||
if (deep) {
|
||||
var n = this.firstChild;
|
||||
while (n) { copy.appendChild(n.cloneNode(true)); n = n.nextSibling; }
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
get textContent() {
|
||||
if (this.nodeType === TEXT_NODE || this.nodeType === COMMENT_NODE) return this.data;
|
||||
var out = "", n = this.firstChild;
|
||||
while (n) {
|
||||
if (n.nodeType !== COMMENT_NODE) out += n.textContent;
|
||||
n = n.nextSibling;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
set textContent(value) {
|
||||
if (this.nodeType === TEXT_NODE || this.nodeType === COMMENT_NODE) { this.data = String(value); return; }
|
||||
while (this.firstChild) this.removeChild(this.firstChild);
|
||||
if (value !== "" && value != null) this.appendChild(new Text(String(value)));
|
||||
}
|
||||
|
||||
querySelectorAll(sel) { return querySelectorAll(this, sel); }
|
||||
querySelector(sel) { var r = querySelectorAll(this, sel); return r.length ? r[0] : null; }
|
||||
}
|
||||
|
||||
function detach(node) {
|
||||
var p = node.parentNode;
|
||||
if (!p) return;
|
||||
if (node.previousSibling) node.previousSibling.nextSibling = node.nextSibling;
|
||||
else p.firstChild = node.nextSibling;
|
||||
if (node.nextSibling) node.nextSibling.previousSibling = node.previousSibling;
|
||||
else p.lastChild = node.previousSibling;
|
||||
node.parentNode = null;
|
||||
node.previousSibling = null;
|
||||
node.nextSibling = null;
|
||||
}
|
||||
|
||||
// ---- Text / Comment ------------------------------------------------
|
||||
|
||||
class Text extends Node {
|
||||
constructor(data) { super(TEXT_NODE); this.data = data == null ? "" : String(data); this.nodeName = "#text"; }
|
||||
_shallowClone() { return new Text(this.data); }
|
||||
}
|
||||
|
||||
class Comment extends Node {
|
||||
constructor(data) { super(COMMENT_NODE); this.data = data == null ? "" : String(data); this.nodeName = "#comment"; }
|
||||
_shallowClone() { return new Comment(this.data); }
|
||||
}
|
||||
|
||||
// ---- Element -------------------------------------------------------
|
||||
|
||||
class Element extends Node {
|
||||
constructor(tagName, ns) {
|
||||
super(ELEMENT_NODE);
|
||||
this.tagName = tagName;
|
||||
this.localName = String(tagName).toLowerCase();
|
||||
this.nodeName = this.localName;
|
||||
this.namespaceURI = ns || null;
|
||||
this.attributes = {};
|
||||
this._style = null;
|
||||
this._classList = null;
|
||||
if (this.localName === "template") this.content = new Fragment();
|
||||
}
|
||||
|
||||
_shallowClone() {
|
||||
var copy = new Element(this.tagName, this.namespaceURI);
|
||||
for (var k in this.attributes) copy.attributes[k] = this.attributes[k];
|
||||
if (this.content) {
|
||||
var n = this.content.firstChild;
|
||||
while (n) { copy.content.appendChild(n.cloneNode(true)); n = n.nextSibling; }
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
setAttribute(name, value) { this.attributes[name] = String(value); }
|
||||
setAttributeNS(_ns, name, value) { this.attributes[name] = String(value); }
|
||||
getAttribute(name) { return name in this.attributes ? this.attributes[name] : null; }
|
||||
hasAttribute(name) { return name in this.attributes; }
|
||||
removeAttribute(name) { delete this.attributes[name]; }
|
||||
removeAttributeNS(_ns, name) { delete this.attributes[name]; }
|
||||
|
||||
get className() { return this.attributes["class"] || ""; }
|
||||
set className(v) { this.attributes["class"] = String(v); }
|
||||
|
||||
get id() { return this.attributes["id"] || ""; }
|
||||
set id(v) { this.attributes["id"] = String(v); }
|
||||
|
||||
get innerHTML() { return serializeChildren(this); }
|
||||
set innerHTML(htmlStr) {
|
||||
var target = this.content ? this.content : this;
|
||||
while (target.firstChild) target.removeChild(target.firstChild);
|
||||
var json = global.__parseHTML(String(htmlStr));
|
||||
var nodes = buildNodes(JSON.parse(json));
|
||||
for (var i = 0; i < nodes.length; i++) target.appendChild(nodes[i]);
|
||||
}
|
||||
|
||||
get style() {
|
||||
if (!this._style) this._style = makeStyle(this);
|
||||
return this._style;
|
||||
}
|
||||
get classList() {
|
||||
if (!this._classList) this._classList = makeClassList(this);
|
||||
return this._classList;
|
||||
}
|
||||
|
||||
// Event wiring is irrelevant to server output.
|
||||
addEventListener() {}
|
||||
removeEventListener() {}
|
||||
}
|
||||
|
||||
class Fragment extends Node {
|
||||
constructor() { super(FRAGMENT_NODE); this.nodeName = "#document-fragment"; }
|
||||
_shallowClone() { return new Fragment(); }
|
||||
}
|
||||
|
||||
// ---- style / classList shims --------------------------------------
|
||||
|
||||
function makeStyle(el) {
|
||||
return {
|
||||
setProperty: function (k, v) {
|
||||
var cur = parseStyle(el.attributes["style"] || "");
|
||||
cur[k] = v;
|
||||
el.attributes["style"] = stringifyStyle(cur);
|
||||
},
|
||||
removeProperty: function (k) {
|
||||
var cur = parseStyle(el.attributes["style"] || "");
|
||||
delete cur[k];
|
||||
el.attributes["style"] = stringifyStyle(cur);
|
||||
},
|
||||
get cssText() { return el.attributes["style"] || ""; },
|
||||
set cssText(v) { el.attributes["style"] = String(v); },
|
||||
};
|
||||
}
|
||||
function parseStyle(s) {
|
||||
var out = {};
|
||||
s.split(";").forEach(function (decl) {
|
||||
var i = decl.indexOf(":");
|
||||
if (i > -1) out[decl.slice(0, i).trim()] = decl.slice(i + 1).trim();
|
||||
});
|
||||
return out;
|
||||
}
|
||||
function stringifyStyle(o) {
|
||||
return Object.keys(o).map(function (k) { return k + ":" + o[k]; }).join(";");
|
||||
}
|
||||
|
||||
function makeClassList(el) {
|
||||
function read() { return (el.attributes["class"] || "").split(/\s+/).filter(Boolean); }
|
||||
function write(list) { el.attributes["class"] = list.join(" "); }
|
||||
return {
|
||||
add: function () { var l = read(); for (var i = 0; i < arguments.length; i++) if (l.indexOf(arguments[i]) < 0) l.push(arguments[i]); write(l); },
|
||||
remove: function () { var l = read(), a = Array.prototype.slice.call(arguments); write(l.filter(function (c) { return a.indexOf(c) < 0; })); },
|
||||
toggle: function (c, force) { var l = read(), has = l.indexOf(c) > -1; if (force === undefined ? has : !force) write(l.filter(function (x) { return x !== c; })); else if (!has) { l.push(c); write(l); } },
|
||||
contains: function (c) { return read().indexOf(c) > -1; },
|
||||
};
|
||||
}
|
||||
|
||||
// ---- build from parsed JSON ---------------------------------------
|
||||
|
||||
function buildNodes(arr) {
|
||||
var out = [];
|
||||
for (var i = 0; i < arr.length; i++) out.push(buildNode(arr[i]));
|
||||
return out;
|
||||
}
|
||||
function buildNode(j) {
|
||||
if (j.t === "t") return new Text(j.d);
|
||||
if (j.t === "c") return new Comment(j.d);
|
||||
var el = new Element(j.n, j.ns === "svg" ? SVG_NS : null);
|
||||
if (j.a) for (var k in j.a) el.attributes[k] = j.a[k];
|
||||
if (j.c) for (var i = 0; i < j.c.length; i++) el.appendChild(buildNode(j.c[i]));
|
||||
return el;
|
||||
}
|
||||
|
||||
// ---- serialization -------------------------------------------------
|
||||
|
||||
function serializeChildren(node) {
|
||||
var out = "", n = node.firstChild;
|
||||
while (n) { out += serializeNode(n); n = n.nextSibling; }
|
||||
return out;
|
||||
}
|
||||
function serializeNode(node) {
|
||||
if (node.nodeType === TEXT_NODE) return escapeText(node.data);
|
||||
// "#" is solid-js/html's template insertion placeholder; any that
|
||||
// survive instantiation are framework artifacts, not page content.
|
||||
if (node.nodeType === COMMENT_NODE) return node.data === "#" ? "" : "<!--" + node.data + "-->";
|
||||
if (node.nodeType === FRAGMENT_NODE) return serializeChildren(node);
|
||||
// Output the original-case tag (SVG is case-sensitive: viewBox,
|
||||
// linearGradient); use the lowercased localName only for lookups.
|
||||
var tag = node.tagName, lname = node.localName;
|
||||
var s = "<" + tag;
|
||||
for (var k in node.attributes) s += " " + k + '="' + escapeAttr(node.attributes[k]) + '"';
|
||||
s += ">";
|
||||
if (VOID[lname]) return s;
|
||||
if (lname === "template" && node.content) s += serializeChildren(node.content);
|
||||
else s += serializeChildren(node);
|
||||
return s + "</" + tag + ">";
|
||||
}
|
||||
|
||||
// ---- minimal querySelectorAll (only script,style and *[data-hk]) ---
|
||||
|
||||
function querySelectorAll(root, sel) {
|
||||
var wantHk = /\[data-hk\]/.test(sel);
|
||||
var tags = sel.split(",").map(function (s) { return s.trim().replace(/\[.*\]/, "").replace("*", "").toLowerCase(); }).filter(Boolean);
|
||||
var out = [];
|
||||
(function walk(n) {
|
||||
var c = n.firstChild;
|
||||
while (c) {
|
||||
if (c.nodeType === ELEMENT_NODE) {
|
||||
if (wantHk && c.attributes["data-hk"] != null) out.push(c);
|
||||
else if (tags.indexOf(c.localName) > -1) out.push(c);
|
||||
walk(c);
|
||||
}
|
||||
c = c.nextSibling;
|
||||
}
|
||||
})(root.content || root);
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---- document ------------------------------------------------------
|
||||
|
||||
var document = {
|
||||
createElement: function (tag) { return new Element(tag, null); },
|
||||
createElementNS: function (ns, tag) { return new Element(tag, ns); },
|
||||
createTextNode: function (data) { return new Text(data); },
|
||||
createComment: function (data) { return new Comment(data); },
|
||||
createDocumentFragment: function () { return new Fragment(); },
|
||||
importNode: function (node, deep) { return node.cloneNode(deep); },
|
||||
addEventListener: function () {},
|
||||
removeEventListener: function () {},
|
||||
nodeType: 9,
|
||||
};
|
||||
|
||||
global.document = document;
|
||||
global.Node = Node;
|
||||
global.Element = Element;
|
||||
global.Text = Text;
|
||||
global.Comment = Comment;
|
||||
global.window = global;
|
||||
|
||||
// Serialize a node's children (innerHTML) — the Go side calls this to
|
||||
// extract the rendered markup from the render root.
|
||||
global.__serialize = function (node) { return serializeChildren(node); };
|
||||
|
||||
})(globalThis);
|
||||
94
go/bundler/jsx.go
Normal file
94
go/bundler/jsx.go
Normal file
@@ -0,0 +1,94 @@
|
||||
// Solid JSX/TSX compilation (part of package bundler): compiles Solid JSX/TSX to
|
||||
// optimized Solid dom-expressions output (template cloning + fine-grained
|
||||
// updates) with a Go-native compiler — no Node, no Babel, no goja. The compiler
|
||||
// lives in compile_solid.go (parser) and compile_solid_gen.go (codegen);
|
||||
// segment.go supplies the top-level splitter it uses to find component
|
||||
// declarations for solid-refresh instrumentation.
|
||||
//
|
||||
// Compile is the prod path (SSR + release bundles); CompileDev adds solid-refresh
|
||||
// HMR instrumentation. Both are plain function calls — fast enough that the old
|
||||
// per-declaration incremental caching is gone. A small in-memory cache dedups
|
||||
// identical transforms within a process (e.g. a module served to several page
|
||||
// loads under HMR); there is no on-disk cache and no compiler bootstrap.
|
||||
package bundler
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
esbuild "github.com/evanw/esbuild/pkg/api"
|
||||
)
|
||||
|
||||
// cache dedups transforms by content within a process, so a module unchanged
|
||||
// across dev page reloads (or repeated in a build) isn't recompiled.
|
||||
var cache = struct {
|
||||
sync.RWMutex
|
||||
m map[string]string
|
||||
}{m: map[string]string{}}
|
||||
|
||||
// cacheKey keys a transform by filename + mode + source.
|
||||
func cacheKey(src, filename string, dev bool) string {
|
||||
h := sha256.New()
|
||||
h.Write([]byte(filename))
|
||||
h.Write([]byte{0})
|
||||
if dev {
|
||||
h.Write([]byte{1})
|
||||
} else {
|
||||
h.Write([]byte{0})
|
||||
}
|
||||
h.Write([]byte{0})
|
||||
h.Write([]byte(src))
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
// Compile compiles one JSX/TSX source to Solid dom-expressions output (prod: no
|
||||
// HMR instrumentation). Results are cached by content for this process.
|
||||
func Compile(src, filename string) (string, error) { return compileCached(src, filename, false) }
|
||||
|
||||
// CompileDev is Compile with solid-refresh HMR instrumentation — the dev module
|
||||
// server's entry.
|
||||
func CompileDev(src, filename string) (string, error) { return compileCached(src, filename, true) }
|
||||
|
||||
func compileCached(src, filename string, dev bool) (string, error) {
|
||||
key := cacheKey(src, filename, dev)
|
||||
cache.RLock()
|
||||
if out, ok := cache.m[key]; ok {
|
||||
cache.RUnlock()
|
||||
return out, nil
|
||||
}
|
||||
cache.RUnlock()
|
||||
|
||||
out, err := compileSolidGo(src, filename, dev)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
cache.Lock()
|
||||
cache.m[key] = out
|
||||
cache.Unlock()
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Plugin returns the esbuild OnLoad hook that Solid-compiles every .tsx/.jsx file
|
||||
// in the bundle graph (prod path). Plain .ts/.js files are left to esbuild.
|
||||
func Plugin() esbuild.Plugin {
|
||||
return esbuild.Plugin{
|
||||
Name: "solid-jsx",
|
||||
Setup: func(b esbuild.PluginBuild) {
|
||||
b.OnLoad(esbuild.OnLoadOptions{Filter: `\.(tsx|jsx)$`}, func(a esbuild.OnLoadArgs) (esbuild.OnLoadResult, error) {
|
||||
data, err := os.ReadFile(a.Path)
|
||||
if err != nil {
|
||||
return esbuild.OnLoadResult{}, err
|
||||
}
|
||||
out, err := Compile(string(data), filepath.Base(a.Path))
|
||||
if err != nil {
|
||||
return esbuild.OnLoadResult{}, err
|
||||
}
|
||||
loader := esbuild.LoaderJS
|
||||
return esbuild.OnLoadResult{Contents: &out, Loader: loader}, nil
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
43
go/bundler/jsx_bench_test.go
Normal file
43
go/bundler/jsx_bench_test.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package bundler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func benchRead(b *testing.B, rel string) string {
|
||||
b.Helper()
|
||||
data, err := os.ReadFile(filepath.Join("..", "..", "frontend", "src", rel))
|
||||
if err != nil {
|
||||
b.Skipf("cannot read %s: %v", rel, err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
var benchComponents = []string{
|
||||
"ui/AutoTable.tsx", // ~212 KB
|
||||
"ui/Forms.tsx", // ~75 KB
|
||||
"pages/app/user/Profile.tsx", // ~26 KB
|
||||
"pages/public/PublicLayout.tsx", // ~27 KB
|
||||
}
|
||||
|
||||
// BenchmarkCompileDev times the full dev HMR compile (Babel solid + solid-refresh
|
||||
// in goja) per component, busting the cache each iteration so every run is a real
|
||||
// recompile — the cost the browser waits on for a single-file HMR update. This is
|
||||
// the harness for judging whether a compile-path change actually moves the needle;
|
||||
// as of this writing the goja/Babel transform dominates (seconds for large files).
|
||||
func BenchmarkCompileDev(b *testing.B) {
|
||||
for _, rel := range benchComponents {
|
||||
src := benchRead(b, rel)
|
||||
b.Run(rel, func(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
id := fmt.Sprintf("bench/c%d.tsx", i) // unique id -> cache miss
|
||||
if _, err := CompileDev(src, id); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
48
go/bundler/jsx_dev_test.go
Normal file
48
go/bundler/jsx_dev_test.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package bundler
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// CompileDev should Solid-compile the component AND wrap it with solid-refresh
|
||||
// HMR instrumentation: an import from the solid-refresh runtime, a registry hot
|
||||
// boundary, and an import.meta.hot accept (bundler:"esm"). None of these appear
|
||||
// in the plain (prod) Compile output.
|
||||
func TestCompileDevSolidRefresh(t *testing.T) {
|
||||
src := `import { createSignal } from "solid-js";
|
||||
export default function Counter() {
|
||||
const [c, setC] = createSignal(0);
|
||||
return <button onclick={() => setC(c() + 1)}>Count: {c()}</button>;
|
||||
}
|
||||
`
|
||||
dev, err := CompileDev(src, "ui/Counter.tsx")
|
||||
if err != nil {
|
||||
t.Fatalf("CompileDev: %v", err)
|
||||
}
|
||||
t.Logf("DEV OUTPUT:\n%s", dev)
|
||||
|
||||
for _, want := range []string{
|
||||
`solid-refresh`, // runtime import
|
||||
"$$registry", // HMR registry boundary
|
||||
"import.meta.hot", // esm bundler hot API
|
||||
"_tmpl$", // still Solid-compiled
|
||||
} {
|
||||
if !strings.Contains(dev, want) {
|
||||
t.Errorf("dev output missing %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
// Prod compile of the same source must NOT carry refresh instrumentation,
|
||||
// and (regression for the mode-keyed cache) must differ from the dev output.
|
||||
prod, err := Compile(src, "ui/Counter.tsx")
|
||||
if err != nil {
|
||||
t.Fatalf("Compile: %v", err)
|
||||
}
|
||||
if strings.Contains(prod, "solid-refresh") || strings.Contains(prod, "import.meta.hot") {
|
||||
t.Errorf("prod output leaked HMR instrumentation:\n%s", prod)
|
||||
}
|
||||
if prod == dev {
|
||||
t.Errorf("mode-keyed cache broken: dev and prod output identical")
|
||||
}
|
||||
}
|
||||
61
go/bundler/jsx_test.go
Normal file
61
go/bundler/jsx_test.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package bundler
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Compiling a Solid component should yield Solid's optimized dom output: a
|
||||
// template clone, fine-grained insert, delegated events, and imports from
|
||||
// solid-js/web — none of which a runtime factory would emit. TS types must be
|
||||
// stripped too.
|
||||
func TestCompileSolidComponent(t *testing.T) {
|
||||
src := `import { createSignal } from "solid-js";
|
||||
|
||||
interface Props { start: number }
|
||||
|
||||
export function Counter(props: Props) {
|
||||
const [c, setC] = createSignal<number>(props.start);
|
||||
return <button class="btn" onclick={() => setC(c() + 1)}>Count: {c()}</button>;
|
||||
}
|
||||
`
|
||||
out, err := Compile(src, "Counter.tsx")
|
||||
if err != nil {
|
||||
t.Fatalf("Compile: %v", err)
|
||||
}
|
||||
t.Logf("OUTPUT:\n%s", out)
|
||||
|
||||
for _, want := range []string{
|
||||
`from "solid-js/web"`, // compiled helpers
|
||||
"_tmpl$", // template clone
|
||||
"template(", // template factory
|
||||
"createSignal", // user code preserved
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("missing %q", want)
|
||||
}
|
||||
}
|
||||
// TS type syntax must be gone.
|
||||
for _, bad := range []string{"interface Props", ": Props", "<number>"} {
|
||||
if strings.Contains(out, bad) {
|
||||
t.Errorf("TS syntax leaked: %q", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A second call with identical input hits the cache and still returns the same
|
||||
// compiled output.
|
||||
func TestCompileCache(t *testing.T) {
|
||||
src := `export function A() { return <div>hi</div>; }`
|
||||
a, err := Compile(src, "A.tsx")
|
||||
if err != nil {
|
||||
t.Fatalf("first: %v", err)
|
||||
}
|
||||
b, err := Compile(src, "A.tsx")
|
||||
if err != nil {
|
||||
t.Fatalf("second: %v", err)
|
||||
}
|
||||
if a != b {
|
||||
t.Errorf("cached output differs")
|
||||
}
|
||||
}
|
||||
228
go/bundler/renderer.go
Normal file
228
go/bundler/renderer.go
Normal file
@@ -0,0 +1,228 @@
|
||||
package bundler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// devPageRenderers caches one dev Renderer per public-page SSR entry so repeated
|
||||
// requests reuse the cached HTML until frontend/src changes.
|
||||
var devPageRenderers sync.Map // entry string -> *Renderer
|
||||
|
||||
// DevRenderPublicPage live-renders a public page's SSR body for the dev server,
|
||||
// re-rendering only when frontend/src changes (Renderer's dev-mode mtime check).
|
||||
// module is relative to frontend/src (e.g. "pages/public/AboutUs.tsx"), component
|
||||
// is the exported body name, and currentPath drives PublicLayout's active nav.
|
||||
// The server runs from the repo root, so projectRoot is ".".
|
||||
func DevRenderPublicPage(module, component, currentPath string) (string, error) {
|
||||
fullModule := filepath.ToSlash(filepath.Join("frontend", "src", module))
|
||||
entry := ssrEntrySolid(fullModule, component, currentPath)
|
||||
r, _ := devPageRenderers.LoadOrStore(entry, NewRenderer(".", entry, true))
|
||||
return r.(*Renderer).HTML()
|
||||
}
|
||||
|
||||
// devPublicBundles caches one compiled SSR bundle per ISR entry, re-bundled only
|
||||
// when frontend/src changes. Unlike devPageRenderers (which caches the finished
|
||||
// data-free HTML), ISR renders with fresh data each request, so we cache the
|
||||
// bundle and re-render it per request.
|
||||
var devPublicBundles sync.Map // entry string -> *devBundle
|
||||
|
||||
type devBundle struct {
|
||||
root, entry string
|
||||
mu sync.Mutex
|
||||
bundle string
|
||||
builtAt time.Time
|
||||
ok bool
|
||||
}
|
||||
|
||||
func (b *devBundle) get() (string, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if b.ok && !newestSourceMtime(b.root).After(b.builtAt) {
|
||||
return b.bundle, nil
|
||||
}
|
||||
bundled, err := BundleEntry(b.entry, b.root)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b.bundle, b.builtAt, b.ok = bundled, newestSourceMtime(b.root), true
|
||||
return bundled, nil
|
||||
}
|
||||
|
||||
// DevRenderPublicPageWithData live-renders an ISR public page in dev: it bundles
|
||||
// the page from current source (re-bundling only when frontend/src changes) and
|
||||
// renders it with the supplied server data. So editing an ISR page's component
|
||||
// shows on the next reload even with JS disabled — only the Go data-loading code
|
||||
// (the real server component) still needs a server restart.
|
||||
func DevRenderPublicPageWithData(module, component, currentPath, dataJSON string) (string, error) {
|
||||
fullModule := filepath.ToSlash(filepath.Join("frontend", "src", module))
|
||||
entry := ssrEntrySolid(fullModule, component, currentPath)
|
||||
bv, _ := devPublicBundles.LoadOrStore(entry, &devBundle{root: ".", entry: entry})
|
||||
bundle, err := bv.(*devBundle).get()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return RenderBundleWithData(bundle, dataJSON)
|
||||
}
|
||||
|
||||
// RewarmDevPublicPages re-renders, in the background, every public page whose dev
|
||||
// SSR has already been requested — so after an edit the fresh no-JS SSR is ready
|
||||
// before the next reload instead of being rendered lazily on it. The dev watcher
|
||||
// calls this on a source change. Each Renderer only actually re-renders if
|
||||
// frontend/src changed, so redundant calls are cheap; errors are left for the
|
||||
// request path to surface.
|
||||
func RewarmDevPublicPages() {
|
||||
devPageRenderers.Range(func(_, v any) bool {
|
||||
go v.(*Renderer).HTML()
|
||||
return true
|
||||
})
|
||||
devPublicBundles.Range(func(_, v any) bool {
|
||||
go v.(*devBundle).get()
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// newestSourceMtime returns the newest modification time under
|
||||
// projectRoot/frontend/src, or the zero time if the tree can't be walked
|
||||
// (treated as "unchanged"). Drives the dev-mode rebuild checks.
|
||||
func newestSourceMtime(projectRoot string) time.Time {
|
||||
var newest time.Time
|
||||
srcDir := filepath.Join(projectRoot, "frontend", "src")
|
||||
filepath.WalkDir(srcDir, func(_ string, d os.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if info, e := d.Info(); e == nil && info.ModTime().After(newest) {
|
||||
newest = info.ModTime()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return newest
|
||||
}
|
||||
|
||||
// Renderer renders one public-page entry to HTML and caches the result.
|
||||
// Because SSR output is data-free it is identical every request, so a cached
|
||||
// string can be reused indefinitely. In dev the cache is invalidated when any
|
||||
// file under frontend/src changes (a cheap mtime scan), so source edits show
|
||||
// up on reload without a restart and without paying the ~1s bundle+render on
|
||||
// every request.
|
||||
type Renderer struct {
|
||||
projectRoot string
|
||||
entry string
|
||||
dev bool
|
||||
|
||||
mu sync.Mutex
|
||||
cached string
|
||||
builtAt time.Time // newest source mtime seen at last build
|
||||
ok bool
|
||||
}
|
||||
|
||||
func NewRenderer(projectRoot, entry string, dev bool) *Renderer {
|
||||
return &Renderer{projectRoot: projectRoot, entry: entry, dev: dev}
|
||||
}
|
||||
|
||||
// HTML returns the rendered markup, rebuilding only when necessary.
|
||||
func (r *Renderer) HTML() (string, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if r.ok {
|
||||
if !r.dev {
|
||||
return r.cached, nil
|
||||
}
|
||||
if !r.maxSourceMtime().After(r.builtAt) {
|
||||
return r.cached, nil // sources unchanged since last build
|
||||
}
|
||||
}
|
||||
|
||||
out, err := renderOnce(r.entry, r.projectRoot)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
r.cached, r.builtAt, r.ok = out, r.maxSourceMtime(), true
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// maxSourceMtime returns the newest modification time under frontend/src.
|
||||
func (r *Renderer) maxSourceMtime() time.Time { return newestSourceMtime(r.projectRoot) }
|
||||
|
||||
func renderOnce(entry, projectRoot string) (string, error) {
|
||||
bundle, err := BundleEntry(entry, projectRoot)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
eng, err := New()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := eng.LoadBundle(bundle); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return eng.Render()
|
||||
}
|
||||
|
||||
// RenderBundleWithData runs a pre-bundled render entry (baked into
|
||||
// public_pages.gen.go by RenderEntryFull) in goja with server data injected, and
|
||||
// returns the rendered HTML. This is the runtime ISR path — no esbuild, no
|
||||
// source files on disk, so it works in a single-binary release.
|
||||
func RenderBundleWithData(bundleJS, dataJSON string) (string, error) {
|
||||
eng, err := New()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := eng.SetServerData(dataJSON); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := eng.LoadBundle(bundleJS); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return eng.Render()
|
||||
}
|
||||
|
||||
// RenderEntryFull bundles and renders one SSR entry, returning the rendered
|
||||
// (data-free) skeleton HTML, the bundled JS, and the on-disk input files esbuild
|
||||
// pulled in. The bundler (genssr.go) uses this at build time: it
|
||||
// bakes the HTML for every page and, for dynamic (ISR) pages, also bakes the JS
|
||||
// so the server can re-render it with data at request time (RenderBundleWithData)
|
||||
// — no esbuild or source files on disk in production. The inputs feed
|
||||
// build-time change detection.
|
||||
func RenderEntryFull(entry, projectRoot string) (html, js string, inputs []string, err error) {
|
||||
bundled, metafile, err := bundleEntry(entry, projectRoot, true)
|
||||
if err != nil {
|
||||
return "", "", nil, err
|
||||
}
|
||||
eng, err := New()
|
||||
if err != nil {
|
||||
return "", "", nil, err
|
||||
}
|
||||
if err := eng.LoadBundle(bundled); err != nil {
|
||||
return "", "", nil, err
|
||||
}
|
||||
out, err := eng.Render()
|
||||
if err != nil {
|
||||
return "", "", nil, err
|
||||
}
|
||||
return out, bundled, metafileInputs(metafile), nil
|
||||
}
|
||||
|
||||
// metafileInputs returns the existing-on-disk input files from an esbuild
|
||||
// metafile (paths are relative to the build's working directory). Virtual
|
||||
// inputs like the inline entry are skipped — they don't os.Stat.
|
||||
func metafileInputs(metafile string) []string {
|
||||
var mf struct {
|
||||
Inputs map[string]json.RawMessage `json:"inputs"`
|
||||
}
|
||||
if metafile == "" || json.Unmarshal([]byte(metafile), &mf) != nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(mf.Inputs))
|
||||
for p := range mf.Inputs {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
out = append(out, filepath.ToSlash(p))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
293
go/bundler/segment.go
Normal file
293
go/bundler/segment.go
Normal file
@@ -0,0 +1,293 @@
|
||||
package bundler
|
||||
|
||||
// Top-level source segmentation. segmentTopLevel splits a module into contiguous
|
||||
// chunks at top-level declaration boundaries; the Go Solid compiler uses it to
|
||||
// find component declarations for solid-refresh instrumentation (wrapComponents
|
||||
// in compile_solid_gen.go).
|
||||
//
|
||||
// Correctness contract: join(chunks) == src, always. A chunk always begins at a
|
||||
// top-level declaration keyword and contains only whole top-level statements. The
|
||||
// splitter is deliberately conservative — when the lexer is unsure it simply
|
||||
// doesn't cut, producing fewer/larger chunks (still correct).
|
||||
|
||||
import "strings"
|
||||
|
||||
// declKeywords begin a top-level declaration. A line that starts (at brace depth
|
||||
// 0, outside any string/comment/template/regex) with one of these — as a whole
|
||||
// word — is a chunk boundary. `export` covers `export default`, `export const`,
|
||||
// `export function`, and re-exports; `async` covers `async function`.
|
||||
var declKeywords = []string{
|
||||
"import", "export", "const", "let", "var", "function",
|
||||
"async", "class", "type", "interface", "enum", "declare", "abstract",
|
||||
}
|
||||
|
||||
// regexPrefixKeywords are the identifiers after which a `/` begins a regex
|
||||
// literal rather than a division (e.g. `return /x/`), needed so the lexer keeps
|
||||
// an accurate brace depth through regexes that contain braces or quotes.
|
||||
var regexPrefixKeywords = map[string]bool{
|
||||
"return": true, "typeof": true, "instanceof": true, "in": true, "of": true,
|
||||
"new": true, "delete": true, "void": true, "do": true, "else": true,
|
||||
"yield": true, "await": true, "case": true,
|
||||
}
|
||||
|
||||
// segmentTopLevel splits src into chunks whose concatenation is exactly src.
|
||||
// Returns a single chunk (the whole source) when there is nothing safe to split.
|
||||
func segmentTopLevel(src string) []string {
|
||||
cuts := topLevelCuts(src)
|
||||
if len(cuts) <= 1 {
|
||||
return []string{src}
|
||||
}
|
||||
chunks := make([]string, 0, len(cuts))
|
||||
for i := range cuts {
|
||||
end := len(src)
|
||||
if i+1 < len(cuts) {
|
||||
end = cuts[i+1]
|
||||
}
|
||||
chunks = append(chunks, src[cuts[i]:end])
|
||||
}
|
||||
// Defensive: the construction above is lossless, but never return a
|
||||
// non-lossless split — a single chunk is always safe.
|
||||
if strings.Join(chunks, "") != src {
|
||||
return []string{src}
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
// lexical states
|
||||
const (
|
||||
stNormal = iota
|
||||
stLineComment
|
||||
stBlockComment
|
||||
stSingle // '...'
|
||||
stDouble // "..."
|
||||
stTemplate
|
||||
stRegex
|
||||
)
|
||||
|
||||
// topLevelCuts returns the sorted byte offsets at which chunks begin. Always
|
||||
// includes 0. A cut is placed at the start of any line that begins in column 0
|
||||
// (no leading whitespace) with a declaration keyword, provided the lexer is in a
|
||||
// clean state there — i.e. not inside a block comment, template body, or `${}`
|
||||
// interpolation that spans into this line.
|
||||
//
|
||||
// Column 0 is the top-level signal: these files indent everything inside a
|
||||
// function/JSX, so a keyword in column 0 is a top-level declaration. That lets
|
||||
// the lexer ignore brace depth and JSX entirely (JSX and nested statements are
|
||||
// always indented) — it need only track string/comment/template state so a
|
||||
// keyword *inside* a multi-line string or comment isn't mistaken for a boundary.
|
||||
// Regexes and single/double strings can't span lines, so any mis-lex of them
|
||||
// self-heals at the newline before the next candidate line.
|
||||
func topLevelCuts(src string) []int {
|
||||
cuts := []int{0}
|
||||
n := len(src)
|
||||
|
||||
state := stNormal
|
||||
depth := 0 // only tracked to match `${ ... }` interpolation braces
|
||||
// tmplStack holds the interpolation brace depth captured at each `${` so the
|
||||
// matching `}` resumes the template body instead of being counted as a plain
|
||||
// brace. Non-empty ⇒ we're inside an interpolation (line not a clean start).
|
||||
var tmplStack []int
|
||||
var prevSig byte // last significant byte, for regex-vs-division
|
||||
|
||||
addCut := func(off int) {
|
||||
if off > cuts[len(cuts)-1] {
|
||||
cuts = append(cuts, off)
|
||||
}
|
||||
}
|
||||
// A newline just moved us to lineStart; if the lexer is clean there and the
|
||||
// line begins in column 0 with a declaration keyword, it's a chunk boundary.
|
||||
checkCut := func(lineStart int) {
|
||||
if state == stNormal && len(tmplStack) == 0 && startsDeclKeyword(src, lineStart) {
|
||||
addCut(lineStart)
|
||||
}
|
||||
}
|
||||
|
||||
checkCut(0)
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
c := src[i]
|
||||
switch state {
|
||||
case stNormal:
|
||||
switch c {
|
||||
case '/':
|
||||
if i+1 < n && src[i+1] == '/' {
|
||||
state = stLineComment
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if i+1 < n && src[i+1] == '*' {
|
||||
state = stBlockComment
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if regexAllowed(src, i, prevSig) {
|
||||
state = stRegex
|
||||
prevSig = c
|
||||
continue
|
||||
}
|
||||
prevSig = c
|
||||
case '\'':
|
||||
state = stSingle
|
||||
prevSig = c
|
||||
case '"':
|
||||
state = stDouble
|
||||
prevSig = c
|
||||
case '`':
|
||||
state = stTemplate
|
||||
prevSig = c
|
||||
case '{', '(', '[':
|
||||
depth++
|
||||
prevSig = c
|
||||
case '}':
|
||||
if len(tmplStack) > 0 && depth == tmplStack[len(tmplStack)-1] {
|
||||
tmplStack = tmplStack[:len(tmplStack)-1]
|
||||
depth--
|
||||
state = stTemplate
|
||||
} else {
|
||||
if depth > 0 {
|
||||
depth--
|
||||
}
|
||||
prevSig = c
|
||||
}
|
||||
case ')', ']':
|
||||
if depth > 0 {
|
||||
depth--
|
||||
}
|
||||
prevSig = c
|
||||
case '\n':
|
||||
checkCut(i + 1)
|
||||
case ' ', '\t', '\r':
|
||||
// insignificant; leave prevSig
|
||||
default:
|
||||
prevSig = c
|
||||
}
|
||||
|
||||
case stLineComment:
|
||||
if c == '\n' {
|
||||
state = stNormal
|
||||
checkCut(i + 1)
|
||||
}
|
||||
|
||||
case stBlockComment:
|
||||
if c == '*' && i+1 < n && src[i+1] == '/' {
|
||||
state = stNormal
|
||||
i++
|
||||
}
|
||||
// a newline inside a block comment is not a clean start: no checkCut
|
||||
|
||||
case stSingle:
|
||||
if c == '\\' {
|
||||
i++
|
||||
} else if c == '\'' {
|
||||
state = stNormal
|
||||
prevSig = c
|
||||
} else if c == '\n' {
|
||||
state = stNormal // strings can't span lines; recover
|
||||
checkCut(i + 1)
|
||||
}
|
||||
|
||||
case stDouble:
|
||||
if c == '\\' {
|
||||
i++
|
||||
} else if c == '"' {
|
||||
state = stNormal
|
||||
prevSig = c
|
||||
} else if c == '\n' {
|
||||
state = stNormal
|
||||
checkCut(i + 1)
|
||||
}
|
||||
|
||||
case stTemplate:
|
||||
if c == '\\' {
|
||||
i++
|
||||
} else if c == '`' {
|
||||
state = stNormal
|
||||
prevSig = c
|
||||
} else if c == '$' && i+1 < n && src[i+1] == '{' {
|
||||
depth++
|
||||
tmplStack = append(tmplStack, depth)
|
||||
state = stNormal
|
||||
i++
|
||||
}
|
||||
// templates may span lines; the continuation is not a clean start
|
||||
|
||||
case stRegex:
|
||||
if c == '\\' {
|
||||
i++
|
||||
} else if c == '[' {
|
||||
for i++; i < n; i++ { // character class: skip to `]`
|
||||
if src[i] == '\\' {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if src[i] == ']' {
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if c == '/' {
|
||||
state = stNormal
|
||||
prevSig = c
|
||||
} else if c == '\n' {
|
||||
state = stNormal // regexes can't span lines; recover
|
||||
checkCut(i + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cuts
|
||||
}
|
||||
|
||||
// startsDeclKeyword reports whether src[i:] begins with a declaration keyword as
|
||||
// a whole word (the next character is not part of an identifier).
|
||||
func startsDeclKeyword(src string, i int) bool {
|
||||
for _, kw := range declKeywords {
|
||||
if strings.HasPrefix(src[i:], kw) {
|
||||
j := i + len(kw)
|
||||
if j >= len(src) || !isIdentPart(src[j]) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// regexAllowed reports whether a `/` at position i begins a regex literal (as
|
||||
// opposed to a division operator), from the preceding significant byte and, when
|
||||
// that byte ends an identifier, whether the identifier is a regex-prefix keyword.
|
||||
func regexAllowed(src string, i int, prevSig byte) bool {
|
||||
if prevSig == 0 {
|
||||
return true // start of input
|
||||
}
|
||||
if isIdentPart(prevSig) {
|
||||
// value context (identifier/number) unless the word is a keyword like
|
||||
// `return` after which a regex is expected.
|
||||
word := trailingWord(src, i)
|
||||
return regexPrefixKeywords[word]
|
||||
}
|
||||
switch prevSig {
|
||||
case ')', ']', '}':
|
||||
return false // end of a value/call/index
|
||||
default:
|
||||
// after operators, punctuation, `(`, `,`, `=`, etc. → regex expected
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// trailingWord returns the identifier word ending just before the run of
|
||||
// whitespace that precedes position i (used to classify the token before a `/`).
|
||||
func trailingWord(src string, i int) string {
|
||||
j := i
|
||||
for j > 0 && (src[j-1] == ' ' || src[j-1] == '\t' || src[j-1] == '\r' || src[j-1] == '\n') {
|
||||
j--
|
||||
}
|
||||
end := j
|
||||
for j > 0 && isIdentPart(src[j-1]) {
|
||||
j--
|
||||
}
|
||||
return src[j:end]
|
||||
}
|
||||
|
||||
func isIdentPart(b byte) bool {
|
||||
return b == '_' || b == '$' ||
|
||||
(b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9')
|
||||
}
|
||||
108
go/bundler/segment_test.go
Normal file
108
go/bundler/segment_test.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package bundler
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// walkRepoTSX returns every .tsx/.jsx under frontend/src (relative to the
|
||||
// package dir, which is internal/bundler).
|
||||
func walkRepoTSX(t *testing.T) []string {
|
||||
t.Helper()
|
||||
root := filepath.Join("..", "..", "frontend", "src")
|
||||
var files []string
|
||||
err := filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
switch filepath.Ext(p) {
|
||||
case ".tsx", ".jsx":
|
||||
files = append(files, p)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Skipf("cannot walk frontend/src: %v", err)
|
||||
}
|
||||
if len(files) == 0 {
|
||||
t.Skip("no .tsx files found")
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
// Segmentation must be lossless for every real component in the repo, and the
|
||||
// join invariant must hold exactly (byte-for-byte).
|
||||
func TestSegmentLosslessRepo(t *testing.T) {
|
||||
files := walkRepoTSX(t)
|
||||
totalChunks, multiChunkFiles := 0, 0
|
||||
for _, f := range files {
|
||||
data, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", f, err)
|
||||
}
|
||||
src := string(data)
|
||||
chunks := segmentTopLevel(src)
|
||||
if strings.Join(chunks, "") != src {
|
||||
t.Errorf("%s: segmentation not lossless (%d chunks)", f, len(chunks))
|
||||
continue
|
||||
}
|
||||
totalChunks += len(chunks)
|
||||
if len(chunks) > 1 {
|
||||
multiChunkFiles++
|
||||
}
|
||||
// Every chunk (except possibly the first) must begin at a declaration
|
||||
// keyword after optional leading blank lines/comments were attached to
|
||||
// the previous chunk — i.e. its first non-space line starts with a kw.
|
||||
}
|
||||
t.Logf("segmented %d files: %d split into >1 chunk, %d chunks total (avg %.1f)",
|
||||
len(files), multiChunkFiles, totalChunks, float64(totalChunks)/float64(len(files)))
|
||||
}
|
||||
|
||||
// Focused check on the pain file: it should split into many chunks so that
|
||||
// editing any single declaration recompiles only that declaration.
|
||||
func TestSegmentAutoTable(t *testing.T) {
|
||||
p := filepath.Join("..", "..", "frontend", "src", "ui", "AutoTable.tsx")
|
||||
data, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
t.Skipf("cannot read AutoTable: %v", err)
|
||||
}
|
||||
chunks := segmentTopLevel(string(data))
|
||||
if strings.Join(chunks, "") != string(data) {
|
||||
t.Fatal("AutoTable segmentation not lossless")
|
||||
}
|
||||
// Size distribution: how big is the largest chunk (the residual worst case)?
|
||||
maxLen, maxIdx := 0, 0
|
||||
for i, c := range chunks {
|
||||
if len(c) > maxLen {
|
||||
maxLen, maxIdx = len(c), i
|
||||
}
|
||||
}
|
||||
head := strings.TrimSpace(chunks[maxIdx])
|
||||
if len(head) > 80 {
|
||||
head = head[:80]
|
||||
}
|
||||
t.Logf("AutoTable: %d chunks, largest = %d bytes (%.0f%% of file), starts: %q",
|
||||
len(chunks), maxLen, 100*float64(maxLen)/float64(len(data)), head)
|
||||
if len(chunks) < 20 {
|
||||
t.Errorf("expected AutoTable to split into many chunks, got %d", len(chunks))
|
||||
}
|
||||
}
|
||||
|
||||
// A few hand-written cases exercise the lexer edge cases the repo may not cover.
|
||||
func TestSegmentEdgeCases(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"template with braces and decl-looking text": "const a = `x${ {y:1} }z\nconst notReal = 2`;\nexport const b = 3;\n",
|
||||
"regex with braces": "const re = /[{}]/g;\nfunction f() { return /a{2}/; }\nexport function g() {}\n",
|
||||
"block comment spanning decl keyword": "/*\nconst hidden = 1;\nfunction alsoHidden() {}\n*/\nexport const real = 1;\n",
|
||||
"string with keyword": "const s = \"export function fake() {}\";\nfunction real() {}\n",
|
||||
"nested template": "const t = `a${`b${1}c`}d`;\nexport const u = 1;\n",
|
||||
}
|
||||
for name, src := range cases {
|
||||
chunks := segmentTopLevel(src)
|
||||
if got := strings.Join(chunks, ""); got != src {
|
||||
t.Errorf("%s: not lossless\n src=%q\n got=%q", name, src, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
340
go/bundler/ssr.go
Normal file
340
go/bundler/ssr.go
Normal file
@@ -0,0 +1,340 @@
|
||||
// SSR (part of package bundler): server-renders the public-page Solid components
|
||||
// to HTML strings by running the (unmodified) client Solid runtime inside a goja
|
||||
// JS engine against a minimal Go-backed DOM (dom.js). Components are authored in
|
||||
// JSX/TSX and compiled to optimized Solid output at build time by the Go-native
|
||||
// compiler (compile_solid.go). The compiled code mounts via solid-js/web's
|
||||
// render into the DOM shim and __serialize walks the shim tree to HTML.
|
||||
//
|
||||
// The rendered HTML is the component's initial markup (data-free at build time, or
|
||||
// rendered with injected __SERVER_DATA__ for ISR); the browser bundle re-renders it
|
||||
// on load (Solid re-render takeover). The runtime ISR entry (RenderBundleWithData)
|
||||
// is imported by the server; the build-time entries are used by the bundler.
|
||||
//
|
||||
// -mta
|
||||
package bundler
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
_ "embed"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
esbuild "github.com/evanw/esbuild/pkg/api"
|
||||
"golang.org/x/net/html"
|
||||
"golang.org/x/net/html/atom"
|
||||
)
|
||||
|
||||
//go:embed js/ssr/dom.js
|
||||
var domJS string
|
||||
|
||||
// preludeJS installs the browser globals Solid's client runtime reaches for
|
||||
// that don't exist in goja. They are deliberately inert: no timer or
|
||||
// microtask fires during the synchronous render, and fetch never resolves —
|
||||
// so a component's onMount data load can't run, leaving exactly the
|
||||
// pre-hydration skeleton we want to serialize.
|
||||
const preludeJS = `
|
||||
var console = {
|
||||
log: function () { __log.apply(null, ["log"].concat(Array.prototype.slice.call(arguments))); },
|
||||
info: function () { __log.apply(null, ["info"].concat(Array.prototype.slice.call(arguments))); },
|
||||
warn: function () { __log.apply(null, ["warn"].concat(Array.prototype.slice.call(arguments))); },
|
||||
error: function () { __log.apply(null, ["error"].concat(Array.prototype.slice.call(arguments))); },
|
||||
debug: function () {},
|
||||
};
|
||||
globalThis.queueMicrotask = function (cb) { (globalThis.__mt || (globalThis.__mt = [])).push(cb); };
|
||||
globalThis.setTimeout = function () { return 0; };
|
||||
globalThis.clearTimeout = function () {};
|
||||
globalThis.setInterval = function () { return 0; };
|
||||
globalThis.clearInterval = function () {};
|
||||
globalThis.requestAnimationFrame = function () { return 0; };
|
||||
globalThis.cancelAnimationFrame = function () {};
|
||||
globalThis.fetch = function () {
|
||||
return Promise.resolve({ ok: false, status: 0, json: function () { return Promise.resolve(null); }, text: function () { return Promise.resolve(""); } });
|
||||
};
|
||||
globalThis._$HY = { events: [], completed: (typeof WeakSet !== "undefined" ? new WeakSet() : null), r: {}, done: true, fe: function () {} };
|
||||
// Marker so page components can render a lightweight skeleton during SSR and
|
||||
// defer heavy, browser-only UI (charts, PDF, icon fonts) to the client takeover.
|
||||
globalThis.__SSR__ = true;
|
||||
// Minimal Intl shim: goja has no Intl, but some modules construct formatters at
|
||||
// import time. The shim just needs to not throw; real formatting happens on the
|
||||
// client. format() returns the stringified input.
|
||||
if (typeof Intl === "undefined") {
|
||||
globalThis.Intl = {
|
||||
NumberFormat: function () { return { format: function (n) { return String(n); }, formatToParts: function (n) { return [{ type: "literal", value: String(n) }]; } }; },
|
||||
DateTimeFormat: function () { return { format: function (d) { return String(d); } }; },
|
||||
};
|
||||
}
|
||||
`
|
||||
|
||||
// Engine is a single goja runtime with the DOM shim installed. It is NOT
|
||||
// safe for concurrent use; the eventual handler keeps a pool of these.
|
||||
type Engine struct {
|
||||
vm *goja.Runtime
|
||||
}
|
||||
|
||||
// New builds a runtime, installs the Go bridges (__parseHTML, __log), runs
|
||||
// the prelude and the DOM shim, and returns a ready engine.
|
||||
func New() (*Engine, error) {
|
||||
vm := goja.New()
|
||||
|
||||
if err := vm.Set("__parseHTML", parseHTMLToJSON); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := vm.Set("__log", func(args ...interface{}) {
|
||||
fmt.Println(append([]interface{}{"[ssr]"}, args...)...)
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, err := vm.RunString(preludeJS); err != nil {
|
||||
return nil, fmt.Errorf("prelude: %w", err)
|
||||
}
|
||||
if _, err := vm.RunString(domJS); err != nil {
|
||||
return nil, fmt.Errorf("dom shim: %w", err)
|
||||
}
|
||||
|
||||
return &Engine{vm: vm}, nil
|
||||
}
|
||||
|
||||
// LoadBundle evaluates a bundled Solid entry (see BundleEntry). The entry is
|
||||
// expected to define globalThis.__render.
|
||||
func (e *Engine) LoadBundle(js string) error {
|
||||
_, err := e.vm.RunString(js)
|
||||
return err
|
||||
}
|
||||
|
||||
// Render invokes the entry's __render() and returns the serialized HTML.
|
||||
func (e *Engine) Render() (string, error) {
|
||||
v, err := e.vm.RunString("__render()")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return v.String(), nil
|
||||
}
|
||||
|
||||
// SetServerData installs the page's per-render data as globalThis.__SERVER_DATA__,
|
||||
// which components read (via the frontend serverData() accessor) to render with
|
||||
// real data instead of a skeleton. dataJSON must be a JSON document — it's valid
|
||||
// JS, wrapped in parens so an object literal parses as an expression. Used by the
|
||||
// ISR path (request-time render-with-data, cached); the static skeleton bake sets
|
||||
// nothing, so components fall back to placeholders there.
|
||||
func (e *Engine) SetServerData(dataJSON string) error {
|
||||
if dataJSON == "" {
|
||||
return nil
|
||||
}
|
||||
_, err := e.vm.RunString("globalThis.__SERVER_DATA__ = (" + dataJSON + ");")
|
||||
return err
|
||||
}
|
||||
|
||||
// BundleEntry bundles an inline JS entry into a single IIFE that goja can run,
|
||||
// resolving bare solid-js* specifiers to the vendored runtime files under
|
||||
// projectRoot/wwwroot/vendor. Any .tsx/.jsx pulled into the graph is Solid-
|
||||
// compiled by the Go-native compiler (Plugin). The generated entry itself is
|
||||
// plain JS — it uses solid-js/web's createComponent rather than JSX — so it needs
|
||||
// no transform.
|
||||
func BundleEntry(entrySource, projectRoot string) (string, error) {
|
||||
js, _, err := bundleEntry(entrySource, projectRoot, false)
|
||||
return js, err
|
||||
}
|
||||
|
||||
// ssrStubModule is the inert CommonJS module the ssr-stub plugin loads for every
|
||||
// browser-only library under SSR. A permissive Proxy satisfies any named or
|
||||
// default import and any no-op property access, call, construction, or assignment,
|
||||
// so it works for any package without knowing its shape.
|
||||
const ssrStubModule = `
|
||||
var handler = {
|
||||
get: function (_t, p) { return p === "__esModule" ? true : stub; },
|
||||
apply: function () { return stub; },
|
||||
construct: function () { return stub; },
|
||||
set: function () { return true; },
|
||||
};
|
||||
var stub = new Proxy(function () {}, handler);
|
||||
module.exports = stub;
|
||||
`
|
||||
|
||||
// bundleEntry is the shared esbuild pass behind BundleEntry. When withMeta is
|
||||
// set it also returns the esbuild metafile JSON, whose "inputs" map lets the
|
||||
// bundler discover (and fingerprint) the source files a page pulled in, for
|
||||
// build-time change detection. Computing the metafile is skipped otherwise.
|
||||
func bundleEntry(entrySource, projectRoot string, withMeta bool) (js, metafile string, err error) {
|
||||
// esbuild's Alias targets and ResolveDir must be absolute: a relative
|
||||
// target like "wwwroot/vendor/solid-js.js" (no leading "./") is read as a
|
||||
// bare package specifier and fails to resolve. Callers pass "." (the
|
||||
// server's cwd), so absolutize here.
|
||||
absRoot, err := filepath.Abs(projectRoot)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("resolve project root: %w", err)
|
||||
}
|
||||
vendorDir := filepath.Join(absRoot, "frontend", "vendor")
|
||||
|
||||
// vendor.json pins each vendored package's exact entrypoint file (see
|
||||
// vendor_plugins.go) because a package's own `exports`/`main`/`module` fields
|
||||
// mis-resolve under NodePaths resolution — e.g. solid-js/web's `module` field
|
||||
// points at dist/server.js (the seroval-based SSR build), not the DOM dev
|
||||
// build. The client bundle avoids that via vendorManifestPlugin; SSR needs the
|
||||
// same pin for the solid-js family it resolves for real (see stubPlugin below).
|
||||
vendorEntrypoints, err := loadVendorManifest(vendorDir)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("loading vendor manifest: %w", err)
|
||||
}
|
||||
|
||||
// The public pages are Solid/TSX. The .tsx files are Solid-compiled by the
|
||||
// Go-native compiler (Plugin); the compiled output +
|
||||
// the tagged-template pages import from solid-js/web. Resolve the solid runtime
|
||||
// from frontend/vendor (dev DOM builds — SSR renders into a DOM shim, not
|
||||
// renderToString), pinning bare solid-js* specifiers to their vendor.json
|
||||
// entrypoint (same single source of truth as the client bundle) and falling
|
||||
// back to NodePaths for any solid-js* subpath vendor.json doesn't list. Every
|
||||
// OTHER bare import (fontawesome, pdf-lib, pdfjs-dist, chart.js, @solidjs/router,
|
||||
// …) resolves to an inert stub: public pages skip their heavy UI under SSR
|
||||
// (globalThis.__SSR__), so the stub only needs to satisfy the graph. Generating
|
||||
// the stub means no hardcoded list and no stub files to maintain.
|
||||
stubPlugin := esbuild.Plugin{
|
||||
Name: "ssr-stub",
|
||||
Setup: func(build esbuild.PluginBuild) {
|
||||
build.OnResolve(esbuild.OnResolveOptions{Filter: `^[^./]`}, func(args esbuild.OnResolveArgs) (esbuild.OnResolveResult, error) {
|
||||
if args.Kind == esbuild.ResolveEntryPoint || filepath.IsAbs(args.Path) || strings.HasPrefix(args.Path, ".") {
|
||||
return esbuild.OnResolveResult{}, nil
|
||||
}
|
||||
if strings.HasPrefix(args.Path, "solid-js") {
|
||||
if target, ok := vendorEntrypoints[args.Path]; ok {
|
||||
abs, err := filepath.Abs(filepath.Join(vendorDir, filepath.FromSlash(target)))
|
||||
if err != nil {
|
||||
return esbuild.OnResolveResult{}, err
|
||||
}
|
||||
return esbuild.OnResolveResult{Path: filepath.ToSlash(abs)}, nil // pinned dev entrypoint
|
||||
}
|
||||
return esbuild.OnResolveResult{}, nil // unpinned subpath -> real solid runtime via NodePaths
|
||||
}
|
||||
return esbuild.OnResolveResult{Path: args.Path, Namespace: "ssr-stub"}, nil
|
||||
})
|
||||
build.OnLoad(esbuild.OnLoadOptions{Filter: `.*`, Namespace: "ssr-stub"}, func(args esbuild.OnLoadArgs) (esbuild.OnLoadResult, error) {
|
||||
contents := ssrStubModule
|
||||
loader := esbuild.LoaderJS
|
||||
return esbuild.OnLoadResult{Contents: &contents, Loader: loader}, nil
|
||||
})
|
||||
},
|
||||
}
|
||||
// The Go Solid compiler (Plugin) compiles .tsx before the catch-all stub sees
|
||||
// any of its imports.
|
||||
plugins := []esbuild.Plugin{Plugin(), stubPlugin}
|
||||
|
||||
result := esbuild.Build(esbuild.BuildOptions{
|
||||
Stdin: &esbuild.StdinOptions{
|
||||
Contents: entrySource,
|
||||
ResolveDir: absRoot,
|
||||
Sourcefile: "ssr-entry.js",
|
||||
Loader: esbuild.LoaderJS,
|
||||
},
|
||||
Bundle: true,
|
||||
Format: esbuild.FormatIIFE,
|
||||
Target: esbuild.ES2017,
|
||||
Platform: esbuild.PlatformBrowser,
|
||||
Conditions: []string{"development"},
|
||||
NodePaths: []string{vendorDir},
|
||||
Plugins: plugins,
|
||||
LogLevel: esbuild.LogLevelSilent,
|
||||
Write: false,
|
||||
Metafile: withMeta,
|
||||
})
|
||||
if len(result.Errors) > 0 {
|
||||
msgs := esbuild.FormatMessages(result.Errors, esbuild.FormatMessagesOptions{})
|
||||
return "", "", fmt.Errorf("esbuild: %s", strings.Join(msgs, "\n"))
|
||||
}
|
||||
if len(result.OutputFiles) == 0 {
|
||||
return "", "", fmt.Errorf("esbuild produced no output")
|
||||
}
|
||||
return string(result.OutputFiles[0].Contents), result.Metafile, nil
|
||||
}
|
||||
|
||||
// engineCacheVersion is bumped by hand when the SSR engine changes in a way
|
||||
// that affects rendered output but isn't captured by the dom.js/prelude source
|
||||
// below (e.g. a change in serialization in renderer.go).
|
||||
//
|
||||
// "2" — public pages migrated from solid-js/html to React/TSX; SSR now renders
|
||||
// via react-dom/server renderToString instead of the DOM-shim serializer.
|
||||
// "3" — public pages migrated to Solid JSX/TSX (compiled via the in-package Solid JSX compiler, jsx.go);
|
||||
// SSR back on the DOM-shim + serialize path, React prelude shims removed.
|
||||
const engineCacheVersion = "3"
|
||||
|
||||
// EngineHash fingerprints the SSR engine — the DOM shim, the prelude, and a
|
||||
// manual version. The bundler folds it into its public-page render cache so an
|
||||
// engine change invalidates every cached page (their input files wouldn't have
|
||||
// changed, but their rendered output would).
|
||||
func EngineHash() string {
|
||||
h := sha256.New()
|
||||
h.Write([]byte(engineCacheVersion))
|
||||
h.Write([]byte{0})
|
||||
h.Write([]byte(preludeJS))
|
||||
h.Write([]byte{0})
|
||||
h.Write([]byte(domJS))
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
// ---- HTML parse bridge (x/net/html) ------------------------------------
|
||||
|
||||
// jnode is the compact JSON shape dom.js rebuilds shim nodes from.
|
||||
type jnode struct {
|
||||
T string `json:"t"` // "e" element, "t" text, "c" comment
|
||||
N string `json:"n,omitempty"` // element tag name
|
||||
NS string `json:"ns,omitempty"` // "svg" / "math" for foreign content
|
||||
A map[string]string `json:"a,omitempty"` // attributes
|
||||
C []*jnode `json:"c,omitempty"` // children
|
||||
D string `json:"d,omitempty"` // text / comment data
|
||||
}
|
||||
|
||||
// parseHTMLToJSON parses an innerHTML fragment using template-content
|
||||
// semantics (so <table> gets its implicit <tbody>, void elements close, and
|
||||
// entities decode per the HTML5 spec) and returns it as a JSON node array.
|
||||
func parseHTMLToJSON(fragment string) string {
|
||||
ctx := &html.Node{Type: html.ElementNode, DataAtom: atom.Template, Data: "template"}
|
||||
nodes, err := html.ParseFragment(strings.NewReader(fragment), ctx)
|
||||
if err != nil {
|
||||
return "[]"
|
||||
}
|
||||
roots := make([]*jnode, 0, len(nodes))
|
||||
for _, n := range nodes {
|
||||
if jn := convert(n); jn != nil {
|
||||
roots = append(roots, jn)
|
||||
}
|
||||
}
|
||||
b, err := json.Marshal(roots)
|
||||
if err != nil {
|
||||
return "[]"
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func convert(n *html.Node) *jnode {
|
||||
switch n.Type {
|
||||
case html.ElementNode:
|
||||
jn := &jnode{T: "e", N: n.Data}
|
||||
if n.Namespace == "svg" || n.Namespace == "math" {
|
||||
jn.NS = n.Namespace
|
||||
}
|
||||
if len(n.Attr) > 0 {
|
||||
jn.A = make(map[string]string, len(n.Attr))
|
||||
for _, a := range n.Attr {
|
||||
key := a.Key
|
||||
if a.Namespace != "" {
|
||||
key = a.Namespace + ":" + a.Key
|
||||
}
|
||||
jn.A[key] = a.Val
|
||||
}
|
||||
}
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
if cj := convert(c); cj != nil {
|
||||
jn.C = append(jn.C, cj)
|
||||
}
|
||||
}
|
||||
return jn
|
||||
case html.TextNode:
|
||||
return &jnode{T: "t", D: n.Data}
|
||||
case html.CommentNode:
|
||||
return &jnode{T: "c", D: n.Data}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
145
go/bundler/ssr_test.go
Normal file
145
go/bundler/ssr_test.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package bundler
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// entryHome renders the real home-page component the way ssrEntrySolid does:
|
||||
// plain JS (createComponent, no JSX in the entry) importing the .tsx page, which
|
||||
// the Go Solid compiler compiles. PublicLayout is omitted to target the body.
|
||||
const entryHome = `
|
||||
import { render, createComponent } from "solid-js/web";
|
||||
import { Home } from "./frontend/src/pages/public/Home.tsx";
|
||||
globalThis.__render = function () {
|
||||
const root = document.createElement("div");
|
||||
const dispose = render(function () { return createComponent(Home, {}); }, root);
|
||||
const out = globalThis.__serialize(root);
|
||||
dispose();
|
||||
return out;
|
||||
};
|
||||
`
|
||||
|
||||
// Proves the ISR data-injection path the server uses: a pre-bundled render entry
|
||||
// run in goja with server data injected (RenderBundleWithData) makes Home render
|
||||
// the injected rates (via serverData()) instead of the loading skeleton.
|
||||
func TestRenderHomeWithServerData(t *testing.T) {
|
||||
root, err := filepath.Abs("../..")
|
||||
if err != nil {
|
||||
t.Fatalf("abs project root: %v", err)
|
||||
}
|
||||
t.Chdir(root)
|
||||
|
||||
data := `{"rates":[{"term":"90 Day","low":"1.111%","high":"2.222%","avg":"1.500%"}]}`
|
||||
js, err := BundleEntry(entryHome, ".")
|
||||
if err != nil {
|
||||
t.Fatalf("bundle: %v", err)
|
||||
}
|
||||
out, err := RenderBundleWithData(js, data)
|
||||
if err != nil {
|
||||
t.Fatalf("render with data: %v", err)
|
||||
}
|
||||
for _, want := range []string{"1.111%", "2.222%", "1.500%"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("output missing injected rate %q", want)
|
||||
}
|
||||
}
|
||||
// With data present the table shows it, not the loading skeleton.
|
||||
if strings.Contains(out, "animate-pulse") {
|
||||
t.Errorf("skeleton rendered despite injected data")
|
||||
}
|
||||
}
|
||||
|
||||
// Reproduces the server's setup (cwd at repo root, relative "." project root).
|
||||
// Guards the esbuild alias-resolution bug where a non-absolute root yields
|
||||
// bare-specifier alias targets that fail to resolve.
|
||||
func TestRenderWithRelativeRoot(t *testing.T) {
|
||||
root, err := filepath.Abs("../..")
|
||||
if err != nil {
|
||||
t.Fatalf("abs project root: %v", err)
|
||||
}
|
||||
t.Chdir(root)
|
||||
|
||||
r := NewRenderer(".", entryHome, true)
|
||||
out, err := r.HTML()
|
||||
if err != nil {
|
||||
t.Fatalf("render with relative root: %v", err)
|
||||
}
|
||||
if !strings.Contains(out, `class="page-home"`) {
|
||||
t.Errorf("output missing page-home")
|
||||
}
|
||||
}
|
||||
|
||||
// Confirms the cache: the first HTML() pays bundle+render, the second (no source
|
||||
// change) returns the cached string near-instantly.
|
||||
func TestRendererCaching(t *testing.T) {
|
||||
root, err := filepath.Abs("../..")
|
||||
if err != nil {
|
||||
t.Fatalf("abs project root: %v", err)
|
||||
}
|
||||
r := NewRenderer(root, entryHome, false) // prod: warm HTML() returns the cached string directly
|
||||
|
||||
t0 := time.Now()
|
||||
first, err := r.HTML()
|
||||
if err != nil {
|
||||
t.Fatalf("cold render: %v", err)
|
||||
}
|
||||
cold := time.Since(t0)
|
||||
|
||||
t1 := time.Now()
|
||||
second, err := r.HTML()
|
||||
if err != nil {
|
||||
t.Fatalf("warm render: %v", err)
|
||||
}
|
||||
warm := time.Since(t1)
|
||||
t.Logf("cold=%v warm=%v", cold, warm)
|
||||
|
||||
if first != second {
|
||||
t.Errorf("cached output differs from first render")
|
||||
}
|
||||
if warm > cold/4 {
|
||||
t.Errorf("cache hit too slow: cold=%v warm=%v", cold, warm)
|
||||
}
|
||||
}
|
||||
|
||||
// No data → the home page renders its loading skeleton, exercising compiled Solid
|
||||
// against inline SVGs (viewBox case), string style attributes, entities, and the
|
||||
// table. SVG attribute case and the style string must survive serialization.
|
||||
func TestRenderHomeSkeleton(t *testing.T) {
|
||||
root, err := filepath.Abs("../..")
|
||||
if err != nil {
|
||||
t.Fatalf("abs project root: %v", err)
|
||||
}
|
||||
bundle, err := BundleEntry(entryHome, root)
|
||||
if err != nil {
|
||||
t.Fatalf("bundle: %v", err)
|
||||
}
|
||||
eng, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("engine: %v", err)
|
||||
}
|
||||
if err := eng.LoadBundle(bundle); err != nil {
|
||||
t.Fatalf("load bundle: %v", err)
|
||||
}
|
||||
out, err := eng.Render()
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
t.Logf("HOME SKELETON (%d bytes):\n%s", len(out), out)
|
||||
|
||||
for _, want := range []string{
|
||||
`class="page-home"`,
|
||||
`The Ultimate Funding and Investing Solution`,
|
||||
`viewBox="0 0 24 24"`, // SVG attribute case preserved
|
||||
`background-image: url(/images/public/hero-bg.jpg)`, // static string style baked into the template
|
||||
`animate-pulse`, // skeleton bars (SSR forces showSkeleton)
|
||||
`90 Day`, // term labels shown in the skeleton
|
||||
`Schedule a free demo`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("output missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
192
go/bundler/ssrcache.go
Normal file
192
go/bundler/ssrcache.go
Normal file
@@ -0,0 +1,192 @@
|
||||
package bundler
|
||||
|
||||
// Build-time caching + parallelism for public-page SSR (see genssr.go).
|
||||
//
|
||||
// Rendering a page means bundling it with esbuild and running it through a goja
|
||||
// runtime — too slow to repeat for every page on every build when nothing
|
||||
// changed. So each render records the source files esbuild pulled in (from the
|
||||
// metafile) and their content hashes; a later build reuses the cached HTML when
|
||||
// the page's entry and every input file are byte-identical. Cache misses are
|
||||
// rendered concurrently, one goja runtime per worker.
|
||||
//
|
||||
// The cache lives under tmp/ (gitignored) and is purely an optimization: any
|
||||
// read/parse/write error just falls back to rendering.
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// ssrCachePath is the on-disk render cache (gitignored via tmp/).
|
||||
var ssrCachePath = filepath.Join("tmp", "ssr-cache.json")
|
||||
|
||||
// ssrCacheEntry fingerprints one rendered page.
|
||||
type ssrCacheEntry struct {
|
||||
EntryHash string `json:"entry"` // hash of the goja entry source
|
||||
Inputs map[string]string `json:"inputs"` // input file path -> content hash
|
||||
HTML string `json:"html"` // the rendered, baked body
|
||||
RenderJS string `json:"renderJS,omitempty"` // bundled JS, baked for dynamic (ISR) pages
|
||||
}
|
||||
|
||||
// ssrCache is the whole render cache, keyed by page URL path.
|
||||
type ssrCache struct {
|
||||
Engine string `json:"engine"` // EngineHash() at write time
|
||||
Pages map[string]ssrCacheEntry `json:"pages"`
|
||||
}
|
||||
|
||||
func loadSSRCache() ssrCache {
|
||||
data, err := os.ReadFile(ssrCachePath)
|
||||
if err != nil {
|
||||
return ssrCache{}
|
||||
}
|
||||
var c ssrCache
|
||||
if json.Unmarshal(data, &c) != nil || c.Pages == nil {
|
||||
return ssrCache{}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func saveSSRCache(c ssrCache) {
|
||||
data, err := json.MarshalIndent(c, "", " ")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(ssrCachePath), 0o755); err != nil {
|
||||
return
|
||||
}
|
||||
_ = os.WriteFile(ssrCachePath, data, 0o644)
|
||||
}
|
||||
|
||||
func hashString(s string) string {
|
||||
sum := sha256.Sum256([]byte(s))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// fileHasher memoizes content hashes within a single build so files shared by
|
||||
// several pages (the vendored Solid runtime, PublicLayout, shared UI) are read
|
||||
// and hashed once. Safe for concurrent use.
|
||||
type fileHasher struct {
|
||||
mu sync.Mutex
|
||||
m map[string]string // path -> hex hash; "" records a read failure
|
||||
}
|
||||
|
||||
func newFileHasher() *fileHasher { return &fileHasher{m: map[string]string{}} }
|
||||
|
||||
// hash returns the file's content hash and whether it was readable.
|
||||
func (f *fileHasher) hash(path string) (string, bool) {
|
||||
f.mu.Lock()
|
||||
if h, ok := f.m[path]; ok {
|
||||
f.mu.Unlock()
|
||||
return h, h != ""
|
||||
}
|
||||
f.mu.Unlock()
|
||||
|
||||
h := ""
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
sum := sha256.Sum256(data)
|
||||
h = hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
f.mu.Lock()
|
||||
f.m[path] = h
|
||||
f.mu.Unlock()
|
||||
return h, h != ""
|
||||
}
|
||||
|
||||
// inputsUnchanged reports whether every recorded input still hashes the same.
|
||||
// A page can't gain a new dependency without editing one of these files, so an
|
||||
// all-match means the bundle (and thus the rendered output) is identical.
|
||||
func inputsUnchanged(old map[string]string, h *fileHasher) bool {
|
||||
if len(old) == 0 {
|
||||
return false
|
||||
}
|
||||
for path, want := range old {
|
||||
got, ok := h.hash(path)
|
||||
if !ok || got != want {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func hashInputs(paths []string, h *fileHasher) map[string]string {
|
||||
m := make(map[string]string, len(paths))
|
||||
for _, p := range paths {
|
||||
if sum, ok := h.hash(p); ok {
|
||||
m[p] = sum
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// renderJob / renderResult carry a cache-miss page through the worker pool.
|
||||
type renderJob struct {
|
||||
idx int
|
||||
path string
|
||||
component string
|
||||
entry string
|
||||
}
|
||||
|
||||
type renderResult struct {
|
||||
idx int
|
||||
path string
|
||||
html string
|
||||
js string // bundled render entry (baked for dynamic/ISR pages)
|
||||
inputs []string
|
||||
}
|
||||
|
||||
// renderMisses renders the given jobs concurrently — one goja runtime per
|
||||
// worker, capped at NumCPU — and returns their results in input order. On the
|
||||
// first render error it stops reporting and returns that error.
|
||||
func renderMisses(jobs []renderJob) ([]renderResult, error) {
|
||||
if len(jobs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
limit := runtime.NumCPU()
|
||||
if limit < 1 {
|
||||
limit = 1
|
||||
}
|
||||
if limit > len(jobs) {
|
||||
limit = len(jobs)
|
||||
}
|
||||
|
||||
results := make([]renderResult, len(jobs))
|
||||
sem := make(chan struct{}, limit)
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
var firstErr error
|
||||
|
||||
for k := range jobs {
|
||||
wg.Add(1)
|
||||
go func(k int) {
|
||||
defer wg.Done()
|
||||
sem <- struct{}{}
|
||||
defer func() { <-sem }()
|
||||
|
||||
j := jobs[k]
|
||||
html, js, inputs, err := RenderEntryFull(j.entry, ".")
|
||||
if err != nil {
|
||||
mu.Lock()
|
||||
if firstErr == nil {
|
||||
firstErr = fmt.Errorf("rendering %s (%s): %w", j.path, j.component, err)
|
||||
}
|
||||
mu.Unlock()
|
||||
return
|
||||
}
|
||||
results[k] = renderResult{idx: j.idx, path: j.path, html: html, js: js, inputs: inputs}
|
||||
}(k)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if firstErr != nil {
|
||||
return nil, firstErr
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
9586
go/bundler/tailwind.go
Normal file
9586
go/bundler/tailwind.go
Normal file
File diff suppressed because it is too large
Load Diff
217
go/bundler/tailwind_test.go
Normal file
217
go/bundler/tailwind_test.go
Normal file
@@ -0,0 +1,217 @@
|
||||
package bundler
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func twTestCompile(t *testing.T, candidates ...string) string {
|
||||
t.Helper()
|
||||
css, _, err := twCompile(`@import "tailwindcss";`, ".", candidates)
|
||||
if err != nil {
|
||||
t.Fatalf("twCompile error: %v", err)
|
||||
}
|
||||
return css
|
||||
}
|
||||
|
||||
// Edge cases that the modifier/fraction rewrite fixes.
|
||||
func TestEngineEdgeCases(t *testing.T) {
|
||||
cases := []struct {
|
||||
candidate string
|
||||
contains string
|
||||
}{
|
||||
// Improper fraction read as a fraction (not an opacity modifier).
|
||||
{"aspect-16/9", "aspect-ratio: 16/9"},
|
||||
// Proper fraction.
|
||||
{"w-1/2", "width: calc(1 / 2 * 100%)"},
|
||||
// Arbitrary opacity modifier decoded (not "[0.5]%").
|
||||
{"bg-white/[0.5]", "color-mix(in oklab, var(--color-white) 50%, transparent)"},
|
||||
// Bare spacing.
|
||||
{"m-4", "margin: calc(var(--spacing) * 4)"},
|
||||
{"p-4", "padding: calc(var(--spacing) * 4)"},
|
||||
// text-{size}/{leading}: font-size AND line-height (the dropped-modifier fix).
|
||||
{"text-sm/6", "line-height: calc(var(--spacing) * 6)"},
|
||||
// Theme color via @theme namespace.
|
||||
{"bg-red-500", "background-color: var(--color-red-500)"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
css := twTestCompile(t, c.candidate)
|
||||
if !strings.Contains(css, c.contains) {
|
||||
t.Errorf("compile(%q): expected to contain %q\n---\n%s", c.candidate, c.contains, css)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSegmentTopLevel(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
sep string
|
||||
want []string
|
||||
}{
|
||||
{"a:b:c", ":", []string{"a", "b", "c"}},
|
||||
{"var(--a, 0 0 1px rgb(0, 0, 0)), 0 0 1px rgb(0, 0, 0)", ",",
|
||||
[]string{"var(--a, 0 0 1px rgb(0, 0, 0))", " 0 0 1px rgb(0, 0, 0)"}},
|
||||
{"display:grid", ":", []string{"display", "grid"}},
|
||||
{"[display:grid]", ":", []string{"[display:grid]"}},
|
||||
{"red-500/50", "/", []string{"red-500", "50"}},
|
||||
{"calc(1/2)/3", "/", []string{"calc(1/2)", "3"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := segment(c.in, c.sep)
|
||||
if len(got) != len(c.want) {
|
||||
t.Errorf("segment(%q,%q) = %v, want %v", c.in, c.sep, got, c.want)
|
||||
continue
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != c.want[i] {
|
||||
t.Errorf("segment(%q,%q)[%d] = %q, want %q", c.in, c.sep, i, got[i], c.want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEscapeIdentifier(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"flex", "flex"},
|
||||
{"hover:bg-red-500", `hover\:bg-red-500`},
|
||||
{"w-1/2", `w-1\/2`},
|
||||
{"bg-[#fff]", `bg-\[\#fff\]`},
|
||||
{"2xl", `\32 xl`},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := escape(c.in); got != c.want {
|
||||
t.Errorf("escape(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeArbitraryValue(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"100%_!important", "100% !important"},
|
||||
{"calc(100dvh_-_5rem)", "calc(100dvh - 5rem)"},
|
||||
{`url(/a_b.png)`, "url(/a_b.png)"},
|
||||
{`var(--my_var)`, "var(--my_var)"},
|
||||
{"calc(var(--spacing)*4_+_env(safe-area-inset-bottom,0px))",
|
||||
"calc(var(--spacing) * 4 + env(safe-area-inset-bottom,0px))"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := decodeArbitraryValue(c.in); got != c.want {
|
||||
t.Errorf("decodeArbitraryValue(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInferDataType(t *testing.T) {
|
||||
cases := []struct {
|
||||
value string
|
||||
types []string
|
||||
want string
|
||||
}{
|
||||
{"#ff0000", []string{dtColor, dtLength}, dtColor},
|
||||
{"10rem", []string{dtColor, dtLength}, dtLength},
|
||||
{"50%", []string{dtLength, dtPercentage}, dtPercentage},
|
||||
{"16/9", []string{dtRatio, dtColor}, dtRatio},
|
||||
{"var(--x)", []string{dtColor, dtLength}, ""},
|
||||
{"calc(1px+2px)", []string{dtLength}, dtLength},
|
||||
{"red", []string{dtColor}, dtColor},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := inferDataType(c.value, c.types); got != c.want {
|
||||
t.Errorf("inferDataType(%q,%v) = %q, want %q", c.value, c.types, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNumericPredicates(t *testing.T) {
|
||||
if !isValidSpacingMultiplier("0.5") {
|
||||
t.Error("0.5 should be a valid spacing multiplier")
|
||||
}
|
||||
if isValidSpacingMultiplier("0.3") {
|
||||
t.Error("0.3 should NOT be a valid spacing multiplier")
|
||||
}
|
||||
if !isPositiveInteger("3") || isPositiveInteger("3.0") || isPositiveInteger("03") {
|
||||
t.Error("isPositiveInteger canonical-form check failed")
|
||||
}
|
||||
}
|
||||
|
||||
// Apostrophes inside JS comments (e.g. "don't", "button's") must not desync the
|
||||
// quote-based class scanner. Before comments were skipped, a stray apostrophe
|
||||
// flipped quote parity and swallowed every class literal until the next quote —
|
||||
// silently dropping singly-used utilities like the lineup card's switch and
|
||||
// drag styles.
|
||||
func TestExtractCandidatesSkipsComments(t *testing.T) {
|
||||
src := "// we don't need reactivity here\n" +
|
||||
"const cur = locked ? \"cursor-not-allowed\" : \"cursor-move\";\n" +
|
||||
"/* the button's thumb: it's offset */\n" +
|
||||
"const thumb = on ? \"translate-x-3\" : \"\";\n" +
|
||||
"const tpl = `<input class=\"sr-only\"/>`;\n"
|
||||
|
||||
got := map[string]bool{}
|
||||
for _, c := range extractCandidates(src) {
|
||||
got[c] = true
|
||||
}
|
||||
for _, want := range []string{"cursor-not-allowed", "cursor-move", "translate-x-3", "sr-only"} {
|
||||
if !got[want] {
|
||||
t.Errorf("candidate %q was not extracted past comment apostrophes", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// scan returns the set of candidates extracted from src.
|
||||
func scan(src string) map[string]bool {
|
||||
got := map[string]bool{}
|
||||
for _, c := range extractCandidates(src) {
|
||||
got[c] = true
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
// Quotes inside a regex literal must not be read as a string (which would
|
||||
// desync the scanner and drop following class literals), and a division `/`
|
||||
// must not be mistaken for a regex (which would swallow the code after it).
|
||||
func TestExtractCandidatesRegexLiterals(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
src string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
"apostrophe in regex",
|
||||
`const r = /it's/; const c = "cursor-move";`,
|
||||
[]string{"cursor-move"},
|
||||
},
|
||||
{
|
||||
"regex after return keyword",
|
||||
`function f(){ return /a"b/; } const c = "p-4";`,
|
||||
[]string{"p-4"},
|
||||
},
|
||||
{
|
||||
"char class containing slash and quote",
|
||||
`const r = /[/']/g; const c = "block";`,
|
||||
[]string{"block"},
|
||||
},
|
||||
{
|
||||
"division is not a regex (string between divisions survives)",
|
||||
`const w = a / 2; cls = "text-lg"; const h = b / 4; cls2 = "m-2";`,
|
||||
[]string{"text-lg", "m-2"},
|
||||
},
|
||||
{
|
||||
"regex inside a template interpolation, class after it",
|
||||
"const t = html`<a class=${x.replace(/'/g, \"\")}>${y ? \"p-5\" : \"p-6\"}</a>`;",
|
||||
[]string{"p-5", "p-6"},
|
||||
},
|
||||
{
|
||||
"regex with braces in template interpolation keeps ${} balanced",
|
||||
"const t = html`<a class=${s.match(/[{}]/) ? \"flex\" : \"hidden\"}></a>`;",
|
||||
[]string{"flex", "hidden"},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := scan(tc.src)
|
||||
for _, w := range tc.want {
|
||||
if !got[w] {
|
||||
t.Errorf("%s: candidate %q not extracted (src=%q)", tc.name, w, tc.src)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
393
go/bundler/tw_preflight.css
Normal file
393
go/bundler/tw_preflight.css
Normal file
@@ -0,0 +1,393 @@
|
||||
/*
|
||||
1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4)
|
||||
2. Remove default margins and padding
|
||||
3. Reset all borders.
|
||||
*/
|
||||
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
box-sizing: border-box; /* 1 */
|
||||
margin: 0; /* 2 */
|
||||
padding: 0; /* 2 */
|
||||
border: 0 solid; /* 3 */
|
||||
}
|
||||
|
||||
/*
|
||||
1. Use a consistent sensible line-height in all browsers.
|
||||
2. Prevent adjustments of font size after orientation changes in iOS.
|
||||
3. Use a more readable tab size.
|
||||
4. Use the user's configured `sans` font-family by default.
|
||||
5. Use the user's configured `sans` font-feature-settings by default.
|
||||
6. Use the user's configured `sans` font-variation-settings by default.
|
||||
7. Disable tap highlights on iOS.
|
||||
*/
|
||||
|
||||
html,
|
||||
:host {
|
||||
line-height: 1.5; /* 1 */
|
||||
-webkit-text-size-adjust: 100%; /* 2 */
|
||||
tab-size: 4; /* 3 */
|
||||
font-family: --theme(
|
||||
--default-font-family,
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
sans-serif,
|
||||
'Apple Color Emoji',
|
||||
'Segoe UI Emoji',
|
||||
'Segoe UI Symbol',
|
||||
'Noto Color Emoji'
|
||||
); /* 4 */
|
||||
font-feature-settings: --theme(--default-font-feature-settings, normal); /* 5 */
|
||||
font-variation-settings: --theme(--default-font-variation-settings, normal); /* 6 */
|
||||
-webkit-tap-highlight-color: transparent; /* 7 */
|
||||
}
|
||||
|
||||
/*
|
||||
1. Add the correct height in Firefox.
|
||||
2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655)
|
||||
3. Reset the default border style to a 1px solid border.
|
||||
*/
|
||||
|
||||
hr {
|
||||
height: 0; /* 1 */
|
||||
color: inherit; /* 2 */
|
||||
border-top-width: 1px; /* 3 */
|
||||
}
|
||||
|
||||
/*
|
||||
Add the correct text decoration in Chrome, Edge, and Safari.
|
||||
*/
|
||||
|
||||
abbr:where([title]) {
|
||||
-webkit-text-decoration: underline dotted;
|
||||
text-decoration: underline dotted;
|
||||
}
|
||||
|
||||
/*
|
||||
Remove the default font size and weight for headings.
|
||||
*/
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
}
|
||||
|
||||
/*
|
||||
Reset links to optimize for opt-in styling instead of opt-out.
|
||||
*/
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
-webkit-text-decoration: inherit;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
|
||||
/*
|
||||
Add the correct font weight in Edge and Safari.
|
||||
*/
|
||||
|
||||
b,
|
||||
strong {
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
/*
|
||||
1. Use the user's configured `mono` font-family by default.
|
||||
2. Use the user's configured `mono` font-feature-settings by default.
|
||||
3. Use the user's configured `mono` font-variation-settings by default.
|
||||
4. Correct the odd `em` font sizing in all browsers.
|
||||
*/
|
||||
|
||||
code,
|
||||
kbd,
|
||||
samp,
|
||||
pre {
|
||||
font-family: --theme(
|
||||
--default-mono-font-family,
|
||||
ui-monospace,
|
||||
SFMono-Regular,
|
||||
Menlo,
|
||||
Monaco,
|
||||
Consolas,
|
||||
'Liberation Mono',
|
||||
'Courier New',
|
||||
monospace
|
||||
); /* 1 */
|
||||
font-feature-settings: --theme(--default-mono-font-feature-settings, normal); /* 2 */
|
||||
font-variation-settings: --theme(--default-mono-font-variation-settings, normal); /* 3 */
|
||||
font-size: 1em; /* 4 */
|
||||
}
|
||||
|
||||
/*
|
||||
Add the correct font size in all browsers.
|
||||
*/
|
||||
|
||||
small {
|
||||
font-size: 80%;
|
||||
}
|
||||
|
||||
/*
|
||||
Prevent `sub` and `sup` elements from affecting the line height in all browsers.
|
||||
*/
|
||||
|
||||
sub,
|
||||
sup {
|
||||
font-size: 75%;
|
||||
line-height: 0;
|
||||
position: relative;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
sub {
|
||||
bottom: -0.25em;
|
||||
}
|
||||
|
||||
sup {
|
||||
top: -0.5em;
|
||||
}
|
||||
|
||||
/*
|
||||
1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297)
|
||||
2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016)
|
||||
3. Remove gaps between table borders by default.
|
||||
*/
|
||||
|
||||
table {
|
||||
text-indent: 0; /* 1 */
|
||||
border-color: inherit; /* 2 */
|
||||
border-collapse: collapse; /* 3 */
|
||||
}
|
||||
|
||||
/*
|
||||
Use the modern Firefox focus style for all focusable elements.
|
||||
*/
|
||||
|
||||
:-moz-focusring {
|
||||
outline: auto;
|
||||
}
|
||||
|
||||
/*
|
||||
Add the correct vertical alignment in Chrome and Firefox.
|
||||
*/
|
||||
|
||||
progress {
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
/*
|
||||
Add the correct display in Chrome and Safari.
|
||||
*/
|
||||
|
||||
summary {
|
||||
display: list-item;
|
||||
}
|
||||
|
||||
/*
|
||||
Make lists unstyled by default.
|
||||
*/
|
||||
|
||||
ol,
|
||||
ul,
|
||||
menu {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
/*
|
||||
1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14)
|
||||
2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210)
|
||||
This can trigger a poorly considered lint error in some tools but is included by design.
|
||||
*/
|
||||
|
||||
img,
|
||||
svg,
|
||||
video,
|
||||
canvas,
|
||||
audio,
|
||||
iframe,
|
||||
embed,
|
||||
object {
|
||||
display: block; /* 1 */
|
||||
vertical-align: middle; /* 2 */
|
||||
}
|
||||
|
||||
/*
|
||||
Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14)
|
||||
*/
|
||||
|
||||
img,
|
||||
video {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/*
|
||||
1. Inherit font styles in all browsers.
|
||||
2. Remove border radius in all browsers.
|
||||
3. Remove background color in all browsers.
|
||||
4. Ensure consistent opacity for disabled states in all browsers.
|
||||
*/
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
optgroup,
|
||||
textarea,
|
||||
::file-selector-button {
|
||||
font: inherit; /* 1 */
|
||||
font-feature-settings: inherit; /* 1 */
|
||||
font-variation-settings: inherit; /* 1 */
|
||||
letter-spacing: inherit; /* 1 */
|
||||
color: inherit; /* 1 */
|
||||
border-radius: 0; /* 2 */
|
||||
background-color: transparent; /* 3 */
|
||||
opacity: 1; /* 4 */
|
||||
}
|
||||
|
||||
/*
|
||||
Restore default font weight.
|
||||
*/
|
||||
|
||||
:where(select:is([multiple], [size])) optgroup {
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
/*
|
||||
Restore indentation.
|
||||
*/
|
||||
|
||||
:where(select:is([multiple], [size])) optgroup option {
|
||||
padding-inline-start: 20px;
|
||||
}
|
||||
|
||||
/*
|
||||
Restore space after button.
|
||||
*/
|
||||
|
||||
::file-selector-button {
|
||||
margin-inline-end: 4px;
|
||||
}
|
||||
|
||||
/*
|
||||
Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300)
|
||||
*/
|
||||
|
||||
::placeholder {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/*
|
||||
Set the default placeholder color to a semi-transparent version of the current text color in browsers that do not
|
||||
crash when using `color-mix(…)` with `currentcolor`. (https://github.com/tailwindlabs/tailwindcss/issues/17194)
|
||||
*/
|
||||
|
||||
@supports (not (-webkit-appearance: -apple-pay-button)) /* Not Safari */ or
|
||||
(contain-intrinsic-size: 1px) /* Safari 17+ */ {
|
||||
::placeholder {
|
||||
color: color-mix(in oklab, currentcolor 50%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Prevent resizing textareas horizontally by default.
|
||||
*/
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
/*
|
||||
Remove the inner padding in Chrome and Safari on macOS.
|
||||
*/
|
||||
|
||||
::-webkit-search-decoration {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
/*
|
||||
1. Ensure date/time inputs have the same height when empty in iOS Safari.
|
||||
2. Ensure text alignment can be changed on date/time inputs in iOS Safari.
|
||||
*/
|
||||
|
||||
::-webkit-date-and-time-value {
|
||||
min-height: 1lh; /* 1 */
|
||||
text-align: inherit; /* 2 */
|
||||
}
|
||||
|
||||
/*
|
||||
Prevent height from changing on date/time inputs in macOS Safari when the input is set to `display: block`.
|
||||
*/
|
||||
|
||||
::-webkit-datetime-edit {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
/*
|
||||
Remove excess padding from pseudo-elements in date/time inputs to ensure consistent height across browsers.
|
||||
*/
|
||||
|
||||
::-webkit-datetime-edit-fields-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
::-webkit-datetime-edit,
|
||||
::-webkit-datetime-edit-year-field,
|
||||
::-webkit-datetime-edit-month-field,
|
||||
::-webkit-datetime-edit-day-field,
|
||||
::-webkit-datetime-edit-hour-field,
|
||||
::-webkit-datetime-edit-minute-field,
|
||||
::-webkit-datetime-edit-second-field,
|
||||
::-webkit-datetime-edit-millisecond-field,
|
||||
::-webkit-datetime-edit-meridiem-field {
|
||||
padding-block: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
Center dropdown marker shown on inputs with paired `<datalist>`s in Chrome. (https://github.com/tailwindlabs/tailwindcss/issues/18499)
|
||||
*/
|
||||
|
||||
::-webkit-calendar-picker-indicator {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/*
|
||||
Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737)
|
||||
*/
|
||||
|
||||
:-moz-ui-invalid {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/*
|
||||
Correct the inability to style the border radius in iOS Safari.
|
||||
*/
|
||||
|
||||
button,
|
||||
input:where([type='button'], [type='reset'], [type='submit']),
|
||||
::file-selector-button {
|
||||
appearance: button;
|
||||
}
|
||||
|
||||
/*
|
||||
Correct the cursor style of increment and decrement buttons in Safari.
|
||||
*/
|
||||
|
||||
::-webkit-inner-spin-button,
|
||||
::-webkit-outer-spin-button {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/*
|
||||
Make elements with the HTML hidden attribute stay hidden by default.
|
||||
*/
|
||||
|
||||
[hidden]:where(:not([hidden='until-found'])) {
|
||||
display: none !important;
|
||||
}
|
||||
510
go/bundler/tw_theme.css
Normal file
510
go/bundler/tw_theme.css
Normal file
@@ -0,0 +1,510 @@
|
||||
@theme default {
|
||||
--font-sans:
|
||||
ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
||||
'Noto Color Emoji';
|
||||
--font-serif: ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif;
|
||||
--font-mono:
|
||||
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
|
||||
monospace;
|
||||
|
||||
--color-red-50: oklch(97.1% 0.013 17.38);
|
||||
--color-red-100: oklch(93.6% 0.032 17.717);
|
||||
--color-red-200: oklch(88.5% 0.062 18.334);
|
||||
--color-red-300: oklch(80.8% 0.114 19.571);
|
||||
--color-red-400: oklch(70.4% 0.191 22.216);
|
||||
--color-red-500: oklch(63.7% 0.237 25.331);
|
||||
--color-red-600: oklch(57.7% 0.245 27.325);
|
||||
--color-red-700: oklch(50.5% 0.213 27.518);
|
||||
--color-red-800: oklch(44.4% 0.177 26.899);
|
||||
--color-red-900: oklch(39.6% 0.141 25.723);
|
||||
--color-red-950: oklch(25.8% 0.092 26.042);
|
||||
|
||||
--color-orange-50: oklch(98% 0.016 73.684);
|
||||
--color-orange-100: oklch(95.4% 0.038 75.164);
|
||||
--color-orange-200: oklch(90.1% 0.076 70.697);
|
||||
--color-orange-300: oklch(83.7% 0.128 66.29);
|
||||
--color-orange-400: oklch(75% 0.183 55.934);
|
||||
--color-orange-500: oklch(70.5% 0.213 47.604);
|
||||
--color-orange-600: oklch(64.6% 0.222 41.116);
|
||||
--color-orange-700: oklch(55.3% 0.195 38.402);
|
||||
--color-orange-800: oklch(47% 0.157 37.304);
|
||||
--color-orange-900: oklch(40.8% 0.123 38.172);
|
||||
--color-orange-950: oklch(26.6% 0.079 36.259);
|
||||
|
||||
--color-amber-50: oklch(98.7% 0.022 95.277);
|
||||
--color-amber-100: oklch(96.2% 0.059 95.617);
|
||||
--color-amber-200: oklch(92.4% 0.12 95.746);
|
||||
--color-amber-300: oklch(87.9% 0.169 91.605);
|
||||
--color-amber-400: oklch(82.8% 0.189 84.429);
|
||||
--color-amber-500: oklch(76.9% 0.188 70.08);
|
||||
--color-amber-600: oklch(66.6% 0.179 58.318);
|
||||
--color-amber-700: oklch(55.5% 0.163 48.998);
|
||||
--color-amber-800: oklch(47.3% 0.137 46.201);
|
||||
--color-amber-900: oklch(41.4% 0.112 45.904);
|
||||
--color-amber-950: oklch(27.9% 0.077 45.635);
|
||||
|
||||
--color-yellow-50: oklch(98.7% 0.026 102.212);
|
||||
--color-yellow-100: oklch(97.3% 0.071 103.193);
|
||||
--color-yellow-200: oklch(94.5% 0.129 101.54);
|
||||
--color-yellow-300: oklch(90.5% 0.182 98.111);
|
||||
--color-yellow-400: oklch(85.2% 0.199 91.936);
|
||||
--color-yellow-500: oklch(79.5% 0.184 86.047);
|
||||
--color-yellow-600: oklch(68.1% 0.162 75.834);
|
||||
--color-yellow-700: oklch(55.4% 0.135 66.442);
|
||||
--color-yellow-800: oklch(47.6% 0.114 61.907);
|
||||
--color-yellow-900: oklch(42.1% 0.095 57.708);
|
||||
--color-yellow-950: oklch(28.6% 0.066 53.813);
|
||||
|
||||
--color-lime-50: oklch(98.6% 0.031 120.757);
|
||||
--color-lime-100: oklch(96.7% 0.067 122.328);
|
||||
--color-lime-200: oklch(93.8% 0.127 124.321);
|
||||
--color-lime-300: oklch(89.7% 0.196 126.665);
|
||||
--color-lime-400: oklch(84.1% 0.238 128.85);
|
||||
--color-lime-500: oklch(76.8% 0.233 130.85);
|
||||
--color-lime-600: oklch(64.8% 0.2 131.684);
|
||||
--color-lime-700: oklch(53.2% 0.157 131.589);
|
||||
--color-lime-800: oklch(45.3% 0.124 130.933);
|
||||
--color-lime-900: oklch(40.5% 0.101 131.063);
|
||||
--color-lime-950: oklch(27.4% 0.072 132.109);
|
||||
|
||||
--color-green-50: oklch(98.2% 0.018 155.826);
|
||||
--color-green-100: oklch(96.2% 0.044 156.743);
|
||||
--color-green-200: oklch(92.5% 0.084 155.995);
|
||||
--color-green-300: oklch(87.1% 0.15 154.449);
|
||||
--color-green-400: oklch(79.2% 0.209 151.711);
|
||||
--color-green-500: oklch(72.3% 0.219 149.579);
|
||||
--color-green-600: oklch(62.7% 0.194 149.214);
|
||||
--color-green-700: oklch(52.7% 0.154 150.069);
|
||||
--color-green-800: oklch(44.8% 0.119 151.328);
|
||||
--color-green-900: oklch(39.3% 0.095 152.535);
|
||||
--color-green-950: oklch(26.6% 0.065 152.934);
|
||||
|
||||
--color-emerald-50: oklch(97.9% 0.021 166.113);
|
||||
--color-emerald-100: oklch(95% 0.052 163.051);
|
||||
--color-emerald-200: oklch(90.5% 0.093 164.15);
|
||||
--color-emerald-300: oklch(84.5% 0.143 164.978);
|
||||
--color-emerald-400: oklch(76.5% 0.177 163.223);
|
||||
--color-emerald-500: oklch(69.6% 0.17 162.48);
|
||||
--color-emerald-600: oklch(59.6% 0.145 163.225);
|
||||
--color-emerald-700: oklch(50.8% 0.118 165.612);
|
||||
--color-emerald-800: oklch(43.2% 0.095 166.913);
|
||||
--color-emerald-900: oklch(37.8% 0.077 168.94);
|
||||
--color-emerald-950: oklch(26.2% 0.051 172.552);
|
||||
|
||||
--color-teal-50: oklch(98.4% 0.014 180.72);
|
||||
--color-teal-100: oklch(95.3% 0.051 180.801);
|
||||
--color-teal-200: oklch(91% 0.096 180.426);
|
||||
--color-teal-300: oklch(85.5% 0.138 181.071);
|
||||
--color-teal-400: oklch(77.7% 0.152 181.912);
|
||||
--color-teal-500: oklch(70.4% 0.14 182.503);
|
||||
--color-teal-600: oklch(60% 0.118 184.704);
|
||||
--color-teal-700: oklch(51.1% 0.096 186.391);
|
||||
--color-teal-800: oklch(43.7% 0.078 188.216);
|
||||
--color-teal-900: oklch(38.6% 0.063 188.416);
|
||||
--color-teal-950: oklch(27.7% 0.046 192.524);
|
||||
|
||||
--color-cyan-50: oklch(98.4% 0.019 200.873);
|
||||
--color-cyan-100: oklch(95.6% 0.045 203.388);
|
||||
--color-cyan-200: oklch(91.7% 0.08 205.041);
|
||||
--color-cyan-300: oklch(86.5% 0.127 207.078);
|
||||
--color-cyan-400: oklch(78.9% 0.154 211.53);
|
||||
--color-cyan-500: oklch(71.5% 0.143 215.221);
|
||||
--color-cyan-600: oklch(60.9% 0.126 221.723);
|
||||
--color-cyan-700: oklch(52% 0.105 223.128);
|
||||
--color-cyan-800: oklch(45% 0.085 224.283);
|
||||
--color-cyan-900: oklch(39.8% 0.07 227.392);
|
||||
--color-cyan-950: oklch(30.2% 0.056 229.695);
|
||||
|
||||
--color-sky-50: oklch(97.7% 0.013 236.62);
|
||||
--color-sky-100: oklch(95.1% 0.026 236.824);
|
||||
--color-sky-200: oklch(90.1% 0.058 230.902);
|
||||
--color-sky-300: oklch(82.8% 0.111 230.318);
|
||||
--color-sky-400: oklch(74.6% 0.16 232.661);
|
||||
--color-sky-500: oklch(68.5% 0.169 237.323);
|
||||
--color-sky-600: oklch(58.8% 0.158 241.966);
|
||||
--color-sky-700: oklch(50% 0.134 242.749);
|
||||
--color-sky-800: oklch(44.3% 0.11 240.79);
|
||||
--color-sky-900: oklch(39.1% 0.09 240.876);
|
||||
--color-sky-950: oklch(29.3% 0.066 243.157);
|
||||
|
||||
--color-blue-50: oklch(97% 0.014 254.604);
|
||||
--color-blue-100: oklch(93.2% 0.032 255.585);
|
||||
--color-blue-200: oklch(88.2% 0.059 254.128);
|
||||
--color-blue-300: oklch(80.9% 0.105 251.813);
|
||||
--color-blue-400: oklch(70.7% 0.165 254.624);
|
||||
--color-blue-500: oklch(62.3% 0.214 259.815);
|
||||
--color-blue-600: oklch(54.6% 0.245 262.881);
|
||||
--color-blue-700: oklch(48.8% 0.243 264.376);
|
||||
--color-blue-800: oklch(42.4% 0.199 265.638);
|
||||
--color-blue-900: oklch(37.9% 0.146 265.522);
|
||||
--color-blue-950: oklch(28.2% 0.091 267.935);
|
||||
|
||||
--color-indigo-50: oklch(96.2% 0.018 272.314);
|
||||
--color-indigo-100: oklch(93% 0.034 272.788);
|
||||
--color-indigo-200: oklch(87% 0.065 274.039);
|
||||
--color-indigo-300: oklch(78.5% 0.115 274.713);
|
||||
--color-indigo-400: oklch(67.3% 0.182 276.935);
|
||||
--color-indigo-500: oklch(58.5% 0.233 277.117);
|
||||
--color-indigo-600: oklch(51.1% 0.262 276.966);
|
||||
--color-indigo-700: oklch(45.7% 0.24 277.023);
|
||||
--color-indigo-800: oklch(39.8% 0.195 277.366);
|
||||
--color-indigo-900: oklch(35.9% 0.144 278.697);
|
||||
--color-indigo-950: oklch(25.7% 0.09 281.288);
|
||||
|
||||
--color-violet-50: oklch(96.9% 0.016 293.756);
|
||||
--color-violet-100: oklch(94.3% 0.029 294.588);
|
||||
--color-violet-200: oklch(89.4% 0.057 293.283);
|
||||
--color-violet-300: oklch(81.1% 0.111 293.571);
|
||||
--color-violet-400: oklch(70.2% 0.183 293.541);
|
||||
--color-violet-500: oklch(60.6% 0.25 292.717);
|
||||
--color-violet-600: oklch(54.1% 0.281 293.009);
|
||||
--color-violet-700: oklch(49.1% 0.27 292.581);
|
||||
--color-violet-800: oklch(43.2% 0.232 292.759);
|
||||
--color-violet-900: oklch(38% 0.189 293.745);
|
||||
--color-violet-950: oklch(28.3% 0.141 291.089);
|
||||
|
||||
--color-purple-50: oklch(97.7% 0.014 308.299);
|
||||
--color-purple-100: oklch(94.6% 0.033 307.174);
|
||||
--color-purple-200: oklch(90.2% 0.063 306.703);
|
||||
--color-purple-300: oklch(82.7% 0.119 306.383);
|
||||
--color-purple-400: oklch(71.4% 0.203 305.504);
|
||||
--color-purple-500: oklch(62.7% 0.265 303.9);
|
||||
--color-purple-600: oklch(55.8% 0.288 302.321);
|
||||
--color-purple-700: oklch(49.6% 0.265 301.924);
|
||||
--color-purple-800: oklch(43.8% 0.218 303.724);
|
||||
--color-purple-900: oklch(38.1% 0.176 304.987);
|
||||
--color-purple-950: oklch(29.1% 0.149 302.717);
|
||||
|
||||
--color-fuchsia-50: oklch(97.7% 0.017 320.058);
|
||||
--color-fuchsia-100: oklch(95.2% 0.037 318.852);
|
||||
--color-fuchsia-200: oklch(90.3% 0.076 319.62);
|
||||
--color-fuchsia-300: oklch(83.3% 0.145 321.434);
|
||||
--color-fuchsia-400: oklch(74% 0.238 322.16);
|
||||
--color-fuchsia-500: oklch(66.7% 0.295 322.15);
|
||||
--color-fuchsia-600: oklch(59.1% 0.293 322.896);
|
||||
--color-fuchsia-700: oklch(51.8% 0.253 323.949);
|
||||
--color-fuchsia-800: oklch(45.2% 0.211 324.591);
|
||||
--color-fuchsia-900: oklch(40.1% 0.17 325.612);
|
||||
--color-fuchsia-950: oklch(29.3% 0.136 325.661);
|
||||
|
||||
--color-pink-50: oklch(97.1% 0.014 343.198);
|
||||
--color-pink-100: oklch(94.8% 0.028 342.258);
|
||||
--color-pink-200: oklch(89.9% 0.061 343.231);
|
||||
--color-pink-300: oklch(82.3% 0.12 346.018);
|
||||
--color-pink-400: oklch(71.8% 0.202 349.761);
|
||||
--color-pink-500: oklch(65.6% 0.241 354.308);
|
||||
--color-pink-600: oklch(59.2% 0.249 0.584);
|
||||
--color-pink-700: oklch(52.5% 0.223 3.958);
|
||||
--color-pink-800: oklch(45.9% 0.187 3.815);
|
||||
--color-pink-900: oklch(40.8% 0.153 2.432);
|
||||
--color-pink-950: oklch(28.4% 0.109 3.907);
|
||||
|
||||
--color-rose-50: oklch(96.9% 0.015 12.422);
|
||||
--color-rose-100: oklch(94.1% 0.03 12.58);
|
||||
--color-rose-200: oklch(89.2% 0.058 10.001);
|
||||
--color-rose-300: oklch(81% 0.117 11.638);
|
||||
--color-rose-400: oklch(71.2% 0.194 13.428);
|
||||
--color-rose-500: oklch(64.5% 0.246 16.439);
|
||||
--color-rose-600: oklch(58.6% 0.253 17.585);
|
||||
--color-rose-700: oklch(51.4% 0.222 16.935);
|
||||
--color-rose-800: oklch(45.5% 0.188 13.697);
|
||||
--color-rose-900: oklch(41% 0.159 10.272);
|
||||
--color-rose-950: oklch(27.1% 0.105 12.094);
|
||||
|
||||
--color-slate-50: oklch(98.4% 0.003 247.858);
|
||||
--color-slate-100: oklch(96.8% 0.007 247.896);
|
||||
--color-slate-200: oklch(92.9% 0.013 255.508);
|
||||
--color-slate-300: oklch(86.9% 0.022 252.894);
|
||||
--color-slate-400: oklch(70.4% 0.04 256.788);
|
||||
--color-slate-500: oklch(55.4% 0.046 257.417);
|
||||
--color-slate-600: oklch(44.6% 0.043 257.281);
|
||||
--color-slate-700: oklch(37.2% 0.044 257.287);
|
||||
--color-slate-800: oklch(27.9% 0.041 260.031);
|
||||
--color-slate-900: oklch(20.8% 0.042 265.755);
|
||||
--color-slate-950: oklch(12.9% 0.042 264.695);
|
||||
|
||||
--color-gray-50: oklch(98.5% 0.002 247.839);
|
||||
--color-gray-100: oklch(96.7% 0.003 264.542);
|
||||
--color-gray-200: oklch(92.8% 0.006 264.531);
|
||||
--color-gray-300: oklch(87.2% 0.01 258.338);
|
||||
--color-gray-400: oklch(70.7% 0.022 261.325);
|
||||
--color-gray-500: oklch(55.1% 0.027 264.364);
|
||||
--color-gray-600: oklch(44.6% 0.03 256.802);
|
||||
--color-gray-700: oklch(37.3% 0.034 259.733);
|
||||
--color-gray-800: oklch(27.8% 0.033 256.848);
|
||||
--color-gray-900: oklch(21% 0.034 264.665);
|
||||
--color-gray-950: oklch(13% 0.028 261.692);
|
||||
|
||||
--color-zinc-50: oklch(98.5% 0 0);
|
||||
--color-zinc-100: oklch(96.7% 0.001 286.375);
|
||||
--color-zinc-200: oklch(92% 0.004 286.32);
|
||||
--color-zinc-300: oklch(87.1% 0.006 286.286);
|
||||
--color-zinc-400: oklch(70.5% 0.015 286.067);
|
||||
--color-zinc-500: oklch(55.2% 0.016 285.938);
|
||||
--color-zinc-600: oklch(44.2% 0.017 285.786);
|
||||
--color-zinc-700: oklch(37% 0.013 285.805);
|
||||
--color-zinc-800: oklch(27.4% 0.006 286.033);
|
||||
--color-zinc-900: oklch(21% 0.006 285.885);
|
||||
--color-zinc-950: oklch(14.1% 0.005 285.823);
|
||||
|
||||
--color-neutral-50: oklch(98.5% 0 0);
|
||||
--color-neutral-100: oklch(97% 0 0);
|
||||
--color-neutral-200: oklch(92.2% 0 0);
|
||||
--color-neutral-300: oklch(87% 0 0);
|
||||
--color-neutral-400: oklch(70.8% 0 0);
|
||||
--color-neutral-500: oklch(55.6% 0 0);
|
||||
--color-neutral-600: oklch(43.9% 0 0);
|
||||
--color-neutral-700: oklch(37.1% 0 0);
|
||||
--color-neutral-800: oklch(26.9% 0 0);
|
||||
--color-neutral-900: oklch(20.5% 0 0);
|
||||
--color-neutral-950: oklch(14.5% 0 0);
|
||||
|
||||
--color-stone-50: oklch(98.5% 0.001 106.423);
|
||||
--color-stone-100: oklch(97% 0.001 106.424);
|
||||
--color-stone-200: oklch(92.3% 0.003 48.717);
|
||||
--color-stone-300: oklch(86.9% 0.005 56.366);
|
||||
--color-stone-400: oklch(70.9% 0.01 56.259);
|
||||
--color-stone-500: oklch(55.3% 0.013 58.071);
|
||||
--color-stone-600: oklch(44.4% 0.011 73.639);
|
||||
--color-stone-700: oklch(37.4% 0.01 67.558);
|
||||
--color-stone-800: oklch(26.8% 0.007 34.298);
|
||||
--color-stone-900: oklch(21.6% 0.006 56.043);
|
||||
--color-stone-950: oklch(14.7% 0.004 49.25);
|
||||
|
||||
--color-mauve-50: oklch(98.5% 0 0);
|
||||
--color-mauve-100: oklch(96% 0.003 325.6);
|
||||
--color-mauve-200: oklch(92.2% 0.005 325.62);
|
||||
--color-mauve-300: oklch(86.5% 0.012 325.68);
|
||||
--color-mauve-400: oklch(71.1% 0.019 323.02);
|
||||
--color-mauve-500: oklch(54.2% 0.034 322.5);
|
||||
--color-mauve-600: oklch(43.5% 0.029 321.78);
|
||||
--color-mauve-700: oklch(36.4% 0.029 323.89);
|
||||
--color-mauve-800: oklch(26.3% 0.024 320.12);
|
||||
--color-mauve-900: oklch(21.2% 0.019 322.12);
|
||||
--color-mauve-950: oklch(14.5% 0.008 326);
|
||||
|
||||
--color-olive-50: oklch(98.8% 0.003 106.5);
|
||||
--color-olive-100: oklch(96.6% 0.005 106.5);
|
||||
--color-olive-200: oklch(93% 0.007 106.5);
|
||||
--color-olive-300: oklch(88% 0.011 106.6);
|
||||
--color-olive-400: oklch(73.7% 0.021 106.9);
|
||||
--color-olive-500: oklch(58% 0.031 107.3);
|
||||
--color-olive-600: oklch(46.6% 0.025 107.3);
|
||||
--color-olive-700: oklch(39.4% 0.023 107.4);
|
||||
--color-olive-800: oklch(28.6% 0.016 107.4);
|
||||
--color-olive-900: oklch(22.8% 0.013 107.4);
|
||||
--color-olive-950: oklch(15.3% 0.006 107.1);
|
||||
|
||||
--color-mist-50: oklch(98.7% 0.002 197.1);
|
||||
--color-mist-100: oklch(96.3% 0.002 197.1);
|
||||
--color-mist-200: oklch(92.5% 0.005 214.3);
|
||||
--color-mist-300: oklch(87.2% 0.007 219.6);
|
||||
--color-mist-400: oklch(72.3% 0.014 214.4);
|
||||
--color-mist-500: oklch(56% 0.021 213.5);
|
||||
--color-mist-600: oklch(45% 0.017 213.2);
|
||||
--color-mist-700: oklch(37.8% 0.015 216);
|
||||
--color-mist-800: oklch(27.5% 0.011 216.9);
|
||||
--color-mist-900: oklch(21.8% 0.008 223.9);
|
||||
--color-mist-950: oklch(14.8% 0.004 228.8);
|
||||
|
||||
--color-taupe-50: oklch(98.6% 0.002 67.8);
|
||||
--color-taupe-100: oklch(96% 0.002 17.2);
|
||||
--color-taupe-200: oklch(92.2% 0.005 34.3);
|
||||
--color-taupe-300: oklch(86.8% 0.007 39.5);
|
||||
--color-taupe-400: oklch(71.4% 0.014 41.2);
|
||||
--color-taupe-500: oklch(54.7% 0.021 43.1);
|
||||
--color-taupe-600: oklch(43.8% 0.017 39.3);
|
||||
--color-taupe-700: oklch(36.7% 0.016 35.7);
|
||||
--color-taupe-800: oklch(26.8% 0.011 36.5);
|
||||
--color-taupe-900: oklch(21.4% 0.009 43.1);
|
||||
--color-taupe-950: oklch(14.7% 0.004 49.3);
|
||||
|
||||
--color-black: #000;
|
||||
--color-white: #fff;
|
||||
|
||||
--spacing: 0.25rem;
|
||||
|
||||
--breakpoint-sm: 40rem;
|
||||
--breakpoint-md: 48rem;
|
||||
--breakpoint-lg: 64rem;
|
||||
--breakpoint-xl: 80rem;
|
||||
--breakpoint-2xl: 96rem;
|
||||
|
||||
--container-3xs: 16rem;
|
||||
--container-2xs: 18rem;
|
||||
--container-xs: 20rem;
|
||||
--container-sm: 24rem;
|
||||
--container-md: 28rem;
|
||||
--container-lg: 32rem;
|
||||
--container-xl: 36rem;
|
||||
--container-2xl: 42rem;
|
||||
--container-3xl: 48rem;
|
||||
--container-4xl: 56rem;
|
||||
--container-5xl: 64rem;
|
||||
--container-6xl: 72rem;
|
||||
--container-7xl: 80rem;
|
||||
|
||||
--text-xs: 0.75rem;
|
||||
--text-xs--line-height: calc(1 / 0.75);
|
||||
--text-sm: 0.875rem;
|
||||
--text-sm--line-height: calc(1.25 / 0.875);
|
||||
--text-base: 1rem;
|
||||
--text-base--line-height: calc(1.5 / 1);
|
||||
--text-lg: 1.125rem;
|
||||
--text-lg--line-height: calc(1.75 / 1.125);
|
||||
--text-xl: 1.25rem;
|
||||
--text-xl--line-height: calc(1.75 / 1.25);
|
||||
--text-2xl: 1.5rem;
|
||||
--text-2xl--line-height: calc(2 / 1.5);
|
||||
--text-3xl: 1.875rem;
|
||||
--text-3xl--line-height: calc(2.25 / 1.875);
|
||||
--text-4xl: 2.25rem;
|
||||
--text-4xl--line-height: calc(2.5 / 2.25);
|
||||
--text-5xl: 3rem;
|
||||
--text-5xl--line-height: 1;
|
||||
--text-6xl: 3.75rem;
|
||||
--text-6xl--line-height: 1;
|
||||
--text-7xl: 4.5rem;
|
||||
--text-7xl--line-height: 1;
|
||||
--text-8xl: 6rem;
|
||||
--text-8xl--line-height: 1;
|
||||
--text-9xl: 8rem;
|
||||
--text-9xl--line-height: 1;
|
||||
|
||||
--font-weight-thin: 100;
|
||||
--font-weight-extralight: 200;
|
||||
--font-weight-light: 300;
|
||||
--font-weight-normal: 400;
|
||||
--font-weight-medium: 500;
|
||||
--font-weight-semibold: 600;
|
||||
--font-weight-bold: 700;
|
||||
--font-weight-extrabold: 800;
|
||||
--font-weight-black: 900;
|
||||
|
||||
--tracking-tighter: -0.05em;
|
||||
--tracking-tight: -0.025em;
|
||||
--tracking-normal: 0em;
|
||||
--tracking-wide: 0.025em;
|
||||
--tracking-wider: 0.05em;
|
||||
--tracking-widest: 0.1em;
|
||||
|
||||
--leading-tight: 1.25;
|
||||
--leading-snug: 1.375;
|
||||
--leading-normal: 1.5;
|
||||
--leading-relaxed: 1.625;
|
||||
--leading-loose: 2;
|
||||
|
||||
--radius-xs: 0.125rem;
|
||||
--radius-sm: 0.25rem;
|
||||
--radius-md: 0.375rem;
|
||||
--radius-lg: 0.5rem;
|
||||
--radius-xl: 0.75rem;
|
||||
--radius-2xl: 1rem;
|
||||
--radius-3xl: 1.5rem;
|
||||
--radius-4xl: 2rem;
|
||||
|
||||
--shadow-2xs: 0 1px rgb(0 0 0 / 0.05);
|
||||
--shadow-xs: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
--shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
|
||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
|
||||
--shadow-2xl: 0 25px 50px -12px rgb(0 0 0 / 0.25);
|
||||
|
||||
--inset-shadow-2xs: inset 0 1px rgb(0 0 0 / 0.05);
|
||||
--inset-shadow-xs: inset 0 1px 1px rgb(0 0 0 / 0.05);
|
||||
--inset-shadow-sm: inset 0 2px 4px rgb(0 0 0 / 0.05);
|
||||
|
||||
--drop-shadow-xs: 0 1px 1px rgb(0 0 0 / 0.05);
|
||||
--drop-shadow-sm: 0 1px 2px rgb(0 0 0 / 0.15);
|
||||
--drop-shadow-md: 0 3px 3px rgb(0 0 0 / 0.12);
|
||||
--drop-shadow-lg: 0 4px 4px rgb(0 0 0 / 0.15);
|
||||
--drop-shadow-xl: 0 9px 7px rgb(0 0 0 / 0.1);
|
||||
--drop-shadow-2xl: 0 25px 25px rgb(0 0 0 / 0.15);
|
||||
|
||||
--text-shadow-2xs: 0px 1px 0px rgb(0 0 0 / 0.15);
|
||||
--text-shadow-xs: 0px 1px 1px rgb(0 0 0 / 0.2);
|
||||
--text-shadow-sm:
|
||||
0px 1px 0px rgb(0 0 0 / 0.075), 0px 1px 1px rgb(0 0 0 / 0.075), 0px 2px 2px rgb(0 0 0 / 0.075);
|
||||
--text-shadow-md:
|
||||
0px 1px 1px rgb(0 0 0 / 0.1), 0px 1px 2px rgb(0 0 0 / 0.1), 0px 2px 4px rgb(0 0 0 / 0.1);
|
||||
--text-shadow-lg:
|
||||
0px 1px 2px rgb(0 0 0 / 0.1), 0px 3px 2px rgb(0 0 0 / 0.1), 0px 4px 8px rgb(0 0 0 / 0.1);
|
||||
|
||||
--ease-in: cubic-bezier(0.4, 0, 1, 1);
|
||||
--ease-out: cubic-bezier(0, 0, 0.2, 1);
|
||||
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
--animate-spin: spin 1s linear infinite;
|
||||
--animate-ping: ping 1s cubic-bezier(0, 0, 0.2, 1) infinite;
|
||||
--animate-pulse: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
--animate-bounce: bounce 1s infinite;
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ping {
|
||||
75%,
|
||||
100% {
|
||||
transform: scale(2);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(-25%);
|
||||
animation-timing-function: cubic-bezier(0.8, 0, 1, 1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: none;
|
||||
animation-timing-function: cubic-bezier(0, 0, 0.2, 1);
|
||||
}
|
||||
}
|
||||
|
||||
--blur-xs: 4px;
|
||||
--blur-sm: 8px;
|
||||
--blur-md: 12px;
|
||||
--blur-lg: 16px;
|
||||
--blur-xl: 24px;
|
||||
--blur-2xl: 40px;
|
||||
--blur-3xl: 64px;
|
||||
|
||||
--perspective-dramatic: 100px;
|
||||
--perspective-near: 300px;
|
||||
--perspective-normal: 500px;
|
||||
--perspective-midrange: 800px;
|
||||
--perspective-distant: 1200px;
|
||||
|
||||
--aspect-video: 16 / 9;
|
||||
|
||||
--default-transition-duration: 150ms;
|
||||
--default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--default-font-family: --theme(--font-sans, initial);
|
||||
--default-font-feature-settings: --theme(--font-sans--font-feature-settings, initial);
|
||||
--default-font-variation-settings: --theme(--font-sans--font-variation-settings, initial);
|
||||
--default-mono-font-family: --theme(--font-mono, initial);
|
||||
--default-mono-font-feature-settings: --theme(--font-mono--font-feature-settings, initial);
|
||||
--default-mono-font-variation-settings: --theme(--font-mono--font-variation-settings, initial);
|
||||
}
|
||||
|
||||
/* Deprecated */
|
||||
@theme default inline reference {
|
||||
--blur: 8px;
|
||||
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
|
||||
--shadow-inner: inset 0 2px 4px 0 rgb(0 0 0 / 0.05);
|
||||
--drop-shadow: 0 1px 2px rgb(0 0 0 / 0.1), 0 1px 1px rgb(0 0 0 / 0.06);
|
||||
--radius: 0.25rem;
|
||||
--max-width-prose: 65ch;
|
||||
}
|
||||
111
go/bundler/vendor_plugins.go
Normal file
111
go/bundler/vendor_plugins.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package bundler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
esbuild "github.com/evanw/esbuild/pkg/api"
|
||||
)
|
||||
|
||||
// VendorManifest mirrors frontend/vendor/vendor.json.
|
||||
type VendorManifest struct {
|
||||
Entrypoints map[string]string `json:"entrypoints"`
|
||||
}
|
||||
|
||||
// loadVendorManifest reads frontend/vendor/vendor.json — the single place that
|
||||
// declares the exact entrypoint file for every bundled vendored package.
|
||||
func loadVendorManifest(vendorDir string) (map[string]string, error) {
|
||||
data, err := os.ReadFile(filepath.Join(vendorDir, "vendor.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var m VendorManifest
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
return nil, fmt.Errorf("parsing vendor.json: %w", err)
|
||||
}
|
||||
return m.Entrypoints, nil
|
||||
}
|
||||
|
||||
// vendorManifestPlugin resolves bare imports from the vendor manifest: a package
|
||||
// listed in vendor.json resolves to its pinned entrypoint file, bypassing the
|
||||
// package's own `exports` map (which mis-resolves — e.g. solid-js's bare core to
|
||||
// its SSR build, where effects are no-ops). Subpath imports of a vendored package
|
||||
// (e.g. pdfjs-dist/build/pdf.worker.min.mjs) resolve to the real file via
|
||||
// NodePaths; anything not vendored is left external for the import map.
|
||||
func vendorManifestPlugin(vendorDir string, entrypoints map[string]string) esbuild.Plugin {
|
||||
abs := make(map[string]string, len(entrypoints))
|
||||
for spec, rel := range entrypoints {
|
||||
p, _ := filepath.Abs(filepath.Join(vendorDir, filepath.FromSlash(rel)))
|
||||
abs[spec] = filepath.ToSlash(p)
|
||||
}
|
||||
return esbuild.Plugin{
|
||||
Name: "vendor-manifest",
|
||||
Setup: func(build esbuild.PluginBuild) {
|
||||
// Bare specifiers only (relative/absolute/entry/?suffixed are skipped).
|
||||
build.OnResolve(esbuild.OnResolveOptions{Filter: `^[^./]`}, func(args esbuild.OnResolveArgs) (esbuild.OnResolveResult, error) {
|
||||
if args.Kind == esbuild.ResolveEntryPoint || filepath.IsAbs(args.Path) || strings.HasPrefix(args.Path, ".") || strings.ContainsRune(args.Path, '?') {
|
||||
return esbuild.OnResolveResult{}, nil
|
||||
}
|
||||
if target, ok := abs[args.Path]; ok {
|
||||
return esbuild.OnResolveResult{Path: target}, nil // pinned entrypoint
|
||||
}
|
||||
if vendorHasPackage(vendorDir, args.Path) {
|
||||
return esbuild.OnResolveResult{}, nil // subpath of a vendored pkg -> NodePaths
|
||||
}
|
||||
return esbuild.OnResolveResult{External: true}, nil // not vendored -> import map
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// vendorHasPackage reports whether a bare specifier maps to a package directory
|
||||
// under vendorDir, honouring @scope/name.
|
||||
func vendorHasPackage(vendorDir, spec string) bool {
|
||||
parts := strings.Split(spec, "/")
|
||||
pkg := parts[0]
|
||||
if strings.HasPrefix(pkg, "@") && len(parts) > 1 {
|
||||
pkg = pkg + "/" + parts[1]
|
||||
}
|
||||
info, err := os.Stat(filepath.Join(vendorDir, filepath.FromSlash(pkg)))
|
||||
return err == nil && info.IsDir()
|
||||
}
|
||||
|
||||
// assetURLPlugin implements a generic `import url from "<path>?url"`: the
|
||||
// referenced file is emitted as a build asset (via esbuild's file loader,
|
||||
// controlled by BuildOptions.AssetNames/PublicPath) and the import resolves to
|
||||
// its served URL. Used for resources that must stay separate files rather than
|
||||
// be inlined — e.g. the pdfjs Web Worker, which relies on import.meta.url and so
|
||||
// can't run from a Blob. The asset path lives in the app code that needs it, not
|
||||
// in the bundler.
|
||||
func assetURLPlugin() esbuild.Plugin {
|
||||
const ns = "asset-url"
|
||||
return esbuild.Plugin{
|
||||
Name: "asset-url",
|
||||
Setup: func(build esbuild.PluginBuild) {
|
||||
build.OnResolve(esbuild.OnResolveOptions{Filter: `\?url$`}, func(args esbuild.OnResolveArgs) (esbuild.OnResolveResult, error) {
|
||||
real := strings.TrimSuffix(args.Path, "?url")
|
||||
r := build.Resolve(real, esbuild.ResolveOptions{
|
||||
ResolveDir: args.ResolveDir,
|
||||
Importer: args.Importer,
|
||||
Kind: args.Kind,
|
||||
})
|
||||
if len(r.Errors) > 0 {
|
||||
return esbuild.OnResolveResult{}, fmt.Errorf("asset-url: cannot resolve %q: %s", real, r.Errors[0].Text)
|
||||
}
|
||||
return esbuild.OnResolveResult{Path: r.Path, Namespace: ns}, nil
|
||||
})
|
||||
build.OnLoad(esbuild.OnLoadOptions{Filter: `.*`, Namespace: ns}, func(args esbuild.OnLoadArgs) (esbuild.OnLoadResult, error) {
|
||||
data, err := os.ReadFile(args.Path)
|
||||
if err != nil {
|
||||
return esbuild.OnLoadResult{}, err
|
||||
}
|
||||
contents := string(data)
|
||||
loader := esbuild.LoaderFile
|
||||
return esbuild.OnLoadResult{Contents: &contents, Loader: loader}, nil
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user