Docs / 主题 SEO 接入规范

主题 SEO 接入规范

GoPress 把 SEO 的所有数据来源统一在框架层,主题只负责"消费"。这一节描述的是契约,新写主题必须遵守,老主题改 base.tmpl 时也照这套来。

本页代码和 URL 示例以主题声明的 product 内容类型为例。product 不是 core 内置类型,只是一个常见的自定义内容类型示例。

数据流

admin 「系统设置 > 网站设置」
  ├── site_name           ─┐
  ├── site_description    ─┤
  └── site_icon           ─┤
                           ▼
core SEOBuilder (per-page)
  ├── ForHome(siteDescription)            → home
  ├── ForArchiveTitle(typeDef, title)     → /products, /blog ...
  └── ForContent(item, typeDef)           → /products/:slug, /blog/:slug ...
                           ▼
core ApplySiteOptionOverrides
  ├── 用 admin 的 site_name 覆盖 cfg.Site.Name 拼出来的 og:title / Title
  ├── description 为空时回填 site_description(兜底 chain)
  └── site_icon 非空时写入 SEOMeta.SiteIcon
                           ▼
data["SEO"] = SEOMeta{...}            (BaseTheme 路径自动注入)
data.PageData.SEO = SEOMeta{...}      (自定义 struct 主题手动注入)
                           ▼
template: {{pageTitleFor . $fallbackTitle}} + {{with seoHeadFor .}}{{.}}{{else}}<meta description fallback>{{end}}
                           ▼
HTML <head>: <title> + <meta description> + <link canonical> + og:* + JSON-LD + favicon links

必须遵守的三条契约

1. <title>pageTitleFor + 主题 fallback

pageTitleFor 是 core 的页面标题 helper,不是 SEO 插件依赖。它会优先使用 core 注入的页面 metadata 标题;如果没有,就返回主题传入的 fallback。因此插件禁用时页面标题仍然正常。不要硬编码品牌字、不要发明 company_name 之类的本地 key:

<!-- ✅ 正确:core 页面标题 + 主题兜底默认 -->
{{$fallbackTitle := printf "%s - %s" .Title (settingOr .Settings "site_name" "My Theme Default")}}
<title>{{pageTitleFor . $fallbackTitle}}</title>

<!-- ❌ 错:硬编码 -->
<title>{{.Title}} - Hurricane Techs</title>

<!-- ❌ 错:用主题自己的 company_name,绕开 core 的 site_name -->
<title>{{.Title}} - {{settingOr .Settings "company_name" "..."}}</title>

company_name 这个 key 可以保留供 footer "© CompanyName" 等正文用途("公司名" 和 "站点名" 概念可分离),但绝不能出现在 <title>

2. <meta name="description">seoHeadFor + 兜底链

{{$siteIcon := settingOr .Settings "site_icon" ""}}
{{with seoHeadFor .}}
  {{.}}
{{else}}
  <meta name="description" content="{{settingOr $.Settings "site_description" "My Theme Default"}}">
  {{faviconLinks $siteIcon}}
{{end}}

seoHeadFor 是核心提供的 reflection-based helper,对 gin.H 和自定义 struct 都安全:找不到 SEO 字段就返回空字符串,模板自动 fallback 到 else 分支,永远不会因为字段缺失把页面渲染成白屏。注意 with 改变了 . 指向,要用 $.Settings 访问根上下文。

3. favicon 统一走 site_icon

后台「系统设置 > 网站设置」里的 site_icon 是全主题统一的网站图标来源。主题不要再发明 favicon_urltheme_icon 之类的本地 key。

  • 正常 SEO 分支:ApplySiteOptionOverrides 会把 site_icon 写入 SEOMeta.SiteIconseoHeadFor 会先渲染 /favicon.ico,再渲染带 type / sizes 的图片 icon 和 Apple touch icon
  • fallback 分支:如果页面没有 SEO 字段,layout 的 else 分支应调用 {{faviconLinks $siteIcon}},确保和 SEO 分支输出一致

