Skip to content

Commit 9050c41

Browse files
committed
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
1 parent 2c2ef53 commit 9050c41

File tree

4 files changed

+131
-30
lines changed

4 files changed

+131
-30
lines changed

server/router/frontend/frontend.go

Lines changed: 52 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,12 @@ package frontend
33
import (
44
"context"
55
"embed"
6+
"errors"
67
"io/fs"
7-
"net/http"
88

99
"github.com/labstack/echo/v4"
10-
"github.com/labstack/echo/v4/middleware"
1110

1211
"github.com/usememos/memos/internal/profile"
13-
"github.com/usememos/memos/internal/util"
1412
"github.com/usememos/memos/store"
1513
)
1614

@@ -29,37 +27,65 @@ func NewFrontendService(profile *profile.Profile, store *store.Store) *FrontendS
2927
}
3028
}
3129

32-
func (*FrontendService) Serve(_ context.Context, e *echo.Echo) {
33-
skipper := func(c echo.Context) bool {
34-
// Skip API routes.
35-
if util.HasPrefixes(c.Path(), "/api", "/memos.api.v1") {
36-
return true
37-
}
38-
// Skip setting cache headers for index.html
39-
if c.Path() == "/" || c.Path() == "/index.html" {
40-
return false
30+
func (*FrontendService) Serve(_ context.Context, e *echo.Echo) error {
31+
fs, err := fs.Sub(embeddedFiles, "dist")
32+
if err != nil {
33+
return err
34+
}
35+
36+
idx, err := parseFSTemplate(fs, "index.html")
37+
if err != nil {
38+
return err
39+
}
40+
41+
htmlMeta := map[string]string{
42+
"viewport": "width=device-width, initial-scale=1, user-scalable=no",
43+
}
44+
static := echo.StaticDirectoryHandler(fs, false)
45+
index := templateHandler(idx, templateConfig{
46+
MetaData: htmlMeta,
47+
})
48+
exploreFeedTitle := func(_ echo.Context) string {
49+
return "Public Memos"
50+
}
51+
userFeedTitle := func(c echo.Context) string {
52+
u := c.Param("username")
53+
54+
return u + " Memos"
55+
}
56+
assets := func(c echo.Context) error {
57+
p := c.Request().URL.Path
58+
if p == "/" || p == "/index.html" {
59+
// do not serve index.html from the filesystem
60+
// but serve it as rendered template instead
61+
return index(c)
4162
}
63+
4264
// Set Cache-Control header for static assets.
4365
// Since Vite generates content-hashed filenames (e.g., index-BtVjejZf.js),
4466
// we can cache aggressively but use immutable to prevent revalidation checks.
4567
// For frequently redeployed instances, use shorter max-age (1 hour) to avoid
4668
// serving stale assets after redeployment.
4769
c.Response().Header().Set(echo.HeaderCacheControl, "public, max-age=3600, immutable") // 1 hour
48-
return false
49-
}
70+
if err := static(c); err == nil || !errors.Is(err, echo.ErrNotFound) {
71+
return err
72+
}
5073

51-
// Route to serve the main app with HTML5 fallback for SPA behavior.
52-
e.Use(middleware.StaticWithConfig(middleware.StaticConfig{
53-
Filesystem: getFileSystem("dist"),
54-
HTML5: true, // Enable fallback to index.html
55-
Skipper: skipper,
74+
// fallback to the index document, assuming it is a SPA route
75+
return index(c)
76+
}
77+
e.GET("/", index)
78+
e.GET("/*", assets)
79+
e.GET("/explore", templateHandler(idx, templateConfig{
80+
MetaData: htmlMeta,
81+
InjectFeedURL: true,
82+
ResolveFeedTitle: exploreFeedTitle,
83+
}))
84+
e.GET("/u/:username", templateHandler(idx, templateConfig{
85+
MetaData: htmlMeta,
86+
InjectFeedURL: true,
87+
ResolveFeedTitle: userFeedTitle,
5688
}))
57-
}
5889

59-
func getFileSystem(path string) http.FileSystem {
60-
fs, err := fs.Sub(embeddedFiles, path)
61-
if err != nil {
62-
panic(err)
63-
}
64-
return http.FS(fs)
90+
return nil
6591
}

server/router/frontend/template.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package frontend
2+
3+
import (
4+
"io/fs"
5+
"net/http"
6+
"text/template"
7+
8+
echo "github.com/labstack/echo/v4"
9+
)
10+
11+
var templateFuncs = template.FuncMap{
12+
"default": templateFuncDefault,
13+
}
14+
15+
type templateData struct {
16+
FeedURL string
17+
FeedTitle string
18+
Title string
19+
MetaData map[string]string
20+
}
21+
22+
type templateConfig struct {
23+
InjectFeedURL bool
24+
ResolveFeedTitle func(c echo.Context) string
25+
MetaData map[string]string
26+
}
27+
28+
func templateHandler(tpl *template.Template, cfg templateConfig) echo.HandlerFunc {
29+
return func(c echo.Context) error {
30+
data := &templateData{
31+
Title: "Memos",
32+
MetaData: cfg.MetaData,
33+
}
34+
35+
if cfg.InjectFeedURL {
36+
if cfg.ResolveFeedTitle != nil {
37+
data.FeedTitle = cfg.ResolveFeedTitle(c)
38+
}
39+
40+
data.FeedURL = c.Request().URL.JoinPath("rss.xml").String()
41+
}
42+
43+
header := c.Response().Header()
44+
if header.Get(echo.HeaderContentType) == "" {
45+
header.Set(echo.HeaderContentType, echo.MIMETextHTMLCharsetUTF8)
46+
}
47+
48+
if err := tpl.Execute(c.Response().Writer, data); err != nil {
49+
return echo.NewHTTPError(http.StatusInternalServerError, "unable to render template").SetInternal(err)
50+
}
51+
52+
return nil
53+
}
54+
}
55+
56+
func parseFSTemplate(root fs.FS, file string) (*template.Template, error) {
57+
return template.New(file).Funcs(templateFuncs).ParseFS(root, file)
58+
}
59+
60+
func templateFuncDefault(fallback, value string) string {
61+
if value != "" {
62+
return value
63+
}
64+
65+
return fallback
66+
}

server/server.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,9 @@ func NewServer(ctx context.Context, profile *profile.Profile, store *store.Store
6363
})
6464

6565
// Serve frontend static files.
66-
frontend.NewFrontendService(profile, store).Serve(ctx, echoServer)
66+
if err := frontend.NewFrontendService(profile, store).Serve(ctx, echoServer); err != nil {
67+
return nil, errors.Wrap(err, "unable to set up frontend service")
68+
}
6769

6870
rootGroup := echoServer.Group("")
6971

web/index.html

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,16 @@
55
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
66
<link rel="icon" type="image/webp" href="/logo.webp" />
77
<link rel="manifest" href="/site.webmanifest" />
8-
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
9-
<!-- memos.metadata.head -->
10-
<title>Memos</title>
8+
<!-- {{ printf "memos.metadata.head %s>" "--" }}
9+
{{- with .FeedURL }}
10+
<link rel="alternate" type="application/rss+xml" href="{{ . }}" title="{{ $.FeedTitle | default $.Title | html }}" />
11+
{{ end }}
12+
13+
{{- range $name, $content := .MetaData }}
14+
<meta name="{{ $name | html }}" content="{{ $content | html }}" />
15+
{{ end }}
16+
{{ printf "<%s" "!--" }} -->
17+
<title>{{ .Title | html }}</title>
1118
</head>
1219
<body class="text-base w-full min-h-svh">
1320
<div id="root" class="relative w-full min-h-full"></div>

0 commit comments

Comments
 (0)