package main import ( "crypto/sha256" "encoding/hex" "errors" "io/fs" "log" "net/http" "strings" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "gitea.parodia.dev/drwily/petal/internal/config" "gitea.parodia.dev/drwily/petal/internal/db" "gitea.parodia.dev/drwily/petal/internal/docs" "gitea.parodia.dev/drwily/petal/internal/lexicon" "gitea.parodia.dev/drwily/petal/internal/llm" "gitea.parodia.dev/drwily/petal/internal/suggestions" "gitea.parodia.dev/drwily/petal/web" ) func main() { cfg := config.Load() database, err := db.Open(cfg.DatabasePath) if err != nil { log.Fatalf("database: %v", err) } defer database.Close() log.Printf("database ready at %s", cfg.DatabasePath) r := chi.NewRouter() r.Use(middleware.RequestID) r.Use(middleware.RealIP) r.Use(middleware.Logger) r.Use(middleware.Recoverer) // Build version: a hash of the embedded SPA shell. Vite rewrites index.html // with content-hashed asset names on every build, so this string changes // exactly when a new frontend is deployed — the client polls it to know when // to offer a refresh. version := buildVersion() log.Printf("frontend build version %s", version) r.Route("/api", func(api chi.Router) { api.Get("/health", func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"status":"ok"}`)) }) api.Get("/version", func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") // Never cache: a stale cached version would defeat the whole check. w.Header().Set("Cache-Control", "no-store") _, _ = w.Write([]byte(`{"version":"` + version + `"}`)) }) llmClient := llm.NewLLMClient(cfg) sug := suggestions.New(database, llmClient) // Document CRUD plus the doc-scoped checkpoint/list suggestion routes, // both under /api/docs. docsHandler := docs.New(database) docsRouter := docsHandler.Routes() sug.RegisterDocRoutes(docsRouter) api.Mount("/docs", docsRouter) // Tag management (the roster) and cross-document full-text search. api.Mount("/tags", docsHandler.TagRoutes()) api.Mount("/search", docsHandler.SearchRoutes()) // Per-suggestion actions (accept/dismiss) under /api/suggestions. api.Mount("/suggestions", sug.Routes()) // Offline lexicon: full word lookups (gloss + definition + synonyms) for // the right-click popover, and the lightweight Chinese-only gloss for the // inline hover/select tooltip. One handler so the datasets load once. lex := lexicon.NewHandler() api.Mount("/word", lex.Routes()) api.Mount("/gloss", lex.GlossRoutes()) }) // Everything else: serve the embedded SPA (with index.html fallback for client routing). r.NotFound(spaHandler()) addr := ":" + cfg.Port log.Printf("petal listening on %s (LLM backend=%s)", addr, cfg.LLMBackend) if err := http.ListenAndServe(addr, r); err != nil { log.Fatalf("server error: %v", err) } } // buildVersion derives a short, stable identifier for the currently embedded // frontend by hashing dist/index.html. Vite stamps content-hashed asset names // into that file each build, so the digest is a reliable "did the deploy // change?" signal. Falls back to "dev" when the frontend hasn't been built. func buildVersion() string { data, err := fs.ReadFile(web.DistFS, "dist/index.html") if err != nil { return "dev" } sum := sha256.Sum256(data) return hex.EncodeToString(sum[:])[:12] } // spaHandler serves the embedded web/dist as a single-page app: static files // when they exist, falling back to index.html for unknown paths. If the // frontend hasn't been built yet, it returns a friendly dev hint instead. func spaHandler() http.HandlerFunc { sub, err := fs.Sub(web.DistFS, "dist") if err != nil { log.Fatalf("embed sub: %v", err) } if _, err := fs.Stat(sub, "index.html"); errors.Is(err, fs.ErrNotExist) { return func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("Petal backend is running, but the frontend isn't built yet.\n" + "Run `npm run build` in web/, or use `npm run dev` for the dev server on :5173.\n")) } } fileServer := http.FileServer(http.FS(sub)) return func(w http.ResponseWriter, req *http.Request) { p := strings.TrimPrefix(req.URL.Path, "/") if p == "" { p = "index.html" } if _, err := fs.Stat(sub, p); errors.Is(err, fs.ErrNotExist) { // Unknown path → let the SPA router handle it. req2 := new(http.Request) *req2 = *req req2.URL.Path = "/" fileServer.ServeHTTP(w, req2) return } fileServer.ServeHTTP(w, req) } }