/favicon.ico/static/*/sitemap.xml/robots.txt 都支持 HEADGET。静态文件不存在时返回 Cache-Control: no-store,避免搜索引擎或中间缓存把 favicon / 图片的 404 结果长期缓存。

浏览器和搜索引擎会强缓存 favicon。修改 site_icon 后如果标签已经输出但仍显示旧图标,先强刷或清理站点缓存,再在搜索引擎站长工具中重新抓取关键 URL。

推荐写法:BaseTheme + gin.H

新主题强烈推荐这条路径——SEO 注入完全免费,未来 core 长出新能力(比如 og:image 兜底、per-page robots)也是零改动跟上。

归档页标题如果需要多语言,内容类型应在 theme.toml 里声明 archive_title_key

[[content_types]]
name = "service"
label_plural = "服务列表"
archive_title_key = "page_title_service"
rewrite_slug = "services"

BaseTheme 会按当前请求语言从主题 locales 读取该 key,用它生成归档页 <title> / Open Graph 标题。未配置时,core 会尝试 page_title_<rewrite_slug> 这类通用 key,最后才回退到 label_plural

// theme.go
type MyTheme struct {
    coreTheme.BaseTheme
    engine *core.Engine
}

func (t *MyTheme) ServeHTTP(c *gin.Context) {
    t.BaseTheme.ServeHTTP(c)  // 自动注入 .SEO 到 home / archive / single
}
<!-- templates/layouts/base.tmpl -->
{{$fallbackTitle := printf "%s - %s" .Title (settingOr .Settings "site_name" "My Theme")}}
<title>{{pageTitleFor . $fallbackTitle}}</title>
{{$siteIcon := settingOr .Settings "site_icon" ""}}
{{with seoHeadFor .}}{{.}}{{else}}
<meta name="description" content="{{settingOr $.Settings "site_description" "..."}}">
{{if $siteIcon}}<link rel="icon" href="{{$siteIcon}}">
<link rel="apple-touch-icon" href="{{$siteIcon}}">{{end}}
{{end}}

收工。所有 SEO 标签自动出现。

兼容写法:自定义 PageData struct

如果你出于类型安全或代码风格选择自己写 PageService + 自定义 data struct(参考 modern-company / financial-news / go-press-landing),需要手动把 SEO 接进去——否则模板里 seoHeadFor 返回空,只会走 fallback 兜底,拿不到 canonical / og / JSON-LD:

import (
    "go-press/core/hook"
    "go-press/core/rewrite"
    coreTheme "go-press/core/theme"
)

// 1. PageData 加 SEO 字段
type PageData struct {
    Title      string
    Settings   map[string]string
    SEO        rewrite.SEOMeta   // ← 必加
    // ...
}

// 2. PageService 引用 SEOBuilder + Registry + HookBus + I18n
type PageService struct {
    options    *option.Store
    seoBuilder *rewrite.SEOBuilder   // engine.SEO
    registry   *content.Registry     // engine.Registry
    hookBus    *hook.Bus             // engine.Hooks
    contentRepo *content.Repository  // engine.Content
    i18nMgr    *coreI18n.Manager     // engine.I18n
    reqCtx     *gin.Context          // ForRequest(c) 注入
}

// 3. 各 Get*Data 方法构建 SEO
func (s *PageService) buildArchiveSEO(typeName string) rewrite.SEOMeta {
    typeDef := s.registry.GetType(typeName)
    title := coreTheme.LocalizedArchiveTitle(s.reqCtx, s.i18nMgr, typeDef)
    seo := s.seoBuilder.ForArchiveTitle(typeDef, title)
    coreTheme.ApplySiteOptionOverridesFromOptions(s.options, s.seoBuilder, &seo)
    return seo
}

// 下面以主题声明的 product 内容类型为例;product 不是 core 内置类型。
func (s *PageService) GetProductDetail(slug string) (*ProductDetailData, error) {
    item, _ := s.contentRepo.FindBySlugScoped(s.reqCtx, "product", slug)
    typeDef := s.registry.GetType("product")

    seo := s.seoBuilder.ForContent(item, typeDef)
    coreTheme.ApplySiteOptionOverridesFromOptions(s.options, s.seoBuilder, &seo) // admin site_name/site_description/site_icon 覆盖
    coreTheme.ApplyContentMetaSEO(s.hookBus, s.contentRepo, &seo, item)     // ← 让 SEO 插件能 patch

    data := &ProductDetailData{ /* ... */ }
    data.SEO = seo
    return data, nil
}

