// Package webui go:embed 前端产物 + SPA fallback + index.html 元信息注入。 // embed 只能引用本包目录树内文件:产物由 Makefile `sync-assets` 拷贝至 // internal/webui/dist(§8.3-1)。 package webui import ( "bytes" "embed" "fmt" "html/template" "io" "io/fs" "net/http" "path" "strings" "time" ) //go:embed all:dist var distFS embed.FS // Meta index.html 模板数据。所有字段由服务端填充(含默认回退值), // 经 html/template 自动转义注入(§8.3-4,防标题内容打断标签结构)。 type Meta struct { Title string Description string OGTitle string OGDescription string OGType string OGURL string OGImage string SiteName string } // UI 静态资源服务。 type UI struct { assets fs.FS indexTmpl *template.Template hasIndex bool } // New 从内嵌产物构造 UI。dist 缺 index.html(M0 占位)时仍可构造, // HTML 路径回退到占位提示页。 func New() (*UI, error) { sub, err := fs.Sub(distFS, "dist") if err != nil { return nil, err } return newFromFS(sub) } // newFromFS 供测试注入任意 FS。 func newFromFS(fsys fs.FS) (*UI, error) { u := &UI{assets: fsys} index, err := fs.ReadFile(fsys, "index.html") if err == nil { tmpl, err := template.New("index").Parse(string(index)) if err != nil { return nil, fmt.Errorf("解析 index.html 模板失败: %w", err) } u.indexTmpl = tmpl u.hasIndex = true } return u, nil } var placeholderTmpl = template.Must(template.New("ph").Parse( `Pure Note

Pure Note

前端静态资源尚未构建:请在仓库根目录执行 make build(会先构建 web/dist 并拷贝到 internal/webui/dist)。

`)) // Handler 返回 SPA 资源服务: // 命中文件 → 按指纹长缓存;未命中且无扩展名 → index.html(注入 meta)。 func (u *UI) Handler(meta func(*http.Request) Meta) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet && r.Method != http.MethodHead { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } p := strings.TrimPrefix(path.Clean(r.URL.Path), "/") if p == "" || p == "." { // 站点根 → index.html u.serveIndex(w, r, meta) return } if st, err := fs.Stat(u.assets, p); err == nil && !st.IsDir() { // index.html 直接命中(显式请求)也走模板渲染 if p == "index.html" { u.serveIndex(w, r, meta) return } f, err := u.assets.Open(p) if err != nil { http.NotFound(w, r) return } defer f.Close() rs, ok := f.(io.ReadSeeker) if !ok { http.NotFound(w, r) return } // Vite 产物按内容 hash 命名 → 指纹天然隔离新旧版本(§8.3-5) if strings.HasPrefix(p, "assets/") { w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") } else { w.Header().Set("Cache-Control", "public, max-age=3600") } http.ServeContent(w, r, path.Base(p), time.Time{}, rs) return } // 带扩展名的未命中路径(如 /assets/missing.js)→ 404,不回退 HTML if strings.Contains(path.Base(p), ".") { http.NotFound(w, r) return } // SPA 深链(/notes/:slug 等)→ index.html u.serveIndex(w, r, meta) }) } func (u *UI) serveIndex(w http.ResponseWriter, r *http.Request, metaFn func(*http.Request) Meta) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-cache") if !u.hasIndex { w.WriteHeader(http.StatusServiceUnavailable) _ = placeholderTmpl.Execute(w, nil) return } var m Meta if metaFn != nil { m = metaFn(r) } var buf bytes.Buffer if err := u.indexTmpl.Execute(&buf, m); err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } _, _ = w.Write(buf.Bytes()) }