前端(新增 ui/yohaku/,各视图按 ui_id=vivid 分支渲染;classic 与后台不受影响) - 令牌层:设计稿数值原样落地,28 个 --v-* 为站主接口(自定义 CSS 可覆盖, 放 @layer 让出优先级);另加防线,中和 styles.css 裸选择器 (h1 衬线 / body 行高 / 后台 .stat/.toolbar/.chip 卡片)漏进余白元素 - 顶栏(滚动收缩态)/ 阅读进度(顶部 2px + READ n%)/ 页脚 - 首页:hero(统计条)/ 筛选(类型分段 + 标签 chips + 搜索)/ 长文卡片与短文纸条 / 分页 / 作品展示区(3 件 + 查看所有,参照 diygod.cc); 面板栅格改 1.6fr 1fr 1fr 且高度自适应内容(标签云明显大一圈) - 文章页:三栏「纸」—— 左时间线(前后各两篇)/ 中纸张 / 右目录与阅读进度; 短文详情不显示标题(h1 视觉隐藏保留无障碍) - 全部规则收敛在 html[data-ui='vivid'] 前缀下;无 !important 后端 - GET /api/posts/:slug 增 neighbors(前后各两篇,详情页侧栏时间线用) - SPA 缓存头:index.html no-cache,assets/ 带 hash 长期 immutable (此前无缓存头,浏览器会一直拿旧构建) 其他 - 站主自定义 CSS 管线(customCss.js + ui/sections.js,按分区注入,仅 vivid) - 删除旧 vivid 覆盖层实现(VividNav/Hero/Footer/Timeline/Toc、ui/vivid.css) - 设计稿原型附于仓库根 index.html;作品表清掉外链 diygod.cc 的示例封面, 改用本地占位图(public/covers/)
278 lines
6.4 KiB
Go
278 lines
6.4 KiB
Go
// Package api serves the read-only public surface: site settings, posts,
|
|
// archive, tags and RSS. Nothing here requires authentication.
|
|
package api
|
|
|
|
import (
|
|
"encoding/xml"
|
|
"errors"
|
|
"html"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"oneblog/internal/config"
|
|
"oneblog/internal/httpx"
|
|
"oneblog/internal/model"
|
|
"oneblog/internal/store"
|
|
)
|
|
|
|
type API struct {
|
|
Store *store.Store
|
|
Cfg *config.Config
|
|
}
|
|
|
|
func (a *API) Routes() http.Handler {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) {
|
|
if err := a.Store.Ping(r.Context()); err != nil {
|
|
httpx.Error(w, http.StatusServiceUnavailable, "database unreachable")
|
|
return
|
|
}
|
|
httpx.OK(w, map[string]any{"ok": true, "driver": a.Cfg.Driver})
|
|
})
|
|
mux.HandleFunc("/api/site", a.site)
|
|
mux.HandleFunc("/api/posts", a.listPosts)
|
|
mux.HandleFunc("/api/posts/", a.getPost)
|
|
mux.HandleFunc("/api/archive", a.archive)
|
|
mux.HandleFunc("/api/tags", a.tags)
|
|
mux.HandleFunc("/api/projects", a.projects)
|
|
return mux
|
|
}
|
|
|
|
// Mount registers the feed routes on the root mux, where they are not
|
|
// shadowed by the SPA catch-all.
|
|
func (a *API) Mount(root *http.ServeMux) {
|
|
root.Handle("/api/", a.Routes())
|
|
root.HandleFunc("/rss.xml", a.RSS)
|
|
root.HandleFunc("/feed", a.RSS)
|
|
root.HandleFunc("/feed.xml", a.RSS)
|
|
}
|
|
|
|
func (a *API) site(w http.ResponseWriter, r *http.Request) {
|
|
st, err := a.Store.GetSettings()
|
|
if err != nil {
|
|
httpx.ServerError(w, err)
|
|
return
|
|
}
|
|
httpx.OK(w, st)
|
|
}
|
|
|
|
func listOptions(r *http.Request, defSize int) store.ListOptions {
|
|
return store.ListOptions{
|
|
Kind: httpx.QueryString(r, "kind"),
|
|
Tag: httpx.QueryString(r, "tag"),
|
|
Query: httpx.QueryString(r, "q"),
|
|
Page: httpx.QueryInt(r, "page", 1),
|
|
Size: httpx.QueryInt(r, "size", defSize),
|
|
Status: model.StatusPublished,
|
|
}
|
|
}
|
|
|
|
func (a *API) listPosts(w http.ResponseWriter, r *http.Request) {
|
|
page, err := a.Store.List(listOptions(r, 10))
|
|
if err != nil {
|
|
httpx.ServerError(w, err)
|
|
return
|
|
}
|
|
httpx.OK(w, page)
|
|
}
|
|
|
|
func (a *API) getPost(w http.ResponseWriter, r *http.Request) {
|
|
slug := strings.TrimPrefix(r.URL.Path, "/api/posts/")
|
|
slug = strings.TrimSuffix(slug, "/")
|
|
if slug == "" {
|
|
a.listPosts(w, r)
|
|
return
|
|
}
|
|
p, err := a.Store.GetBySlug(slug)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
httpx.NotFound(w)
|
|
return
|
|
}
|
|
httpx.ServerError(w, err)
|
|
return
|
|
}
|
|
if p.Status != model.StatusPublished {
|
|
httpx.NotFound(w)
|
|
return
|
|
}
|
|
// 邻居给详情页侧栏的时间线用;取不到不该让详情页挂掉。
|
|
nb, err := a.Store.Neighbors(slug)
|
|
if err != nil {
|
|
nb = nil
|
|
}
|
|
httpx.OK(w, struct {
|
|
model.Post
|
|
Neighbors []model.Post `json:"neighbors"`
|
|
}{p, nb})
|
|
}
|
|
|
|
func (a *API) archive(w http.ResponseWriter, r *http.Request) {
|
|
years, err := a.Store.Archive()
|
|
if err != nil {
|
|
httpx.ServerError(w, err)
|
|
return
|
|
}
|
|
httpx.OK(w, map[string]any{"years": years})
|
|
}
|
|
|
|
func (a *API) tags(w http.ResponseWriter, r *http.Request) {
|
|
tags, err := a.Store.ListTags()
|
|
if err != nil {
|
|
httpx.ServerError(w, err)
|
|
return
|
|
}
|
|
if tags == nil {
|
|
tags = []model.Tag{}
|
|
}
|
|
httpx.OK(w, map[string]any{"tags": tags})
|
|
}
|
|
|
|
func (a *API) projects(w http.ResponseWriter, r *http.Request) {
|
|
projects, err := a.Store.ListProjects(model.StatusPublished)
|
|
if err != nil {
|
|
httpx.ServerError(w, err)
|
|
return
|
|
}
|
|
if projects == nil {
|
|
projects = []model.Project{}
|
|
}
|
|
httpx.OK(w, map[string]any{"projects": projects})
|
|
}
|
|
|
|
// ---------- RSS ----------
|
|
|
|
type rssItem struct {
|
|
XMLName xml.Name `xml:"item"`
|
|
Title string `xml:"title"`
|
|
Link string `xml:"link"`
|
|
Description string `xml:"description"`
|
|
PubDate string `xml:"pubDate"`
|
|
GUID string `xml:"guid"`
|
|
Category []string `xml:"category,omitempty"`
|
|
}
|
|
|
|
type rssChannel struct {
|
|
XMLName xml.Name `xml:"channel"`
|
|
Title string `xml:"title"`
|
|
Link string `xml:"link"`
|
|
Description string `xml:"description"`
|
|
Language string `xml:"language"`
|
|
LastBuild string `xml:"lastBuildDate"`
|
|
Items []rssItem `xml:"item"`
|
|
}
|
|
|
|
type rssFeed struct {
|
|
XMLName xml.Name `xml:"rss"`
|
|
Version string `xml:"version,attr"`
|
|
Channel rssChannel `xml:"channel"`
|
|
}
|
|
|
|
// RSS renders the site feed.
|
|
func (a *API) RSS(w http.ResponseWriter, r *http.Request) {
|
|
settings, err := a.Store.GetSettings()
|
|
if err != nil {
|
|
httpx.ServerError(w, err)
|
|
return
|
|
}
|
|
page, err := a.Store.List(store.ListOptions{Status: model.StatusPublished, Page: 1, Size: 30})
|
|
if err != nil {
|
|
httpx.ServerError(w, err)
|
|
return
|
|
}
|
|
base := strings.TrimSuffix(a.Cfg.SiteURL, "/")
|
|
|
|
feed := rssFeed{
|
|
Version: "2.0",
|
|
Channel: rssChannel{
|
|
Title: orDefault(settings.SiteTitle, "ONE"),
|
|
Link: base + "/",
|
|
Description: orDefault(settings.SiteDesc, ""),
|
|
Language: "zh-CN",
|
|
LastBuild: time.Now().UTC().Format(time.RFC1123Z),
|
|
Items: []rssItem{},
|
|
},
|
|
}
|
|
for _, p := range page.Items {
|
|
title := p.Title
|
|
if p.Kind == model.KindShort || title == "" {
|
|
title = shortTitle(p)
|
|
}
|
|
desc := p.Summary
|
|
if desc == "" {
|
|
desc = trimRunes(stripTags(p.ContentHTML), 160)
|
|
}
|
|
item := rssItem{
|
|
Title: title,
|
|
Link: base + "/post/" + p.Slug,
|
|
Description: desc,
|
|
PubDate: rssDate(p.PublishedAt),
|
|
GUID: base + "/post/" + p.Slug,
|
|
Category: p.Tags,
|
|
}
|
|
if p.Kind == model.KindShort {
|
|
item.Title = "[短] " + title
|
|
}
|
|
feed.Channel.Items = append(feed.Channel.Items, item)
|
|
}
|
|
|
|
out, err := xml.MarshalIndent(feed, "", " ")
|
|
if err != nil {
|
|
httpx.ServerError(w, err)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/rss+xml; charset=utf-8")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(xml.Header))
|
|
_, _ = w.Write(out)
|
|
_, _ = w.Write([]byte("\n"))
|
|
}
|
|
|
|
func shortTitle(p model.Post) string {
|
|
s := strings.TrimSpace(stripTags(p.ContentHTML))
|
|
if s == "" {
|
|
return "无题"
|
|
}
|
|
return trimRunes(s, 40)
|
|
}
|
|
|
|
func stripTags(s string) string {
|
|
var b strings.Builder
|
|
in := false
|
|
for _, r := range s {
|
|
switch {
|
|
case r == '<':
|
|
in = true
|
|
case r == '>':
|
|
in = false
|
|
case !in:
|
|
b.WriteRune(r)
|
|
}
|
|
}
|
|
return html.UnescapeString(strings.TrimSpace(b.String()))
|
|
}
|
|
|
|
func trimRunes(s string, n int) string {
|
|
runes := []rune(s)
|
|
if len(runes) <= n {
|
|
return s
|
|
}
|
|
return string(runes[:n]) + "…"
|
|
}
|
|
|
|
func rssDate(rfc3339 string) string {
|
|
t, err := time.Parse(time.RFC3339, rfc3339)
|
|
if err != nil {
|
|
return time.Now().UTC().Format(time.RFC1123Z)
|
|
}
|
|
return t.UTC().Format(time.RFC1123Z)
|
|
}
|
|
|
|
func orDefault(s, def string) string {
|
|
if strings.TrimSpace(s) == "" {
|
|
return def
|
|
}
|
|
return s
|
|
}
|