add bundler stuff

This commit is contained in:
2026-07-08 16:59:00 -04:00
parent 2a5fbffaa2
commit ba3d87c27f
12 changed files with 425 additions and 70 deletions

76
go/bundler/alias.go Normal file
View 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)
})
},
}
}

View File

@@ -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
View 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") }

View 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))
}

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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.

View File

@@ -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,

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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
View 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);
}