add bundler stuff
This commit is contained in:
76
go/bundler/alias.go
Normal file
76
go/bundler/alias.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package bundler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
esbuild "github.com/evanw/esbuild/pkg/api"
|
||||
)
|
||||
|
||||
// aliasRoots maps the framework import prefixes to absolute filesystem roots:
|
||||
//
|
||||
// @ui/* -> kjol web kit (shared Solid components)
|
||||
// @kjol/* -> kjol web root (auth, utils, hooks, ssr, env.ts, ...)
|
||||
// @appgen/* -> the app's generated dir (faIcons registry etc. — app-owned)
|
||||
//
|
||||
// In single-tree mode @ui/@kjol resolve back under the app frontend.
|
||||
func aliasRoots() map[string]string {
|
||||
abs := func(p string) string { a, _ := filepath.Abs(p); return a }
|
||||
return map[string]string{
|
||||
"@ui": abs(kitSrcDir()),
|
||||
"@kjol": abs(webRoot()),
|
||||
"@appgen": abs(genTSDir),
|
||||
}
|
||||
}
|
||||
|
||||
// resolveAlias turns "<root>/<rest>" into a concrete file, probing the usual
|
||||
// TS/JS extensions and index files when the specifier is extensionless.
|
||||
func resolveAlias(root, rest string) (string, bool) {
|
||||
base := filepath.Join(root, filepath.FromSlash(rest))
|
||||
var cands []string
|
||||
if filepath.Ext(base) != "" {
|
||||
cands = append(cands, base)
|
||||
} else {
|
||||
for _, e := range []string{".tsx", ".ts", ".jsx", ".js"} {
|
||||
cands = append(cands, base+e)
|
||||
}
|
||||
for _, e := range []string{".tsx", ".ts", ".jsx", ".js"} {
|
||||
cands = append(cands, filepath.Join(base, "index"+e))
|
||||
}
|
||||
}
|
||||
for _, c := range cands {
|
||||
if fi, err := os.Stat(c); err == nil && !fi.IsDir() {
|
||||
return c, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// aliasPlugin resolves @ui / @kjol / @appgen imports to absolute paths across the
|
||||
// app and kjol web trees. It sits before the vendor resolvers; the Solid
|
||||
// compiler's OnLoad then compiles any .tsx/.jsx it points at.
|
||||
func aliasPlugin() esbuild.Plugin {
|
||||
roots := aliasRoots()
|
||||
return esbuild.Plugin{
|
||||
Name: "kjol-alias",
|
||||
Setup: func(b esbuild.PluginBuild) {
|
||||
b.OnResolve(esbuild.OnResolveOptions{Filter: `^@(ui|kjol|appgen)/`}, func(a esbuild.OnResolveArgs) (esbuild.OnResolveResult, error) {
|
||||
slash := strings.IndexByte(a.Path, '/')
|
||||
if slash < 0 {
|
||||
return esbuild.OnResolveResult{}, nil
|
||||
}
|
||||
prefix, rest := a.Path[:slash], a.Path[slash+1:]
|
||||
root, ok := roots[prefix]
|
||||
if !ok {
|
||||
return esbuild.OnResolveResult{}, nil
|
||||
}
|
||||
if p, ok := resolveAlias(root, rest); ok {
|
||||
return esbuild.OnResolveResult{Path: p}, nil
|
||||
}
|
||||
return esbuild.OnResolveResult{}, fmt.Errorf("kjol-alias: cannot resolve %q under %s", a.Path, root)
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -27,17 +27,17 @@ import (
|
||||
"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 {
|
||||
// the JS/CSS bundles, and the SSR bake, printing a stats summary. c selects the
|
||||
// app + kjol web trees (see Config); zero-value fields fall back to the
|
||||
// single-tree defaults.
|
||||
func Build(c Config) error {
|
||||
Configure(c)
|
||||
// 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.
|
||||
|
||||
96
go/bundler/config.go
Normal file
96
go/bundler/config.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package bundler
|
||||
|
||||
import "path/filepath"
|
||||
|
||||
// Config tells the bundler where the application tree and the shared kjol web
|
||||
// tree live. The bundler always runs from the app root, so relative paths are
|
||||
// resolved against that working directory (absolute paths also work).
|
||||
//
|
||||
// When WebDir is empty the bundler runs in single-tree mode: the kit, vendored
|
||||
// runtime, icons, and styles are expected under AppFrontend (the pre-extraction
|
||||
// layout). When WebDir points at kjol/web, those come from the shared tree and
|
||||
// the app supplies only its pages/routes/brand.
|
||||
type Config struct {
|
||||
AppFrontend string // app frontend source root (default "frontend")
|
||||
WebDir string // path to kjol/web; "" => single-tree under AppFrontend
|
||||
Output string // build output dir (default "wwwroot")
|
||||
GenGoDir string // dir for generated Go, e.g. public_pages.gen.go (default "internal/handlers")
|
||||
GenTSDir string // dir for generated TS, e.g. faIcons.ts (default AppFrontend/src/ui/generated)
|
||||
}
|
||||
|
||||
// Package-level resolved configuration. Set once by Configure (called from
|
||||
// Build / StartDevHMR) and read by every stage. Defaults preserve the original
|
||||
// single-tree behaviour so nothing breaks before an app opts into the kjol tree.
|
||||
var (
|
||||
frontendDir = "frontend"
|
||||
outputDir = "wwwroot"
|
||||
webDir = "" // kjol/web; empty => single-tree
|
||||
genGoDir = "internal/handlers"
|
||||
genTSDir = filepath.Join("frontend", "src", "ui", "generated")
|
||||
)
|
||||
|
||||
// Configure applies c, filling defaults for empty fields.
|
||||
func Configure(c Config) {
|
||||
if c.AppFrontend != "" {
|
||||
frontendDir = c.AppFrontend
|
||||
}
|
||||
if c.Output != "" {
|
||||
outputDir = c.Output
|
||||
}
|
||||
if c.GenGoDir != "" {
|
||||
genGoDir = c.GenGoDir
|
||||
}
|
||||
webDir = c.WebDir
|
||||
if c.GenTSDir != "" {
|
||||
genTSDir = c.GenTSDir
|
||||
} else {
|
||||
genTSDir = filepath.Join(frontendDir, "src", "ui", "generated")
|
||||
}
|
||||
}
|
||||
|
||||
// webRoot is the kjol web tree (@kjol/* root), falling back to the app frontend
|
||||
// in single-tree mode.
|
||||
func webRoot() string {
|
||||
if webDir != "" {
|
||||
return webDir
|
||||
}
|
||||
return frontendDir
|
||||
}
|
||||
|
||||
// kitSrcDir is where the shared Solid component kit lives (@ui/* root).
|
||||
func kitSrcDir() string {
|
||||
if webDir != "" {
|
||||
return filepath.Join(webDir, "kit")
|
||||
}
|
||||
return filepath.Join(frontendDir, "src", "ui")
|
||||
}
|
||||
|
||||
// iconsDir is the FontAwesome SVG source kit.
|
||||
func iconsDir() string {
|
||||
if webDir != "" {
|
||||
return filepath.Join(webDir, "icons")
|
||||
}
|
||||
return filepath.Join(frontendDir, "icons")
|
||||
}
|
||||
|
||||
// vendorDirs lists the vendored-runtime roots in resolution precedence order.
|
||||
// The kjol runtime comes first so its solid-js wins (a split reactive instance
|
||||
// silently breaks effect flushing).
|
||||
func vendorDirs() []string {
|
||||
if webDir != "" {
|
||||
return []string{filepath.Join(webDir, "runtime"), filepath.Join(frontendDir, "vendor")}
|
||||
}
|
||||
return []string{filepath.Join(frontendDir, "vendor")}
|
||||
}
|
||||
|
||||
// themeCSSPath is the shared Tailwind @theme scaffold prepended to the app's
|
||||
// brand style.css, or "" in single-tree mode (the app's style.css is complete).
|
||||
func themeCSSPath() string {
|
||||
if webDir != "" {
|
||||
return filepath.Join(webDir, "styles", "theme.css")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// faOutPath is where the generated FA registry is written (app-owned).
|
||||
func faOutPath() string { return filepath.Join(genTSDir, "faIcons.ts") }
|
||||
96
go/bundler/crosstree_test.go
Normal file
96
go/bundler/crosstree_test.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package bundler
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestCrossTreeJSBundle proves the bundler resolves the shared kit across the
|
||||
// app/kjol tree boundary: a temp app entry imports @ui/Buttons, which pulls the
|
||||
// real kjol web/kit (Buttons -> ./Icons -> @appgen/faIcons stub) and the solid
|
||||
// runtime from web/runtime. If the alias plugin, merged vendor manifest, and
|
||||
// multi-dir NodePaths all work, esbuild produces a non-empty bundle.
|
||||
func TestCrossTreeJSBundle(t *testing.T) {
|
||||
webAbs, err := filepath.Abs(filepath.Join("..", "..", "web"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(webAbs, "kit", "Buttons.tsx")); err != nil {
|
||||
t.Skipf("kjol web kit not present (%v)", err)
|
||||
}
|
||||
|
||||
app := t.TempDir()
|
||||
gen := t.TempDir()
|
||||
out := t.TempDir()
|
||||
|
||||
if err := os.MkdirAll(filepath.Join(app, "src"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entry := filepath.Join(app, "src", "app.ts")
|
||||
entrySrc := "import * as Buttons from \"@ui/Buttons\";\n" +
|
||||
"(globalThis as any).__kit = Buttons;\n"
|
||||
if err := os.WriteFile(entry, []byte(entrySrc), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Stub the app-owned generated FA registry that Icons.tsx imports via @appgen.
|
||||
if err := os.WriteFile(filepath.Join(gen, "faIcons.ts"), []byte("export const FA_ICONS = {};\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
Configure(Config{AppFrontend: app, WebDir: webAbs, Output: out, GenTSDir: gen})
|
||||
|
||||
if _, err := bundleJSEntry(entry, "app.test.js"); err != nil {
|
||||
t.Fatalf("cross-tree bundle failed: %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(out, "app.test.js"))
|
||||
if err != nil {
|
||||
t.Fatalf("no output bundle: %v", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
t.Fatal("empty bundle")
|
||||
}
|
||||
t.Logf("cross-tree bundle produced %d bytes", len(data))
|
||||
}
|
||||
|
||||
// TestCrossTreeCSS confirms the CSS pipeline compiles with the kit tree scanned
|
||||
// for utility candidates across the boundary (WebDir set) without error, and
|
||||
// that an app-side utility class makes it into the output.
|
||||
func TestCrossTreeCSS(t *testing.T) {
|
||||
webAbs, err := filepath.Abs(filepath.Join("..", "..", "web"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(webAbs, "kit")); err != nil {
|
||||
t.Skipf("kjol web kit not present (%v)", err)
|
||||
}
|
||||
|
||||
app := t.TempDir()
|
||||
out := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(app, "css"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(app, "src"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(app, "css", "style.css"), []byte("@import \"tailwindcss\";\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(app, "src", "app.ts"), []byte("export const x = `<div class=\"p-4\"></div>`;\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
Configure(Config{AppFrontend: app, WebDir: webAbs, Output: out})
|
||||
if _, err := compileCSSBundle("test", []string{"../src/**/*.ts"}, "test.css"); err != nil {
|
||||
t.Fatalf("cross-tree css compile failed: %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(out, "test.css"))
|
||||
if err != nil {
|
||||
t.Fatalf("no output css: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), "padding") {
|
||||
t.Errorf("expected the app utility .p-4 to compile into the output")
|
||||
}
|
||||
t.Logf("cross-tree css produced %d bytes", len(data))
|
||||
}
|
||||
@@ -69,9 +69,24 @@ func compileCSSBundle(label string, twSources []string, outName string) (bundleS
|
||||
}
|
||||
cssDir := filepath.Dir(entryPath)
|
||||
|
||||
// Candidate classes come from the app sources (patterns relative to the css
|
||||
// dir) plus the shared kit tree scanned directly, so kit component classes are
|
||||
// present even though the kit lives outside the app frontend.
|
||||
candidates := scanSources(cssDir, twSources)
|
||||
kitCands := scanSources(kitSrcDir(), []string{"**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"})
|
||||
candidates = dedupStrings(append(candidates, kitCands...))
|
||||
|
||||
compiled, count, err := twCompile(string(src), cssDir, candidates)
|
||||
// Prepend the shared @theme scaffold (kjol web/styles/theme.css) ahead of the
|
||||
// app's brand style.css so its tokens/vars are in scope. Absent in single-tree
|
||||
// mode (the app's style.css is already complete).
|
||||
input := string(src)
|
||||
if tp := themeCSSPath(); tp != "" {
|
||||
if theme, e := os.ReadFile(tp); e == nil {
|
||||
input = string(theme) + "\n" + input
|
||||
}
|
||||
}
|
||||
|
||||
compiled, count, err := twCompile(input, cssDir, candidates)
|
||||
if err != nil {
|
||||
return bundleStats{}, fmt.Errorf("tailwind compile (%s): %w", label, err)
|
||||
}
|
||||
@@ -89,3 +104,16 @@ func compileCSSBundle(label string, twSources []string, outName string) (bundleS
|
||||
}
|
||||
return bundleStats{files: len(candidates), bytes: len(minified)}, nil
|
||||
}
|
||||
|
||||
// dedupStrings returns in with duplicates removed, preserving first-seen order.
|
||||
func dedupStrings(in []string) []string {
|
||||
seen := make(map[string]bool, len(in))
|
||||
out := make([]string, 0, len(in))
|
||||
for _, s := range in {
|
||||
if !seen[s] {
|
||||
seen[s] = true
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -23,11 +23,9 @@ var faStyleDirs = map[string]string{
|
||||
"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")
|
||||
// The FA SVG source dir (iconsDir) and the generated registry output path
|
||||
// (faOutPath) are resolved from Config — see config.go. Only the styles in
|
||||
// faStyleDirs are read; the rest of the kit is unused.
|
||||
|
||||
var (
|
||||
// icon="name" / icon: "name"
|
||||
@@ -44,11 +42,13 @@ var (
|
||||
// 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 {
|
||||
if _, err := os.Stat(iconsDir()); err != nil {
|
||||
return nil // SVGs absent — keep the committed registry
|
||||
}
|
||||
|
||||
names, custom, err := scanIconNames(filepath.Join(frontendDir, "src"))
|
||||
// Scan both the app pages and the shared kit so every icon either tree
|
||||
// references ends up in this app's registry.
|
||||
names, custom, err := scanIconNames([]string{filepath.Join(frontendDir, "src"), kitSrcDir()})
|
||||
if err != nil {
|
||||
return fmt.Errorf("scanning icon names: %w", err)
|
||||
}
|
||||
@@ -61,7 +61,7 @@ func generateFAIcons() error {
|
||||
for _, name := range names {
|
||||
found := false
|
||||
for prefix, dir := range faStyleDirs {
|
||||
svg, err := os.ReadFile(filepath.Join(faIconsDir, dir, name+".svg"))
|
||||
svg, err := os.ReadFile(filepath.Join(iconsDir(), dir, name+".svg"))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -90,10 +90,10 @@ func generateFAIcons() error {
|
||||
}
|
||||
b.WriteString("};\n")
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(faOutFile), 0755); err != nil {
|
||||
if err := os.MkdirAll(filepath.Dir(faOutPath()), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(faOutFile, []byte(b.String()), 0644); err != nil {
|
||||
if err := os.WriteFile(faOutPath(), []byte(b.String()), 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -126,10 +126,10 @@ func generateFAIcons() error {
|
||||
// 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) {
|
||||
func scanIconNames(dirs []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 {
|
||||
walk := func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return err
|
||||
}
|
||||
@@ -155,10 +155,15 @@ func scanIconNames(dir string) (names []string, custom map[string]bool, err erro
|
||||
custom[m[1]] = true
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
}
|
||||
for _, dir := range dirs {
|
||||
if _, e := os.Stat(dir); e != nil {
|
||||
continue // tree not present (e.g. no separate kit in single-tree mode)
|
||||
}
|
||||
if err = filepath.WalkDir(dir, walk); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
names = make([]string, 0, len(set))
|
||||
for n := range set {
|
||||
names = append(names, n)
|
||||
|
||||
@@ -59,21 +59,25 @@ func bundleJSEntry(entry, outName string) (bundleStats, error) {
|
||||
drop = esbuild.DropDebugger
|
||||
}
|
||||
|
||||
vendorDir := filepath.Join(frontendDir, "vendor")
|
||||
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).
|
||||
entrypoints, err := loadVendorManifest(vendorDir)
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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))
|
||||
// aliasPlugin resolves @ui/@kjol/@appgen into the kjol web 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},
|
||||
@@ -84,7 +88,7 @@ func bundleJSEntry(entry, outName string) (bundleStats, error) {
|
||||
Target: esbuild.ES2022,
|
||||
Sourcemap: esbuild.SourceMapLinked,
|
||||
SourceRoot: "./",
|
||||
Outbase: "frontend",
|
||||
Outbase: frontendDir,
|
||||
SourcesContent: esbuild.SourcesContentInclude,
|
||||
MinifyWhitespace: true,
|
||||
MinifyIdentifiers: true,
|
||||
@@ -105,7 +109,7 @@ func bundleJSEntry(entry, outName string) (bundleStats, error) {
|
||||
// data-driven with no per-package list in this source.
|
||||
Platform: esbuild.PlatformBrowser,
|
||||
Conditions: []string{"development"},
|
||||
NodePaths: []string{vendorDir},
|
||||
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.
|
||||
|
||||
@@ -168,7 +168,16 @@ func bundleEntry(entrySource, projectRoot string, withMeta bool) (js, metafile s
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("resolve project root: %w", err)
|
||||
}
|
||||
vendorDir := filepath.Join(absRoot, "frontend", "vendor")
|
||||
// Absolute vendor roots (kjol runtime first, then app vendor) so SSR resolves
|
||||
// the SAME solid-js the client bundle does.
|
||||
var absVendorDirs []string
|
||||
for _, d := range vendorDirs() {
|
||||
if filepath.IsAbs(d) {
|
||||
absVendorDirs = append(absVendorDirs, d)
|
||||
} else {
|
||||
absVendorDirs = append(absVendorDirs, filepath.Join(absRoot, d))
|
||||
}
|
||||
}
|
||||
|
||||
// vendor.json pins each vendored package's exact entrypoint file (see
|
||||
// vendor_plugins.go) because a package's own `exports`/`main`/`module` fields
|
||||
@@ -176,7 +185,8 @@ func bundleEntry(entrySource, projectRoot string, withMeta bool) (js, metafile s
|
||||
// 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)
|
||||
// loadVendorManifest returns absolute entrypoint paths.
|
||||
vendorEntrypoints, err := loadVendorManifest(absVendorDirs)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("loading vendor manifest: %w", err)
|
||||
}
|
||||
@@ -201,11 +211,7 @@ func bundleEntry(entrySource, projectRoot string, withMeta bool) (js, metafile s
|
||||
}
|
||||
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{Path: target}, nil // pinned dev entrypoint (absolute)
|
||||
}
|
||||
return esbuild.OnResolveResult{}, nil // unpinned subpath -> real solid runtime via NodePaths
|
||||
}
|
||||
@@ -234,7 +240,7 @@ func bundleEntry(entrySource, projectRoot string, withMeta bool) (js, metafile s
|
||||
Target: esbuild.ES2017,
|
||||
Platform: esbuild.PlatformBrowser,
|
||||
Conditions: []string{"development"},
|
||||
NodePaths: []string{vendorDir},
|
||||
NodePaths: absVendorDirs,
|
||||
Plugins: plugins,
|
||||
LogLevel: esbuild.LogLevelSilent,
|
||||
Write: false,
|
||||
|
||||
@@ -15,32 +15,43 @@ 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"))
|
||||
// loadVendorManifest reads and merges vendor.json from every vendor dir (kjol
|
||||
// runtime first, app vendor second), returning spec -> ABSOLUTE entrypoint path.
|
||||
// Higher-precedence dirs win on conflict, so the shared solid-js always resolves
|
||||
// to the kjol runtime copy (a split reactive instance silently breaks effects).
|
||||
// A missing vendor.json in a dir is skipped.
|
||||
func loadVendorManifest(vendorDirs []string) (map[string]string, error) {
|
||||
abs := map[string]string{}
|
||||
for _, dir := range vendorDirs {
|
||||
data, err := os.ReadFile(filepath.Join(dir, "vendor.json"))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var m VendorManifest
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
return nil, fmt.Errorf("parsing vendor.json: %w", err)
|
||||
return nil, fmt.Errorf("parsing %s/vendor.json: %w", dir, err)
|
||||
}
|
||||
return m.Entrypoints, nil
|
||||
for spec, rel := range m.Entrypoints {
|
||||
if _, exists := abs[spec]; exists {
|
||||
continue // earlier (higher-precedence) dir wins
|
||||
}
|
||||
|
||||
// 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)))
|
||||
p, _ := filepath.Abs(filepath.Join(dir, filepath.FromSlash(rel)))
|
||||
abs[spec] = filepath.ToSlash(p)
|
||||
}
|
||||
}
|
||||
return abs, nil
|
||||
}
|
||||
|
||||
// vendorManifestPlugin resolves bare imports from the merged vendor manifest: a
|
||||
// package listed in a vendor.json resolves to its pinned (absolute) entrypoint,
|
||||
// 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 resolve to the real file via NodePaths; anything not vendored
|
||||
// is left external for the import map.
|
||||
func vendorManifestPlugin(entrypoints map[string]string, vendorDirs []string) esbuild.Plugin {
|
||||
return esbuild.Plugin{
|
||||
Name: "vendor-manifest",
|
||||
Setup: func(build esbuild.PluginBuild) {
|
||||
@@ -49,10 +60,10 @@ func vendorManifestPlugin(vendorDir string, entrypoints map[string]string) esbui
|
||||
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 {
|
||||
if target, ok := entrypoints[args.Path]; ok {
|
||||
return esbuild.OnResolveResult{Path: target}, nil // pinned entrypoint
|
||||
}
|
||||
if vendorHasPackage(vendorDir, args.Path) {
|
||||
if vendorHasPackage(vendorDirs, args.Path) {
|
||||
return esbuild.OnResolveResult{}, nil // subpath of a vendored pkg -> NodePaths
|
||||
}
|
||||
return esbuild.OnResolveResult{External: true}, nil // not vendored -> import map
|
||||
@@ -62,15 +73,20 @@ func vendorManifestPlugin(vendorDir string, entrypoints map[string]string) esbui
|
||||
}
|
||||
|
||||
// vendorHasPackage reports whether a bare specifier maps to a package directory
|
||||
// under vendorDir, honouring @scope/name.
|
||||
func vendorHasPackage(vendorDir, spec string) bool {
|
||||
// under any vendor dir, honouring @scope/name.
|
||||
func vendorHasPackage(vendorDirs []string, 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()
|
||||
for _, dir := range vendorDirs {
|
||||
info, err := os.Stat(filepath.Join(dir, filepath.FromSlash(pkg)))
|
||||
if err == nil && info.IsDir() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// assetURLPlugin implements a generic `import url from "<path>?url"`: the
|
||||
|
||||
@@ -4,6 +4,7 @@ package main
|
||||
// Solid JSX compiler (see bundler.Build), so this wrapper carries no build logic.
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
@@ -11,7 +12,21 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := bundler.Build(); err != nil {
|
||||
app := flag.String("app", "frontend", "App frontend source root (relative to cwd).")
|
||||
web := flag.String("web", "kjol/web", "Path to the kjol web tree (kit, runtime, icons, styles).")
|
||||
out := flag.String("out", "wwwroot", "Build output directory.")
|
||||
genGo := flag.String("gen-go", "internal/handlers", "Directory for generated Go files.")
|
||||
genTS := flag.String("gen-ts", "", "Directory for the generated TS icon registry (default <app>/src/ui/generated).")
|
||||
flag.Parse()
|
||||
|
||||
cfg := bundler.Config{
|
||||
AppFrontend: *app,
|
||||
WebDir: *web,
|
||||
Output: *out,
|
||||
GenGoDir: *genGo,
|
||||
GenTSDir: *genTS,
|
||||
}
|
||||
if err := bundler.Build(cfg); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ import { createEffect, createSignal, createMemo, JSXElement } from "solid-js";
|
||||
import html from "solid-js/html";
|
||||
import { useAuth } from "./AuthContext.ts";
|
||||
import { getToken } from "./useAuthFetch.js";
|
||||
import { hasPermission } from "./Permissions.js";
|
||||
import { Loader } from "../ui/General.tsx";
|
||||
import { hasPermission } from "./permissions.ts";
|
||||
import { Loader } from "../kit/General.tsx";
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
permissions?: string[];
|
||||
|
||||
13
web/auth/permissions.ts
Normal file
13
web/auth/permissions.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
// Generic permission check shared by the framework. The permission CONSTANTS
|
||||
// (P_APP_*, P_ORG_*, ...) are application-specific and live app-side; only this
|
||||
// membership test is generic. "*" (P_ALL) grants everything.
|
||||
export const P_ALL = "*";
|
||||
|
||||
export function hasPermission(
|
||||
permissions: string[] | null | undefined,
|
||||
permission: string,
|
||||
): boolean {
|
||||
if (!permissions) return false;
|
||||
if (permissions.includes(P_ALL)) return true;
|
||||
return permissions.includes(permission);
|
||||
}
|
||||
Reference in New Issue
Block a user