这些 core helper 不要在主题里复制实现:

  • LocalizedArchiveTitle — 按当前请求语言读取 archive_title_keypage_title_<rewrite_slug>,让归档页标题跟随 locales
  • ApplySiteOptionOverridesFromOptions — 替换标题里的站点名后缀,在 seo.Description 为空时回填 site_description,并把 site_icon 写入 seo.SiteIcon
  • ApplyContentMetaSEO — 触发 seo.content.meta filter 链,让 seo-extras 插件 这类 per-content SEO 覆盖插件能修改 SEOMeta

BaseTheme + gin.H 主题完全不用关心,core 的 renderSingle 已经替你调好了。这又是一个倾向 BaseTheme 的理由——插件生态默认就工作。

类型安全 vs 框架免维护

类型安全和 BaseTheme 不冲突——可以用 BaseTheme + gin.H 的路由 / SEO,同时把内部数据写成类型化切片塞进 map:

data := b.buildBaseData("Products")
data["Products"] = productViews  // []ProductView,模板里照样有字段提示

这样既享受框架级免维护,又保留了模板里的智能提示。

现状参考表

主题 渲染路径 SEO 接入方式
atelier-slate / civic-estate / florafi(FloraFi) / terra-trail / axis-form(Axis Form) BaseTheme + gin.H 框架自动
modern-company / financial-news / go-press-landing 自定义 PageData struct PageService 手动注入

未来新主题除非有非常明确的理由,默认走 BaseTheme + gin.H,避免重复 modern-company 那套 PageService 代码。

Per-content SEO 覆盖

默认情况下,单内容页的 SEO 字段是从内容自身字段推断的:

SEOMeta 字段 默认数据源
<meta description> / og:description Content.Excerpt(自动 truncate 到 160)
og:image Content.ImageURL
og:title Content.Title
<meta robots> index, follow

如果你希望像 WordPress + Yoast SEO 那样允许编辑给每条内容写独立的 SEO 标题/描述/分享图/robots,激活内置 seo-extras 插件 即可——内容编辑页底部会多出一个折叠的「SEO 设置(可选)」面板。

实现层面这套是纯插件:core 完全不动,插件消费 3 个公开 hook:

admin.content_form.fields  filter → 渲染 meta box HTML
admin.content.saved         action → 把 form 值存进 gp_content_meta(_seo_*)
seo.content.meta            filter → 在 SEOBuilder 输出后 patch SEOMeta

整套数据流(包含插件介入)变成:

SEOBuilder.ForContent(item, typeDef)
                ▼
ApplySiteOptionOverrides         (site_name / site_description / site_icon 兜底)
                ▼
ApplyContentMetaSEO              (触发 seo.content.meta filter 链)
   ├── seo-extras 插件插入        (读 _seo_* meta,覆盖 Title/Description/OGImage/Robots)
   └── 你的其它 SEO 插件...
                ▼
data.SEO / data["SEO"]          (注入模板)
                ▼
{{seoHeadFor .}}                (渲染 HTML)

自己写"扩展 SEO"插件

如果你想加自己的 SEO 字段(比如 schema.org 产品规格、自定义 og:type),完全可以再写一个插件,跟 seo-extras 并存。每个插件订阅同一组 hook,按 priority 顺序累加修改 SEOMeta,互不干扰。

这才是这套架构的真正价值:SEO 不是 core 写死的特性,而是可叠加的插件能力