Add js web stuff to landing page + documentation
This commit is contained in:
202
go/jsbundler/js.go
Normal file
202
go/jsbundler/js.go
Normal file
@@ -0,0 +1,202 @@
|
||||
package jsbundler
|
||||
|
||||
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 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
|
||||
}
|
||||
|
||||
vdirs := vendorDirs()
|
||||
|
||||
// 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).
|
||||
// Manifests from every vendor dir are merged (kjol runtime wins) so the shared
|
||||
// solid-js resolves to ONE copy across the app + kit trees.
|
||||
entrypoints, err := loadVendorManifest(vdirs)
|
||||
if err != nil {
|
||||
return bundleStats{}, fmt.Errorf("loading vendor manifest: %w", err)
|
||||
}
|
||||
|
||||
// aliasPlugin resolves @ui/@kjol/@appgen into the kjol JS tree (and the app's
|
||||
// generated dir) first. 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{aliasPlugin(), defaultExportShimPlugin(), Plugin()}
|
||||
plugins = append(plugins, assetURLPlugin(), vendorManifestPlugin(entrypoints, vdirs))
|
||||
|
||||
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: frontendDir,
|
||||
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: vdirs,
|
||||
// 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], `":{`)
|
||||
}
|
||||
Reference in New Issue
Block a user