From c80bae510602e7c3d96bc07a49aff3b8685e225e Mon Sep 17 00:00:00 2001 From: mlogclub Date: Sat, 23 May 2026 22:44:06 +0800 Subject: [PATCH] feat: enhance Makefile commands and update README for improved development workflow refactor: separate API and SPA handling in server routes and add static file serving options test: add tests for SPA handling and static file serving feat: implement embedded SPA support for production and development environments --- Makefile | 194 +++++++++++------------- README.md | 29 ++-- internal/bootstrap/server.go | 50 +++--- internal/bootstrap/server_route_test.go | 39 +++++ internal/pkg/ginx/static.go | 165 ++++++++++++++++++++ internal/pkg/ginx/static_test.go | 115 ++++++++++++++ web/embed.go | 8 + web/embed_dev.go | 10 ++ 8 files changed, 466 insertions(+), 144 deletions(-) create mode 100644 internal/pkg/ginx/static.go create mode 100644 internal/pkg/ginx/static_test.go create mode 100644 web/embed.go create mode 100644 web/embed_dev.go diff --git a/Makefile b/Makefile index e222f34..8343b38 100644 --- a/Makefile +++ b/Makefile @@ -1,134 +1,120 @@ -.PHONY: help install run run-server run-web build build-all build-assets build-web \ - package-current package-platform build-linux-amd64 clean-dist clean-temp generator enums migration testdata +APP := cs-agent +MAIN := ./cmd/server +WEB_DIR := web +SPA_INDEX := $(WEB_DIR)/out/index.html -DIST_DIR ?= dist -TMP_DIR := $(DIST_DIR)/.tmp -APP_NAME ?= cs-agent -ARCHIVE_NAME ?= cs-ai-agent -PACKAGE_ROOT_DIR ?= $(ARCHIVE_NAME) -APP_ENTRY ?= ./cmd/server -PLATFORMS := linux-amd64 linux-arm64 darwin-amd64 darwin-arm64 windows-amd64 -CURRENT_GOOS := $(shell go env GOOS) -CURRENT_GOARCH := $(shell go env GOARCH) -CURRENT_PLATFORM := $(CURRENT_GOOS)-$(CURRENT_GOARCH) -PLATFORM ?= $(CURRENT_PLATFORM) +GO ?= go +PNPM ?= pnpm +GOOS ?= $(shell $(GO) env GOOS) +GOARCH ?= $(shell $(GO) env GOARCH) + +.DEFAULT_GOAL := help + +.PHONY: all help build build-go build-linux release run run-go dev test check clean clean-web \ + web-install web-dev web-build-spa ensure-spa build-spa web-build-ssr web-typecheck web-lint \ + generator enums migration testdata + +all: build help: @echo "Available targets:" - @echo " make help Show this help message" - @echo " make install Install Go and web dependencies" - @echo " make run Run server and web" - @echo " make run-server Run Go server" - @echo " make run-web Run web app" - @echo " make build Build assets and package current platform" - @echo " make build-all Build and package all platforms" - @echo " make build-assets Build web assets" - @echo " make build-web Build web app and sdk" - @echo " make package-current Package current platform" - @echo " make package-platform Package specified PLATFORM" - @echo " make build-linux-amd64 Build and package linux amd64 release" - @echo " make clean-dist Remove dist directory" - @echo " make clean-temp Remove temporary packaging files" + @echo " make build Build web SPA and Go binary" + @echo " make build-go Build Go binary only, ensuring SPA exists" + @echo " make build-linux Build linux amd64 binary" + @echo " make release Build release binaries for common platforms" + @echo " make run Build web SPA then run server" + @echo " make run-go Run server only, ensuring SPA exists" + @echo " make dev Run Go server with dev tag and web dev server" + @echo " make test Run Go tests, ensuring SPA exists" + @echo " make check Run Go tests, web typecheck, and web lint" + @echo " make clean Remove Go binaries" + @echo " make clean-web Remove web build output" + @echo " make web-install Install web dependencies" + @echo " make web-dev Run web dev server" + @echo " make web-build-spa Build static web SPA" + @echo " make web-typecheck Run web typecheck" + @echo " make web-lint Run web lint" @echo " make generator Run code generator" @echo " make enums Generate frontend enums" @echo " make migration Run migration command" @echo " make testdata Run testdata generator" -install: - go mod download - cd web && pnpm install - @echo "[install] done" +build: web-build-spa + @$(MAKE) build-go -run: - @server_pid=0; \ - web_pid=0; \ - trap 'kill $$server_pid $$web_pid 2>/dev/null || true' INT TERM EXIT; \ - $(MAKE) run-server & \ +build-go: ensure-spa + @echo "Building $(APP)..." + @$(GO) build -v -o $(APP) $(MAIN) + +build-linux: web-build-spa + @echo "Building $(APP) for linux/amd64..." + @GOOS=linux GOARCH=amd64 $(GO) build -v -o $(APP)-linux-amd64 $(MAIN) + +release: web-build-spa + @echo "Building release binaries..." + @GOOS=linux GOARCH=amd64 $(GO) build -v -o $(APP)-linux-amd64 $(MAIN) + @GOOS=linux GOARCH=arm64 $(GO) build -v -o $(APP)-linux-arm64 $(MAIN) + @GOOS=darwin GOARCH=amd64 $(GO) build -v -o $(APP)-darwin-amd64 $(MAIN) + @GOOS=darwin GOARCH=arm64 $(GO) build -v -o $(APP)-darwin-arm64 $(MAIN) + @GOOS=windows GOARCH=amd64 $(GO) build -v -o $(APP)-windows-amd64.exe $(MAIN) + +run: web-build-spa + @$(GO) run $(MAIN) + +run-go: ensure-spa + @$(GO) run $(MAIN) + +dev: + @$(GO) run -tags dev $(MAIN) & \ server_pid=$$!; \ - $(MAKE) run-web & \ - web_pid=$$!; \ - while kill -0 $$server_pid 2>/dev/null && kill -0 $$web_pid 2>/dev/null; do \ - sleep 1; \ - done; \ - status=0; \ - wait $$server_pid || status=$$?; \ - wait $$web_pid || status=$$?; \ - kill $$server_pid $$web_pid 2>/dev/null || true; \ - exit $$status + trap 'kill $$server_pid 2>/dev/null || true' EXIT INT TERM; \ + $(MAKE) web-dev -run-server: - go run ./cmd/server +test: ensure-spa + @$(GO) test ./... -run-web: - cd web && pnpm dev +check: test web-typecheck web-lint -build: clean-dist build-assets package-current clean-temp - @echo "[build] done" +clean: + @rm -f $(APP) $(APP)-linux-amd64 $(APP)-linux-arm64 $(APP)-darwin-amd64 $(APP)-darwin-arm64 $(APP)-windows-amd64.exe -build-all: clean-dist build-assets - @echo "[build-all] packaging platforms: $(PLATFORMS)" - @for platform in $(PLATFORMS); do \ - echo "[build-all] packaging $$platform"; \ - $(MAKE) package-platform PLATFORM=$$platform; \ - done - @$(MAKE) clean-temp - @echo "[build-all] done" +clean-web: + @rm -rf $(WEB_DIR)/out -build-assets: build-web - @echo "[build-assets] done" +web-install: + @cd $(WEB_DIR) && $(PNPM) install --frozen-lockfile -build-web: - @echo "[build-web] building web sdk and app" - cd web && pnpm build:sdk && pnpm build - @echo "[build-web] done" +web-dev: + @cd $(WEB_DIR) && $(PNPM) dev -package-current: - @$(MAKE) package-platform PLATFORM=$(CURRENT_PLATFORM) +web-build-spa: + @cd $(WEB_DIR) && $(PNPM) build:sdk && $(PNPM) build -build-linux-amd64: clean-dist build-assets - @$(MAKE) package-platform PLATFORM=linux-amd64 - @$(MAKE) clean-temp - @echo "[build-linux-amd64] done" +ensure-spa: + @if [ ! -f "$(SPA_INDEX)" ]; then \ + echo "SPA build missing; running web-build-spa..."; \ + $(MAKE) web-build-spa; \ + fi -package-platform: - @platform="$(PLATFORM)"; \ - goos=$${platform%-*}; \ - goarch=$${platform#*-}; \ - stage_dir="$(TMP_DIR)/$$platform"; \ - package_dir="$$stage_dir/$(PACKAGE_ROOT_DIR)"; \ - dist_dir="$(CURDIR)/$(DIST_DIR)"; \ - archive_base="$$dist_dir/$(ARCHIVE_NAME)-$$platform"; \ - binary_name="$(APP_NAME)"; \ - if [ "$$goos" = "windows" ]; then binary_name="$(APP_NAME).exe"; fi; \ - echo "[package] start $$platform"; \ - rm -rf "$$stage_dir"; \ - mkdir -p "$$package_dir/web" "$$package_dir/config" "$$dist_dir"; \ - echo "[package] go build $$platform"; \ - GOOS=$$goos GOARCH=$$goarch go build -o "$$package_dir/$$binary_name" $(APP_ENTRY); \ - echo "[package] copy assets $$platform"; \ - cp -R web/out "$$package_dir/web/out"; \ - echo "[package] include example config only"; \ - cp config/config.example.yaml "$$package_dir/config/config.example.yaml"; \ - rm -f "$$archive_base.zip"; \ - echo "[package] archive $$archive_base.zip"; \ - cd "$$stage_dir" && zip -rXq "$$archive_base.zip" "$(PACKAGE_ROOT_DIR)" -x "*.DS_Store" "__MACOSX/*"; \ - rm -rf "$$stage_dir"; \ - echo "[package] done $$platform -> $$archive_base.zip" +build-spa: web-build-spa -clean-dist: - rm -rf $(DIST_DIR) +web-build-ssr: + @cd $(WEB_DIR) && $(PNPM) build -clean-temp: - rm -rf $(TMP_DIR) +web-typecheck: + @cd $(WEB_DIR) && $(PNPM) typecheck + +web-lint: + @cd $(WEB_DIR) && $(PNPM) lint generator: - go run ./cmd/generator/generator.go + @$(GO) run ./cmd/generator/generator.go enums: - go run ./cmd/enums/generator.go + @$(GO) run ./cmd/enums/generator.go migration: - go run ./cmd/migration + @$(GO) run ./cmd/migration testdata: - go run ./cmd/testdata + @$(GO) run ./cmd/testdata diff --git a/README.md b/README.md index 13d3cfd..9826a71 100644 --- a/README.md +++ b/README.md @@ -241,14 +241,14 @@ cd .. 同时启动后端和前端: ```bash -make run +make dev ``` 或分别启动: ```bash -make run-server -make run-web +make run-go +make web-dev ``` 开发环境默认入口: @@ -263,16 +263,19 @@ make run-web ## 常用命令 ```bash -make run # 同时启动后端和前端 -make run-server # 启动后端 -make run-web # 启动前端 -make build # 构建前端静态资源并打包当前平台 -make build-web # 构建 web 应用和嵌入式 SDK -make test # 运行 Go 测试 -make tidy # go mod tidy -make generator # 执行代码生成 -make enums # 生成前端枚举 -make migration # 执行 migration +make dev # 同时启动后端和前端开发服务 +make run # 构建前端 SPA 后启动后端 +make run-go # 启动后端,自动确保 SPA 已构建 +make web-dev # 启动前端开发服务 +make build # 构建前端 SPA 和当前平台 Go 二进制 +make build-linux # 构建 linux/amd64 二进制 +make release # 构建常用平台二进制 +make web-build-spa # 构建 web 静态 SPA 和嵌入式 SDK +make test # 运行 Go 测试,自动确保 SPA 已构建 +make check # 运行 Go 测试、前端 typecheck 和 lint +make generator # 执行代码生成 +make enums # 生成前端枚举 +make migration # 执行 migration ``` ## 系统视角 diff --git a/internal/bootstrap/server.go b/internal/bootstrap/server.go index 73f55b4..4c3e2b2 100644 --- a/internal/bootstrap/server.go +++ b/internal/bootstrap/server.go @@ -3,8 +3,6 @@ package bootstrap import ( "log/slog" "net/http" - "os" - "path/filepath" "strings" "time" @@ -12,9 +10,13 @@ import ( _ "cs-agent/internal/ai/runtime" "cs-agent/internal/middleware" "cs-agent/internal/pkg/config" + "cs-agent/internal/pkg/ginx" + "cs-agent/internal/pkg/httpx" "cs-agent/internal/services" + webspa "cs-agent/web" "github.com/gin-gonic/gin" + "github.com/mlogclub/simple/web" _ "cs-agent/internal/services/wx_callback_handlers" ) @@ -29,8 +31,25 @@ func NewServer() (*gin.Engine, error) { addRouter(app) - app.StaticFS(cfg.Storage.Local.BaseURL, http.Dir(cfg.Storage.Local.Root)) - registerDashboardStatic(app, "web/out") + notFoundPrefixes := []string{"/api/"} + if baseURL := strings.TrimRight(cfg.Storage.Local.BaseURL, "/"); baseURL != "" { + notFoundPrefixes = append(notFoundPrefixes, baseURL+"/") + } + app.StaticFS(cfg.Storage.Local.BaseURL, ginx.StaticFiles(cfg.Storage.Local.Root)) + ginx.HandleSPA(app, ginx.SPAOptions{ + Root: "./web/out", + EmbeddedFS: webspa.SPA, + EmbeddedRoot: "out", + DirOptions: ginx.DirOptions{ + ShowList: false, + SPA: true, + IndexName: "index.html", + }, + NotFoundPrefixes: notFoundPrefixes, + NotFoundHandler: func(ctx *gin.Context) { + httpx.WriteHttpStatusJSON(ctx, http.StatusNotFound, web.JsonErrorCode(http.StatusNotFound, "Not found")) + }, + }) return app, nil } @@ -134,26 +153,3 @@ func addRouter(app *gin.Engine) { thirdGroup := app.Group("/api/third") registerThirdWechatRoutes(thirdGroup.Group("/wechat")) } - -func registerDashboardStatic(app *gin.Engine, root string) { - app.NoRoute(func(ctx *gin.Context) { - if strings.HasPrefix(ctx.Request.URL.Path, "/api/") { - ctx.JSON(http.StatusNotFound, gin.H{"success": false, "message": "not found"}) - return - } - requestPath := filepath.Clean(strings.TrimPrefix(ctx.Request.URL.Path, "/")) - if strings.HasPrefix(requestPath, "..") { - ctx.Status(http.StatusBadRequest) - return - } - if requestPath == "." { - requestPath = "index.html" - } - fullPath := filepath.Join(root, requestPath) - if stat, err := os.Stat(fullPath); err == nil && !stat.IsDir() { - ctx.File(fullPath) - return - } - ctx.File(filepath.Join(root, "index.html")) - }) -} diff --git a/internal/bootstrap/server_route_test.go b/internal/bootstrap/server_route_test.go index 135fd76..6e5d6f6 100644 --- a/internal/bootstrap/server_route_test.go +++ b/internal/bootstrap/server_route_test.go @@ -2,6 +2,8 @@ package bootstrap import ( "net/http" + "net/http/httptest" + "strings" "testing" "cs-agent/internal/pkg/config" @@ -43,3 +45,40 @@ func TestNewServerRegistersGinRoutes(t *testing.T) { } } } + +func TestNewServerSeparatesAPIStaticAndSPA(t *testing.T) { + config.SetCurrent(&config.Config{ + Storage: config.StorageConfig{ + Local: config.LocalStorageConfig{ + Root: "storage", + BaseURL: "/storage", + }, + }, + }) + + app, err := NewServer() + if err != nil { + t.Fatalf("NewServer() error = %v", err) + } + + tests := []struct { + path string + wantStatus int + contentType string + }{ + {path: "/api/not-exists", wantStatus: http.StatusNotFound, contentType: "application/json"}, + {path: "/dashboard/not-exists", wantStatus: http.StatusOK, contentType: "text/html"}, + } + + for _, tt := range tests { + rec := httptest.NewRecorder() + app.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, tt.path, nil)) + + if rec.Code != tt.wantStatus { + t.Fatalf("%s status=%d want %d", tt.path, rec.Code, tt.wantStatus) + } + if !strings.Contains(rec.Header().Get("Content-Type"), tt.contentType) { + t.Fatalf("%s Content-Type=%q want %q", tt.path, rec.Header().Get("Content-Type"), tt.contentType) + } + } +} diff --git a/internal/pkg/ginx/static.go b/internal/pkg/ginx/static.go new file mode 100644 index 0000000..d57b029 --- /dev/null +++ b/internal/pkg/ginx/static.go @@ -0,0 +1,165 @@ +package ginx + +import ( + "io/fs" + "log/slog" + "net/http" + "os" + "path" + "strings" + + "github.com/gin-gonic/gin" +) + +type noDirFS struct { + fs http.FileSystem +} + +type DirOptions struct { + ShowList bool + SPA bool + IndexName string +} + +type SPAOptions struct { + Root string + EmbeddedFS fs.FS + EmbeddedRoot string + DirOptions DirOptions + NotFoundPrefixes []string + NotFoundHandler gin.HandlerFunc +} + +func StaticFiles(root string) http.FileSystem { + return noDirFS{fs: http.Dir(root)} +} + +func (n noDirFS) Open(name string) (http.File, error) { + file, err := n.fs.Open(name) + if err != nil { + return nil, err + } + info, err := file.Stat() + if err != nil { + _ = file.Close() + return nil, err + } + if info.IsDir() { + _ = file.Close() + return nil, os.ErrNotExist + } + return file, nil +} + +func HandleSPA(engine *gin.Engine, options SPAOptions) gin.HandlerFunc { + handler := NewSPAHandler(options.Root, options.EmbeddedFS, options.EmbeddedRoot, options.DirOptions) + engine.GET("/", handler) + engine.HEAD("/", handler) + engine.NoRoute(func(ctx *gin.Context) { + for _, prefix := range options.NotFoundPrefixes { + if strings.HasPrefix(ctx.Request.URL.Path, prefix) { + if options.NotFoundHandler != nil { + options.NotFoundHandler(ctx) + return + } + ctx.AbortWithStatus(http.StatusNotFound) + return + } + } + handler(ctx) + }) + return handler +} + +func NewSPAHandler(root string, embeddedFS fs.FS, embeddedRoot string, options DirOptions) gin.HandlerFunc { + if _, err := os.Stat(path.Join(root, options.IndexName)); err == nil { + return DirHandler(http.Dir(root), options) + } + + spaFS, err := fs.Sub(embeddedFS, embeddedRoot) + if err != nil { + slog.Error("failed to load embedded SPA files", slog.Any("err", err)) + return func(ctx *gin.Context) { + ctx.AbortWithStatus(http.StatusInternalServerError) + } + } + return DirHandler(http.FS(spaFS), options) +} + +func DirHandler(fileSystem http.FileSystem, options DirOptions) gin.HandlerFunc { + return func(ctx *gin.Context) { + name := path.Clean(ctx.Request.URL.Path) + if name == "." || name == "/" { + name = "/" + options.IndexName + } + + file, err := fileSystem.Open(name) + if err == nil { + defer file.Close() + info, err := file.Stat() + if err != nil { + ctx.AbortWithStatus(http.StatusInternalServerError) + return + } + if info.IsDir() { + if options.IndexName != "" { + indexName := path.Join(name, options.IndexName) + indexFile, err := fileSystem.Open(indexName) + if err == nil { + defer indexFile.Close() + indexInfo, statErr := indexFile.Stat() + if statErr != nil { + ctx.AbortWithStatus(http.StatusInternalServerError) + return + } + if !indexInfo.IsDir() { + serveFile(ctx, indexName, indexFile, indexInfo) + return + } + } + if err != nil && !os.IsNotExist(err) { + ctx.AbortWithStatus(http.StatusInternalServerError) + return + } + } + if !options.ShowList { + ctx.AbortWithStatus(http.StatusNotFound) + return + } + } else { + serveFile(ctx, name, file, info) + return + } + } else if !os.IsNotExist(err) { + ctx.AbortWithStatus(http.StatusInternalServerError) + return + } + + if options.SPA && options.IndexName != "" { + indexName := "/" + options.IndexName + indexFile, err := fileSystem.Open(indexName) + if err != nil { + if os.IsNotExist(err) { + ctx.AbortWithStatus(http.StatusNotFound) + return + } + ctx.AbortWithStatus(http.StatusInternalServerError) + return + } + defer indexFile.Close() + indexInfo, err := indexFile.Stat() + if err != nil { + ctx.AbortWithStatus(http.StatusInternalServerError) + return + } + serveFile(ctx, indexName, indexFile, indexInfo) + return + } + + ctx.AbortWithStatus(http.StatusNotFound) + } +} + +func serveFile(ctx *gin.Context, name string, file http.File, info os.FileInfo) { + http.ServeContent(ctx.Writer, ctx.Request, name, info.ModTime(), file) +} diff --git a/internal/pkg/ginx/static_test.go b/internal/pkg/ginx/static_test.go new file mode 100644 index 0000000..b464fcc --- /dev/null +++ b/internal/pkg/ginx/static_test.go @@ -0,0 +1,115 @@ +package ginx + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestDirHandlerWithSPA(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "index.html"), []byte("spa"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "manifest"), []byte("manifest content"), 0o644); err != nil { + t.Fatal(err) + } + + tests := []struct { + path string + wantStatus int + wantBody string + }{ + {path: "/assets/not-exists.css", wantStatus: http.StatusOK, wantBody: "spa"}, + {path: "/images/not-exists.png", wantStatus: http.StatusOK, wantBody: "spa"}, + {path: "/fonts/not-exists", wantStatus: http.StatusOK, wantBody: "spa"}, + {path: "/manifest", wantStatus: http.StatusOK, wantBody: "manifest content"}, + {path: "/dashboard", wantStatus: http.StatusOK, wantBody: "spa"}, + } + handler := DirHandler(http.Dir(root), DirOptions{ + ShowList: false, + SPA: true, + IndexName: "index.html", + }) + + for _, tt := range tests { + rec := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(rec) + ctx.Request = httptest.NewRequest(http.MethodGet, tt.path, nil) + + handler(ctx) + + if rec.Code != tt.wantStatus { + t.Fatalf("%s status=%d want %d; body=%q", tt.path, rec.Code, tt.wantStatus, rec.Body.String()) + } + if tt.wantBody != "" && strings.TrimSpace(rec.Body.String()) != tt.wantBody { + t.Fatalf("%s body=%q want %q", tt.path, strings.TrimSpace(rec.Body.String()), tt.wantBody) + } + } +} + +func TestStaticFilesDoesNotOpenDirectories(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "file.txt"), []byte("content"), 0o644); err != nil { + t.Fatal(err) + } + + fileSystem := StaticFiles(root) + if file, err := fileSystem.Open("/file.txt"); err != nil { + t.Fatalf("Open(file.txt) err=%v", err) + } else { + _ = file.Close() + } + + if file, err := fileSystem.Open("/"); err == nil { + _ = file.Close() + t.Fatal("Open(/) succeeded, want error") + } else if !os.IsNotExist(err) { + t.Fatalf("Open(/) err=%v, want not exist", err) + } +} + +func TestHandleSPAKeepsNotFoundPrefixes(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "index.html"), []byte("spa"), 0o644); err != nil { + t.Fatal(err) + } + + engine := gin.New() + HandleSPA(engine, SPAOptions{ + Root: root, + DirOptions: DirOptions{ + ShowList: false, + SPA: true, + IndexName: "index.html", + }, + NotFoundPrefixes: []string{"/api/"}, + }) + + tests := []struct { + path string + wantStatus int + wantBody string + }{ + {path: "/", wantStatus: http.StatusOK, wantBody: "spa"}, + {path: "/dashboard", wantStatus: http.StatusOK, wantBody: "spa"}, + {path: "/api/not-exists", wantStatus: http.StatusNotFound}, + } + + for _, tt := range tests { + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, tt.path, nil)) + + if rec.Code != tt.wantStatus { + t.Fatalf("%s status=%d want %d", tt.path, rec.Code, tt.wantStatus) + } + if tt.wantBody != "" && strings.TrimSpace(rec.Body.String()) != tt.wantBody { + t.Fatalf("%s body=%q want %q", tt.path, strings.TrimSpace(rec.Body.String()), tt.wantBody) + } + } +} diff --git a/web/embed.go b/web/embed.go new file mode 100644 index 0000000..5dae5ce --- /dev/null +++ b/web/embed.go @@ -0,0 +1,8 @@ +//go:build !dev + +package webspa + +import "embed" + +//go:embed out +var SPA embed.FS diff --git a/web/embed_dev.go b/web/embed_dev.go new file mode 100644 index 0000000..78a43c7 --- /dev/null +++ b/web/embed_dev.go @@ -0,0 +1,10 @@ +//go:build dev + +package webspa + +import ( + "io/fs" + "os" +) + +var SPA fs.FS = os.DirFS("web")