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"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const frontendDir = "frontend"
|
|
||||||
const outputDir = "wwwroot"
|
|
||||||
|
|
||||||
type bundleStats struct {
|
type bundleStats struct {
|
||||||
files int
|
files int
|
||||||
bytes int
|
bytes int
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build runs the full one-shot build: FA icon subset, public-route generation,
|
// 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.
|
// the JS/CSS bundles, and the SSR bake, printing a stats summary. c selects the
|
||||||
func Build() error {
|
// 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`,
|
// esbuild silently resolves a `.js` import to a sibling `.ts`/`.tsx`/`.jsx`,
|
||||||
// which lets misnamed specifiers slip through. Catch them up front so the
|
// which lets misnamed specifiers slip through. Catch them up front so the
|
||||||
// import path always names the file that actually exists on disk.
|
// 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)
|
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)
|
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 {
|
if err != nil {
|
||||||
return bundleStats{}, fmt.Errorf("tailwind compile (%s): %w", label, err)
|
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
|
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",
|
"fas": "solid",
|
||||||
}
|
}
|
||||||
|
|
||||||
// faIconsDir holds the FontAwesome SVGs (the kit's svgs-full/, relocated here),
|
// The FA SVG source dir (iconsDir) and the generated registry output path
|
||||||
// grouped by style. Only the styles in faStyleDirs are read; the rest are unused.
|
// (faOutPath) are resolved from Config — see config.go. Only the styles in
|
||||||
const faIconsDir = "frontend/icons"
|
// faStyleDirs are read; the rest of the kit is unused.
|
||||||
|
|
||||||
var faOutFile = filepath.Join(frontendDir, "src", "ui", "generated", "faIcons.ts")
|
|
||||||
|
|
||||||
var (
|
var (
|
||||||
// icon="name" / icon: "name"
|
// icon="name" / icon: "name"
|
||||||
@@ -44,11 +42,13 @@ var (
|
|||||||
// generateFAIcons regenerates the icon registry from the FontAwesome kit. It's a
|
// 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).
|
// no-op when the kit isn't present (CI builds use the committed registry).
|
||||||
func generateFAIcons() error {
|
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
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("scanning icon names: %w", err)
|
return fmt.Errorf("scanning icon names: %w", err)
|
||||||
}
|
}
|
||||||
@@ -61,7 +61,7 @@ func generateFAIcons() error {
|
|||||||
for _, name := range names {
|
for _, name := range names {
|
||||||
found := false
|
found := false
|
||||||
for prefix, dir := range faStyleDirs {
|
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 {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -90,10 +90,10 @@ func generateFAIcons() error {
|
|||||||
}
|
}
|
||||||
b.WriteString("};\n")
|
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
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,10 +126,10 @@ func generateFAIcons() error {
|
|||||||
// inside `icon={...}` expressions), and the set of custom icon names registered
|
// inside `icon={...}` expressions), and the set of custom icon names registered
|
||||||
// via registerIcon("name", …). The latter lets the caller tell a legitimate
|
// 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.
|
// 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{}
|
set := map[string]bool{}
|
||||||
custom = 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() {
|
if err != nil || d.IsDir() {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -155,10 +155,15 @@ func scanIconNames(dir string) (names []string, custom map[string]bool, err erro
|
|||||||
custom[m[1]] = true
|
custom[m[1]] = true
|
||||||
}
|
}
|
||||||
return nil
|
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
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
}
|
||||||
names = make([]string, 0, len(set))
|
names = make([]string, 0, len(set))
|
||||||
for n := range set {
|
for n := range set {
|
||||||
names = append(names, n)
|
names = append(names, n)
|
||||||
|
|||||||
@@ -59,21 +59,25 @@ func bundleJSEntry(entry, outName string) (bundleStats, error) {
|
|||||||
drop = esbuild.DropDebugger
|
drop = esbuild.DropDebugger
|
||||||
}
|
}
|
||||||
|
|
||||||
vendorDir := filepath.Join(frontendDir, "vendor")
|
vdirs := vendorDirs()
|
||||||
|
|
||||||
// Every bundled vendored package's entrypoint is declared in vendor.json rather
|
// Every bundled vendored package's entrypoint is declared in vendor.json rather
|
||||||
// than discovered from each package's `exports` — those mis-resolve (e.g.
|
// 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/
|
// 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).
|
// 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 {
|
if err != nil {
|
||||||
return bundleStats{}, fmt.Errorf("loading vendor manifest: %w", err)
|
return bundleStats{}, fmt.Errorf("loading vendor manifest: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The Go-native Solid compiler (Plugin) sits between the export shim and the
|
// aliasPlugin resolves @ui/@kjol/@appgen into the kjol web tree (and the app's
|
||||||
// vendor resolvers so it compiles .tsx/.jsx before they're resolved.
|
// generated dir) first. The Go-native Solid compiler (Plugin) sits between the
|
||||||
plugins := []esbuild.Plugin{defaultExportShimPlugin(), Plugin()}
|
// export shim and the vendor resolvers so it compiles .tsx/.jsx before they're
|
||||||
plugins = append(plugins, assetURLPlugin(), vendorManifestPlugin(vendorDir, entrypoints))
|
// resolved.
|
||||||
|
plugins := []esbuild.Plugin{aliasPlugin(), defaultExportShimPlugin(), Plugin()}
|
||||||
|
plugins = append(plugins, assetURLPlugin(), vendorManifestPlugin(entrypoints, vdirs))
|
||||||
|
|
||||||
result := esbuild.Build(esbuild.BuildOptions{
|
result := esbuild.Build(esbuild.BuildOptions{
|
||||||
EntryPoints: []string{entry},
|
EntryPoints: []string{entry},
|
||||||
@@ -84,7 +88,7 @@ func bundleJSEntry(entry, outName string) (bundleStats, error) {
|
|||||||
Target: esbuild.ES2022,
|
Target: esbuild.ES2022,
|
||||||
Sourcemap: esbuild.SourceMapLinked,
|
Sourcemap: esbuild.SourceMapLinked,
|
||||||
SourceRoot: "./",
|
SourceRoot: "./",
|
||||||
Outbase: "frontend",
|
Outbase: frontendDir,
|
||||||
SourcesContent: esbuild.SourcesContentInclude,
|
SourcesContent: esbuild.SourcesContentInclude,
|
||||||
MinifyWhitespace: true,
|
MinifyWhitespace: true,
|
||||||
MinifyIdentifiers: true,
|
MinifyIdentifiers: true,
|
||||||
@@ -105,7 +109,7 @@ func bundleJSEntry(entry, outName string) (bundleStats, error) {
|
|||||||
// data-driven with no per-package list in this source.
|
// data-driven with no per-package list in this source.
|
||||||
Platform: esbuild.PlatformBrowser,
|
Platform: esbuild.PlatformBrowser,
|
||||||
Conditions: []string{"development"},
|
Conditions: []string{"development"},
|
||||||
NodePaths: []string{vendorDir},
|
NodePaths: vdirs,
|
||||||
// Assets emitted by assetURLPlugin (e.g. the pdfjs worker, which can't be
|
// 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
|
// inlined because it reads import.meta.url) land in wwwroot/vendor and are
|
||||||
// referenced by a root-absolute URL.
|
// referenced by a root-absolute URL.
|
||||||
|
|||||||
@@ -168,7 +168,16 @@ func bundleEntry(entrySource, projectRoot string, withMeta bool) (js, metafile s
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", fmt.Errorf("resolve project root: %w", err)
|
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.json pins each vendored package's exact entrypoint file (see
|
||||||
// vendor_plugins.go) because a package's own `exports`/`main`/`module` fields
|
// 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
|
// 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
|
// 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).
|
// 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 {
|
if err != nil {
|
||||||
return "", "", fmt.Errorf("loading vendor manifest: %w", err)
|
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 strings.HasPrefix(args.Path, "solid-js") {
|
||||||
if target, ok := vendorEntrypoints[args.Path]; ok {
|
if target, ok := vendorEntrypoints[args.Path]; ok {
|
||||||
abs, err := filepath.Abs(filepath.Join(vendorDir, filepath.FromSlash(target)))
|
return esbuild.OnResolveResult{Path: target}, nil // pinned dev entrypoint (absolute)
|
||||||
if err != nil {
|
|
||||||
return esbuild.OnResolveResult{}, err
|
|
||||||
}
|
|
||||||
return esbuild.OnResolveResult{Path: filepath.ToSlash(abs)}, nil // pinned dev entrypoint
|
|
||||||
}
|
}
|
||||||
return esbuild.OnResolveResult{}, nil // unpinned subpath -> real solid runtime via NodePaths
|
return esbuild.OnResolveResult{}, 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,
|
Target: esbuild.ES2017,
|
||||||
Platform: esbuild.PlatformBrowser,
|
Platform: esbuild.PlatformBrowser,
|
||||||
Conditions: []string{"development"},
|
Conditions: []string{"development"},
|
||||||
NodePaths: []string{vendorDir},
|
NodePaths: absVendorDirs,
|
||||||
Plugins: plugins,
|
Plugins: plugins,
|
||||||
LogLevel: esbuild.LogLevelSilent,
|
LogLevel: esbuild.LogLevelSilent,
|
||||||
Write: false,
|
Write: false,
|
||||||
|
|||||||
@@ -15,32 +15,43 @@ type VendorManifest struct {
|
|||||||
Entrypoints map[string]string `json:"entrypoints"`
|
Entrypoints map[string]string `json:"entrypoints"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadVendorManifest reads frontend/vendor/vendor.json — the single place that
|
// loadVendorManifest reads and merges vendor.json from every vendor dir (kjol
|
||||||
// declares the exact entrypoint file for every bundled vendored package.
|
// runtime first, app vendor second), returning spec -> ABSOLUTE entrypoint path.
|
||||||
func loadVendorManifest(vendorDir string) (map[string]string, error) {
|
// Higher-precedence dirs win on conflict, so the shared solid-js always resolves
|
||||||
data, err := os.ReadFile(filepath.Join(vendorDir, "vendor.json"))
|
// 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 err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var m VendorManifest
|
var m VendorManifest
|
||||||
if err := json.Unmarshal(data, &m); err != nil {
|
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
|
p, _ := filepath.Abs(filepath.Join(dir, filepath.FromSlash(rel)))
|
||||||
// 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)
|
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{
|
return esbuild.Plugin{
|
||||||
Name: "vendor-manifest",
|
Name: "vendor-manifest",
|
||||||
Setup: func(build esbuild.PluginBuild) {
|
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, '?') {
|
if args.Kind == esbuild.ResolveEntryPoint || filepath.IsAbs(args.Path) || strings.HasPrefix(args.Path, ".") || strings.ContainsRune(args.Path, '?') {
|
||||||
return esbuild.OnResolveResult{}, nil
|
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
|
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{}, nil // subpath of a vendored pkg -> NodePaths
|
||||||
}
|
}
|
||||||
return esbuild.OnResolveResult{External: true}, nil // not vendored -> import map
|
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
|
// vendorHasPackage reports whether a bare specifier maps to a package directory
|
||||||
// under vendorDir, honouring @scope/name.
|
// under any vendor dir, honouring @scope/name.
|
||||||
func vendorHasPackage(vendorDir, spec string) bool {
|
func vendorHasPackage(vendorDirs []string, spec string) bool {
|
||||||
parts := strings.Split(spec, "/")
|
parts := strings.Split(spec, "/")
|
||||||
pkg := parts[0]
|
pkg := parts[0]
|
||||||
if strings.HasPrefix(pkg, "@") && len(parts) > 1 {
|
if strings.HasPrefix(pkg, "@") && len(parts) > 1 {
|
||||||
pkg = pkg + "/" + parts[1]
|
pkg = pkg + "/" + parts[1]
|
||||||
}
|
}
|
||||||
info, err := os.Stat(filepath.Join(vendorDir, filepath.FromSlash(pkg)))
|
for _, dir := range vendorDirs {
|
||||||
return err == nil && info.IsDir()
|
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
|
// 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.
|
// Solid JSX compiler (see bundler.Build), so this wrapper carries no build logic.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
@@ -11,7 +12,21 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
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)
|
fmt.Fprintln(os.Stderr, err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { createEffect, createSignal, createMemo, JSXElement } from "solid-js";
|
|||||||
import html from "solid-js/html";
|
import html from "solid-js/html";
|
||||||
import { useAuth } from "./AuthContext.ts";
|
import { useAuth } from "./AuthContext.ts";
|
||||||
import { getToken } from "./useAuthFetch.js";
|
import { getToken } from "./useAuthFetch.js";
|
||||||
import { hasPermission } from "./Permissions.js";
|
import { hasPermission } from "./permissions.ts";
|
||||||
import { Loader } from "../ui/General.tsx";
|
import { Loader } from "../kit/General.tsx";
|
||||||
|
|
||||||
interface ProtectedRouteProps {
|
interface ProtectedRouteProps {
|
||||||
permissions?: string[];
|
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