feat(web): render index.html as template to inject HTML tags

render the SPA differently based on the route. if a page is
available as RSS feed, inject a link element into the head
element of the HTML variant. this ensures feeds are discoverable.

this change replaces the middleware with custom router handlers
to serve either assets from the embedded filesystem or the SPA
document.

closes #4276
This commit is contained in:
Gordon Bleux 2025-12-19 22:00:12 +01:00
parent 332d32bd35
commit 2741a1cac7
4 changed files with 136 additions and 34 deletions

View File

@ -3,14 +3,12 @@ package frontend
import (
"context"
"embed"
"errors"
"io/fs"
"net/http"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"github.com/usememos/memos/internal/profile"
"github.com/usememos/memos/internal/util"
"github.com/usememos/memos/store"
)
@ -29,41 +27,65 @@ func NewFrontendService(profile *profile.Profile, store *store.Store) *FrontendS
}
}
func (*FrontendService) Serve(_ context.Context, e *echo.Echo) {
skipper := func(c echo.Context) bool {
// Skip API routes.
if util.HasPrefixes(c.Path(), "/api", "/memos.api.v1") {
return true
}
// For index.html and root path, set no-cache headers to prevent browser caching
// This prevents sensitive data from being accessible via browser back button after logout
if c.Path() == "/" || c.Path() == "/index.html" {
c.Response().Header().Set(echo.HeaderCacheControl, "no-cache, no-store, must-revalidate")
c.Response().Header().Set("Pragma", "no-cache")
c.Response().Header().Set("Expires", "0")
return false
func (*FrontendService) Serve(_ context.Context, e *echo.Echo) error {
fs, err := fs.Sub(embeddedFiles, "dist")
if err != nil {
return err
}
idx, err := parseFSTemplate(fs, "index.html")
if err != nil {
return err
}
htmlMeta := map[string]string{
"viewport": "width=device-width, initial-scale=1, user-scalable=no",
}
static := echo.StaticDirectoryHandler(fs, false)
index := templateHandler(idx, templateConfig{
MetaData: htmlMeta,
})
exploreFeedTitle := func(_ echo.Context) string {
return "Public Memos"
}
userFeedTitle := func(c echo.Context) string {
u := c.Param("username")
return u + " Memos"
}
assets := func(c echo.Context) error {
p := c.Request().URL.Path
if p == "/" || p == "/index.html" {
// do not serve index.html from the filesystem
// but serve it as rendered template instead
return index(c)
}
// Set Cache-Control header for static assets.
// Since Vite generates content-hashed filenames (e.g., index-BtVjejZf.js),
// we can cache aggressively but use immutable to prevent revalidation checks.
// For frequently redeployed instances, use shorter max-age (1 hour) to avoid
// serving stale assets after redeployment.
c.Response().Header().Set(echo.HeaderCacheControl, "public, max-age=3600, immutable") // 1 hour
return false
}
if err := static(c); err == nil || !errors.Is(err, echo.ErrNotFound) {
return err
}
// Route to serve the main app with HTML5 fallback for SPA behavior.
e.Use(middleware.StaticWithConfig(middleware.StaticConfig{
Filesystem: getFileSystem("dist"),
HTML5: true, // Enable fallback to index.html
Skipper: skipper,
// fallback to the index document, assuming it is a SPA route
return index(c)
}
e.GET("/", index)
e.GET("/*", assets)
e.GET("/explore", templateHandler(idx, templateConfig{
MetaData: htmlMeta,
InjectFeedURL: true,
ResolveFeedTitle: exploreFeedTitle,
}))
e.GET("/u/:username", templateHandler(idx, templateConfig{
MetaData: htmlMeta,
InjectFeedURL: true,
ResolveFeedTitle: userFeedTitle,
}))
}
func getFileSystem(path string) http.FileSystem {
fs, err := fs.Sub(embeddedFiles, path)
if err != nil {
panic(err)
}
return http.FS(fs)
return nil
}

View File

@ -0,0 +1,71 @@
package frontend
import (
"io/fs"
"net/http"
"text/template"
echo "github.com/labstack/echo/v4"
)
var templateFuncs = template.FuncMap{
"default": templateFuncDefault,
}
type templateData struct {
FeedURL string
FeedTitle string
Title string
MetaData map[string]string
}
type templateConfig struct {
InjectFeedURL bool
ResolveFeedTitle func(c echo.Context) string
MetaData map[string]string
}
func templateHandler(tpl *template.Template, cfg templateConfig) echo.HandlerFunc {
return func(c echo.Context) error {
data := &templateData{
Title: "Memos",
MetaData: cfg.MetaData,
}
if cfg.InjectFeedURL {
if cfg.ResolveFeedTitle != nil {
data.FeedTitle = cfg.ResolveFeedTitle(c)
}
data.FeedURL = c.Request().URL.JoinPath("rss.xml").String()
}
header := c.Response().Header()
if header.Get(echo.HeaderContentType) == "" {
header.Set(echo.HeaderContentType, echo.MIMETextHTMLCharsetUTF8)
}
// Prevent sensitive data from being accessible via browser back button after logout
header.Set(echo.HeaderCacheControl, "no-cache, no-store, must-revalidate")
header.Set("Pragma", "no-cache")
header.Set("Expires", "0")
if err := tpl.Execute(c.Response().Writer, data); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "unable to render template").SetInternal(err)
}
return nil
}
}
func parseFSTemplate(root fs.FS, file string) (*template.Template, error) {
return template.New(file).Funcs(templateFuncs).ParseFS(root, file)
}
func templateFuncDefault(fallback, value string) string {
if value != "" {
return value
}
return fallback
}

View File

@ -63,7 +63,9 @@ func NewServer(ctx context.Context, profile *profile.Profile, store *store.Store
})
// Serve frontend static files.
frontend.NewFrontendService(profile, store).Serve(ctx, echoServer)
if err := frontend.NewFrontendService(profile, store).Serve(ctx, echoServer); err != nil {
return nil, errors.Wrap(err, "unable to set up frontend service")
}
rootGroup := echoServer.Group("")

View File

@ -8,9 +8,16 @@
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/webp" href="/logo.webp" />
<link rel="manifest" href="/site.webmanifest" />
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
<!-- memos.metadata.head -->
<title>Memos</title>
<!-- {{ printf "memos.metadata.head %s>" "--" }}
{{- with .FeedURL }}
<link rel="alternate" type="application/rss+xml" href="{{ . }}" title="{{ $.FeedTitle | default $.Title | html }}" />
{{ end }}
{{- range $name, $content := .MetaData }}
<meta name="{{ $name | html }}" content="{{ $content | html }}" />
{{ end }}
{{ printf "<%s" "!--" }} -->
<title>{{ .Title | html }}</title>
</head>
<body class="text-base w-full min-h-svh">
<div id="root" class="relative w-full min-h-full"></div>