From ba3d87c27ff616dd50c921317be7553072b5d0f1 Mon Sep 17 00:00:00 2001 From: Max Amundsen Date: Wed, 8 Jul 2026 16:59:00 -0400 Subject: [PATCH] add bundler stuff --- go/bundler/alias.go | 76 ++++++++++++++++++++++++++++ go/bundler/build.go | 10 ++-- go/bundler/config.go | 96 ++++++++++++++++++++++++++++++++++++ go/bundler/crosstree_test.go | 96 ++++++++++++++++++++++++++++++++++++ go/bundler/css.go | 30 ++++++++++- go/bundler/faicons.go | 35 +++++++------ go/bundler/js.go | 20 +++++--- go/bundler/ssr.go | 22 ++++++--- go/bundler/vendor_plugins.go | 74 ++++++++++++++++----------- go/cmd/bundle/main.go | 19 ++++++- web/auth/ProtectedRoute.ts | 4 +- web/auth/permissions.ts | 13 +++++ 12 files changed, 425 insertions(+), 70 deletions(-) create mode 100644 go/bundler/alias.go create mode 100644 go/bundler/config.go create mode 100644 go/bundler/crosstree_test.go create mode 100644 web/auth/permissions.ts diff --git a/go/bundler/alias.go b/go/bundler/alias.go new file mode 100644 index 00000000..a30c5151 --- /dev/null +++ b/go/bundler/alias.go @@ -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 "/" 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) + }) + }, + } +} diff --git a/go/bundler/build.go b/go/bundler/build.go index 1162e746..e081c3db 100644 --- a/go/bundler/build.go +++ b/go/bundler/build.go @@ -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. diff --git a/go/bundler/config.go b/go/bundler/config.go new file mode 100644 index 00000000..e5331cd7 --- /dev/null +++ b/go/bundler/config.go @@ -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") } diff --git a/go/bundler/crosstree_test.go b/go/bundler/crosstree_test.go new file mode 100644 index 00000000..caa0768d --- /dev/null +++ b/go/bundler/crosstree_test.go @@ -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 = `
`;\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)) +} diff --git a/go/bundler/css.go b/go/bundler/css.go index e5300b03..9fba82cc 100644 --- a/go/bundler/css.go +++ b/go/bundler/css.go @@ -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 +} diff --git a/go/bundler/faicons.go b/go/bundler/faicons.go index df5a2701..47a296d4 100644 --- a/go/bundler/faicons.go +++ b/go/bundler/faicons.go @@ -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,9 +155,14 @@ func scanIconNames(dir string) (names []string, custom map[string]bool, err erro custom[m[1]] = true } return nil - }) - if err != nil { - return nil, nil, err + } + 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 { diff --git a/go/bundler/js.go b/go/bundler/js.go index d92aaded..295283ea 100644 --- a/go/bundler/js.go +++ b/go/bundler/js.go @@ -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. diff --git a/go/bundler/ssr.go b/go/bundler/ssr.go index 3728a81a..97c63887 100644 --- a/go/bundler/ssr.go +++ b/go/bundler/ssr.go @@ -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, diff --git a/go/bundler/vendor_plugins.go b/go/bundler/vendor_plugins.go index 16a08ffb..d17ab86e 100644 --- a/go/bundler/vendor_plugins.go +++ b/go/bundler/vendor_plugins.go @@ -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")) - if err != nil { - return nil, err +// 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 %s/vendor.json: %w", dir, err) + } + for spec, rel := range m.Entrypoints { + if _, exists := abs[spec]; exists { + continue // earlier (higher-precedence) dir wins + } + p, _ := filepath.Abs(filepath.Join(dir, filepath.FromSlash(rel))) + abs[spec] = filepath.ToSlash(p) + } } - var m VendorManifest - if err := json.Unmarshal(data, &m); err != nil { - return nil, fmt.Errorf("parsing vendor.json: %w", err) - } - return m.Entrypoints, nil + return abs, 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) - } +// 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 "?url"`: the diff --git a/go/cmd/bundle/main.go b/go/cmd/bundle/main.go index 27f214e2..7c238f1c 100644 --- a/go/cmd/bundle/main.go +++ b/go/cmd/bundle/main.go @@ -4,6 +4,7 @@ package main // Solid JSX compiler (see bundler.Build), so this wrapper carries no build logic. import ( + "flag" "fmt" "os" @@ -11,8 +12,22 @@ 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 /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) } -} +} \ No newline at end of file diff --git a/web/auth/ProtectedRoute.ts b/web/auth/ProtectedRoute.ts index d97fc091..bba811fb 100644 --- a/web/auth/ProtectedRoute.ts +++ b/web/auth/ProtectedRoute.ts @@ -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[]; diff --git a/web/auth/permissions.ts b/web/auth/permissions.ts new file mode 100644 index 00000000..c1f94eb0 --- /dev/null +++ b/web/auth/permissions.ts @@ -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); +}