Gitea源码深度剖析-第13章 前端工程与模板体系

Gitea源码深度剖析-第13章 前端工程与模板体系

本章对应第 1 章 1.8 节导航表中的承诺:「13 | 前端工程 | 1.1.2 的模板与 web_src 渐进增强 | web_src/、templates/、modules/templates/、vite.config.ts」。第 3 章 3.7 节已经铺垫了静态资源的两态问题(assetfs 分层文件系统、make watch、ViteDevMiddleware、httpcache),本章沿着同一条线继续往里走:资源是怎么被构建 出来的、模板是怎么被渲染 的、服务端 HTML 是怎么被前端渐进增强的,以及贯穿三者的 i18n、主题与质量工具链。

与前 12 章一样,本章遵循「Never assume, verify before claim」:动笔之前先通读源码,凡是与常见认知不符的地方,都在正文中给出事实纠正。事实上,这一章需要纠正的"常识"比前几章都多------因为 Gitea 的前端形态恰好是"看起来像 A、实际是 B"的重灾区。

13.1 名词消毒:先纠正九个想当然的假设

在展开之前,先把容易想当然的论断列出来逐一验证。下表左列是关于 Gitea 前端的常见描述(包括本系列任务最初稿中的假设),右列是本仓库当前源码给出的事实:

| # | 常见/想当然的描述 | 源码事实(已验证) | 证据位置 |
|---|---------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------|---------------------------------------------------------------------------|------------------------------------------------|
| 1 | Vue 生态配套 Pinia 做状态管理 | 不存在 Pinia 。package.json 依赖中只有 vue@3.5.43,无 pinia;Vue 组件通过 createApp(Comp, props) 传参,状态靠 props 与 window.config.pageData | package.json 依赖表;web_src/js/features/heatmap.ts |
| 2 | 有 make build-frontend 构建目标 | 不存在 该目标。真实目标是 frontend:(依赖 $(FRONTEND_DEST))与别名 vite:,开发态是 watch-frontend: | Makefile:493、Makefile:599-607 |
| 3 | 页面模板用 {{block}}/{{define}} 继承布局 | 全仓库 0 处 {{block}}/{{define}};页面模板通过 {{template "base/head" .}} ... {{template "base/footer" .}} 显式拼装 ,100 个文件里共 346 处 {{template}} 调用 | Grep: \{\{-?\s*(block|define)\s 无匹配;\{\{-?\s*template\s 346 处 |
| 4 | 模板 FuncMap 提供 trim/crosses 之类工具函数 | FuncMap 完整清单在 newFuncMapWebPage(),核心是 dict/Iif/Eval/HTMLFormat/QueryBuild/svg/AssetURI/...,并无 trim | modules/templates/helper.go:26-129 |
| 5 | 前端通过某端点按需拉取翻译数据 | 无动态翻译端点 。全局 window.config.i18n 只放 7 个通用键,专用文案走 API 响应 / HTML data-locale-* 属性 / PageData 三条通道 | services/context/context_template.go:171-186 注释明示 |
| 6 | 独立的 PostCSS 配置文件驱动 Tailwind | 无 postcss.config ;Tailwind 通过 vite.config.ts 的 css.postcss.plugins 内联注入 | vite.config.ts:290-297 |
| 7 | 用 mini-css-extract-plugin 抽取 CSS | 不存在该依赖;CSS 抽取与哈希是 Vite(Rolldown)内置能力,产物为 css/[name].[hash:8].css | vite.config.ts:275 |
| 8 | 构建产物输出到 public/js、public/vendor | 产物在 `public/assets/js | css | fonts,manifest 在 public/assets/.vite/manifest.json,且 .gitignore` 忽略之 | vite.config.ts:25,271-278;Makefile:117-118 |
| 9 | 后端交互的 fetch 封装叫 modules/apiFetch | 真实封装是 web_src/js/modules/fetch.ts 的 request/GET/POST/...,ESLint 用 no-restricted-globals 强制全仓库不得裸用 fetch | web_src/js/modules/fetch.ts;eslint.config.ts:21-31 |

这九条不是文字游戏,它们各自牵出一块真实的工程设计:纠正 3 牵出模板的"显式组合"架构(13.5),纠正 5 牵出三通道 i18n 设计(13.9),纠正 8/9 牵出 manifest 驱动的资产解析与 fetch-action 框架(13.4、13.8)。本章余下部分就按这九条的线索展开。

先给出量化全景,后续各节都会回到这些数字:

  • templates/ 共 585 个 .tmpl(根目录 3 个:home.tmpl、post-install.tmpl、install.tmpl 一类的顶层页面;其余 582 个分布在子目录),按目录分布:repo 240、user 75、package 58、admin 52、shared 40、org 39、devtest 25、base 16、mail 13、custom 9、explore 6、projects 3、status 3、api/swagger/webhook 各 1。
  • web_src/ 共 412 个文件:219 个 .ts、23 个 .vue、92 个 .css、51 个 *.test.ts。
  • options/locale/ 共 28 个 locale_*.json,构成 Go 与前端共享的翻译事实源。
  • createApp( 出现在 16 个文件(14 个业务挂载点 + 2 个测试),说明 Vue 只承担局部组件,不是整页框架。
  • tests/e2e/ 共 32 个文件(31 个 *.test.ts + utils.ts + env.d.ts)。

13.2 技术栈全景:一张依赖表与三个"反直觉"

package.json 是技术栈的第一手证据。Gitea 的包管理钉死在 pnpm@12.6.0(packageManager 字段),Node 要求 >= 22.18.0,且 type: module------整个工具链都是 ESM 优先的。把依赖按职责归类(版本号均为源码锁定值):

职责 关键依赖 版本 说明
构建 vite 8.3.0 基于 Rolldown(rolldownOptions、minify: 'oxc' 均为 Rolldown/Oxc 特性)
框架 vue / @vitejs/plugin-vue 3.5.43 / 6.0.9 仅局部组件,无 JSX(官方规范见 docs/guidelines-frontend.md)
CSS tailwindcss / postcss 3.4.19 / 8.5.28 tw- 前缀 + important: true,不是 Tailwind v4
兼容层 jquery 4.0.0 Fomantic UI 仍依赖 jQuery,jQuery 4 仍在依赖里
富文本 easymde + @codemirror/* 全家桶 2.21.0 / 6.x Markdown 编辑器双轨:EasyMDE 与 CodeMirror 6
展示 mermaid / katex / tippy.js / chart.js 12.0.0 / 0.18.7 / 6.3.7 / 4.5.1 图表、公式、提示、统计图
局部 DOM 更新 idiomorph 0.8.0 fetch-action 的 $morph 命令后端
样式工具 @primer/octicons / svgo 19.38.0 / 4.1.0 图标与 SVG 优化(make svg)
Lint eslint / stylelint / typescript / vue-tsc 10.11.0 / 17.15.0 / 6.0.3 / 3.3.11 make lint-js 同时跑 eslint 与 vue-tsc
测试 vitest / @vitest/browser-playwright / @playwright/test 5.0.1 / 5.0.1 / 1.63.0 单测跑真实浏览器,e2e 跑 Playwright
模板校验 djlint(经 uv)/ markdownlint-cli / spectral-cli-bundle --- make lint-templates、lint-md、lint-swagger

三个反直觉之处值得单独展开:

反直觉一:jQuery 没有被移除,而是被"硬分叉"。 web_src/fomantic/build/fomantic.js 只有 178 字节,内容是三条 import 加一行声明:

js 复制代码
// Hard-forked from Fomantic UI 2.8.7, patches are commented with "GITEA-PATCH"
import './components/api.js';
import './components/dropdown.js';
import './components/modal.js';

也就是说 Gitea 并不引入完整 Fomantic UI,而是从 2.8.7 硬分叉出三个组件(api/dropdown/modal),所有补丁以 GITEA-PATCH 注释标记。分叉层之上还有一层 Gitea 自己的初始化 web_src/js/modules/fomantic.ts:

ts 复制代码
export function initGiteaFomantic() {
  // our extensions
  $.fn.fomanticExt = {};
  // By default, use "exact match" for full text search
  $.fn.dropdown.settings.fullTextSearch = 'exact';
  // Do not use "cursor: pointer" for dropdown labels
  $.fn.dropdown.settings.className.label += ' tw-cursor-default';
  ...
  initFomanticTransition();
  initFomanticDimmer();
  // Use the patches to improve accessibility ...
  initAriaDropdownPatch();
  initAriaModalPatch();
}

initGiteaFomantic 是 index.ts 初始化列表的第一项------后续所有 dropdown/modal 都建立在这些全局 settings 之上,所以它必须最先执行。

反直觉二:Tailwind 是 3.4 而非 4.x,且不引入 @tailwind base。 tailwind.config.ts 的 blocklist 直接写明了原因:

ts 复制代码
blocklist: [
  // classes that don't work without CSS variables from "@tailwind base" which we don't use
  'transform', 'shadow', 'ring', 'blur', 'grayscale', 'invert', '!invert', 'filter', '!filter',
  'backdrop-filter',
  ...
],

因为 Gitea 混用 Fomantic UI 与自研样式,引入 @tailwind base(preflight reset)会破坏既有样式,所以全量 reset 被禁用,凡是依赖 reset 变量的工具类一律进黑名单。

反直觉三:ESLint 不只是风格检查,它强制架构约束。 eslint.config.ts 开头就定义了两个 restricted globals:

ts 复制代码
const restrictedGlobals = [
  {name: 'localStorage', message: 'Use `modules/user-settings.ts` instead.'},
  {name: 'fetch', message: 'Use `modules/fetch.ts` instead.'},
];

配合 no-restricted-properties(连 window.fetch、globalThis.localStorage 都封),全仓库的网络请求与本地存储被强制收敛到两个模块:modules/fetch.ts(33 行的请求封装)与 modules/user-settings.ts(gitea:setting: 前缀的 localStorage 封装,带 legacy 键迁移)。另外自定义规则 gitea/unescaped-html-literal 拦截未转义的 HTML 字符串字面量,从 lint 层面防 XSS。这些约束在 13.11 会再与测试工具链一起展开。

13.3 目录结构:源码如何映射到产物

先看整体结构(只列与前端相关的骨架,... 表示省略同级条目):

bash 复制代码
gitea-28.1.0/
├── package.json               # pnpm@12.6.0, node>=22.18.0
├── vite.config.ts             # 双入口 IIFE 插件 + manifest + Tailwind 注入
├── tailwind.config.ts         # tw- 前缀、important、主题 CSS 变量映射
├── eslint.config.ts           # flat config,架构级 restricted globals
├── stylelint.config.ts        # 忽略 web_src/fomantic
├── vitest.config.ts           # browser(playwright) + node 双 project
├── playwright.config.ts       # testDir: tests/e2e
├── web_src/
│   ├── js/
│   │   ├── index.ts           # module 入口(延迟加载,~70 个 init 函数)
│   │   ├── iife.ts            # 阻塞入口(bootstrap→globals→webcomponents)
│   │   ├── bootstrap.ts       # 解析 #global-window-config、错误缓冲
│   │   ├── modules/           # fetch.ts / fetch-action.ts / observer.ts / i18n.ts ...
│   │   ├── features/          # 按页面域划分的 init 函数(repo-*.ts、common-*.ts ...)
│   │   ├── components/        # *.vue(23 个)
│   │   ├── markup/            # 渲染 Markdown 产物的通用增强
│   │   ├── webcomponents/     # 自定义元素(relative-time、markdown-toolbar ...)
│   │   └── globals.d.ts       # window.config 完整类型
│   ├── css/
│   │   ├── index.css          # ~90 行 @import 总入口 + @tailwind utilities
│   │   ├── themes/theme-*.css # 9 个主题(light/dark/auto × 色觉变体)
│   │   └── helpers.css        # gt-/g- 前缀 helper
│   └── fomantic/              # Fomantic UI 2.8.7 硬分叉(lint 忽略)
├── templates/
│   ├── base/                  # head/footer/navbar 等 16 个共享片段
│   ├── custom/                # 9 个空钩子模板(升级安全的定制点)
│   ├── repo|user|org|admin|...  # 按路由域组织的页面模板
│   └── shared/                # combomarkdowneditor 等 40 个复用组件片段
├── modules/templates/         # 渲染器:page.go/helper.go/htmlrenderer.go
│   └── scopedtmpl/            # 按模板裁剪执行集 + unsafe 执行桥
├── modules/public/            # manifest.go / vitedev.go(资产解析与开发反代)
├── services/webtheme/         # 主题元信息服务
├── options/locale/            # 28 个 locale_*.json(Go/前端共享翻译源)
├── public/assets/             # 构建产物(js/css/fonts/.vite,gitignore)
└── tests/e2e/                 # Playwright 用例(31 个 test)

构建数据流用一张 mermaid 图概括(生产态;开发态差异见 13.4):

flowchart LR subgraph Sources[&#34;源码&#34;] TS[&#34;web_src/js/*.ts<br/>*.vue&#34;] CSS[&#34;web_src/css/*.css<br/>themes/theme-*.css&#34;] GO[&#34;templates/**/*.tmpl<br/>*.go 中的 tw- 类名&#34;] end subgraph Build[&#34;pnpm exec vite build(make frontend)&#34;] RLD[&#34;Rolldown 打包<br/>entryFileNames: js/[name].[hash:8].js&#34;] TW[&#34;Tailwind(vite css.postcss)<br/>content 扫 .go/.tmpl/.ts/.vue&#34;] IIFE[&#34;iifePlugin × 2<br/>iife.ts / external-render-helper.ts&#34;] end subgraph Out[&#34;public/assets/(gitignore)&#34;] JS[&#34;js/*.hash8.js&#34;] CS[&#34;css/*.hash8.css&#34;] MF[&#34;.vite/manifest.json<br/>源路径 → 哈希产物&#34;] end subgraph Serve[&#34;Go 服务端&#34;] TPL[&#34;ctx.HTML → PageRenderer<br/>{{AssetCSSLinks}} / {{ctx.ScriptImport}}&#34;] RES[&#34;modules/public/manifest.go<br/>AssetURI() 解析哈希 URL&#34;] AFS[&#34;assetfs.Layered<br/>custom 覆盖 builtin(承接 3.7)&#34;] end TS --> RLD CSS --> RLD GO --> TW TW --> RLD TS --> IIFE RLD --> JS RLD --> CS RLD --> MF IIFE --> JS MF --> RES RES --> TPL JS --> AFS CS --> AFS AFS --> BROWSER[&#34;浏览器&#34;] TPL --> BROWSER

关键路径上每个方框在后文都有对应小节:iifePlugin 见 13.3.2,manifest.go 见 13.4.1,PageRenderer 见 13.5,assetfs 承接第 3 章 3.7.2。

13.3.1 Makefile 中真实的前端目标

make help 在开发机上列出的目标里,与前端相关的有(行号取自本仓库 Makefile):

目标 定义处 行为
frontend Makefile:493 frontend: $(FRONTEND_DEST),即构建产物落盘
vite Makefile:599-600 vite: $(FRONTEND_DEST) ## build vite files,与 frontend 等价的别名
$(FRONTEND_DEST) Makefile:602-607 真正的构建规则(见下)
watch Makefile:370-372 @bash tools/watch.sh(前后端并行,第 3 章 3.7 已述)
watch-frontend Makefile:374-376 NODE_ENV=development pnpm exec vite,起 Vite dev server
watch-backend Makefile:378-380 GITEA_RUN_MODE=dev go run air
lint-frontend Makefile:282 lint-js lint-css 组合
lint-js Makefile:293-296 pnpm exec eslint $(ESLINT_FILES) + pnpm exec vue-tsc
lint-css Makefile:303-305 pnpm exec stylelint --max-warnings=0
lint-templates Makefile:353-356 node tools/lint-templates-svg.ts + djlint templates/**/*.tmpl
test-frontend Makefile:387-389 pnpm exec vitest
svg Makefile:609-612 node tools/generate-svg.ts 生成图标集

产物与源的依赖变量(Makefile:115-118):

makefile 复制代码
FRONTEND_SOURCES := $(shell find web_src/js web_src/css -type f)
FRONTEND_CONFIGS := vite.config.ts tailwind.config.ts
FRONTEND_DEST := public/assets/.vite/manifest.json
FRONTEND_DEST_ENTRIES := public/assets/js public/assets/css public/assets/fonts public/assets/.vite

注意 FRONTEND_DEST 是 manifest.json 而非某个 js 文件 ------构建是否完成以 manifest 为判据,这也解释了 $(FRONTEND_DEST) 规则为何先删旧产物再构建:

makefile 复制代码
$(FRONTEND_DEST): $(FRONTEND_SOURCES) $(FRONTEND_CONFIGS) pnpm-lock.yaml
	@$(MAKE) -s node_modules
	@rm -rf $(FRONTEND_DEST_ENTRIES)
	@echo "Running vite build..."
	@pnpm exec vite build
	@touch $(FRONTEND_DEST)

rm -rf $(FRONTEND_DEST_ENTRIES) 先清空 js/css/fonts/.vite 四个目录:因为产物带内容哈希,增量构建会残留旧哈希文件,不清空会让磁盘上堆积死文件,也会让 AssetNameFromHashedPath 的反查表混入陈旧条目。ESLINT_FILES := web_src/js tools *.ts tests/e2e(Makefile:137)说明 lint 范围覆盖仓库根的 *.ts 配置文件与 tests/e2e,但不含 web_src/css 与 web_src/fomantic(后者被 globalIgnores 排除)。

13.3.2 Vite 配置:双入口 IIFE 插件的必要性

vite.config.ts 最核心的设计是两类入口 。普通入口走 build.rolldownOptions.input:

ts 复制代码
build: {
  manifest: true,
  rolldownOptions: {
    input: {
      index: join(import.meta.dirname, 'web_src/js/index.ts'),
      swagger: join(import.meta.dirname, 'web_src/js/swagger.ts'),
      'external-render-frontend': join(import.meta.dirname, 'web_src/js/external-render-frontend.ts'),
      'user-events.sharedworker': join(import.meta.dirname, 'web_src/js/user-events.sharedworker.ts'),
      devtest: join(import.meta.dirname, 'web_src/css/devtest.css'),
      ...themes, // web_src/css/themes/*.css 逐个作为入口
    },
    ...
  },
},

index.ts 是 type="module" 入口(在 templates/base/footer.tmpl 末尾以 {{ctx.ScriptImport "web_src/js/index.ts" "module"}} 引入),defer 执行 ;而 iife.ts 必须阻塞渲染(它要赶在页面首个绘制前解析 window.config、挂好 jQuery、装上错误缓冲),所以用另一个插件把它打成 IIFE 单文件:

ts 复制代码
// vite.config.ts
plugins: [
  iifePlugin('iife.ts'),
  iifePlugin('external-render-helper.ts'),
  ...
],

iifePlugin(vite.config.ts:96-166)干了两件事,分别对应开发态与生产态:

  • 开发态(configureServer) :调用 build()(write: false)把 IIFE 打进内存,注册 dev-server 中间件直接应答 /web_src/js/iife.ts 路径;server.watcher 监听文件变化,命中 iifeModules 集合才重建,且用 do { needsRebuild = false; await buildAndCache(); } while (needsRebuild) 防抖合并连续变更,完成后 server.ws.send({type: 'full-reload'}) 触发整页刷新(IIFE 无法热替换)。同时应答 /web_src/js/__vite_dev_server_check 探活路径------这正是 Go 侧 IsViteDevMode() 用 GET .../__vite_dev_server_check 判断 dev server 是否存活的依据(13.4.2)。
  • 生产态(writeBundle) :主构建写完 manifest 后,先删掉 js/iife.*.js* 旧文件,再单独构建一次 IIFE,并把条目手工补进 manifest:
ts 复制代码
const manifestPath = join(outDir, '.vite', 'manifest.json');
const manifestData = JSON.parse(readFileSync(manifestPath, 'utf8'));
manifestData[`web_src/js/${sourceFileName}`] = {file: entry.fileName, name: sourceBaseName, isEntry: true};
writeFileSync(manifestPath, JSON.stringify(manifestData, null, 2));

为什么必须补:iifePlugin 用的是独立的 build() 调用(lib 模式),其输出不会进入主构建的 manifest;而 Go 侧 AssetURI("web_src/js/iife.ts") 以源路径 为 key 查 manifest,不补这条记录,生产态的 <script src> 就解析不到哈希文件(开发态则走 viteDevSourceURL 直接给源路径,见 13.4.2)。这是"manifest 是前后端契约"最直观的体现。

产物命名规则集中在 output:

ts 复制代码
entryFileNames: 'js/[name].[hash:8].js',
chunkFileNames: 'js/[name].[hash:8].js',
assetFileNames: ({names}) => {
  const name = names[0];
  if (name.endsWith('.css')) return 'css/[name].[hash:8].css';
  if (/\.(ttf|woff2?)$/.test(name)) return 'fonts/[name].[hash:8].[ext]';
  return '[name].[hash:8].[ext]';
},

配置里两处 HINT: VITE-OUTPUT-DIR 注释强调"所有 JS 产物必须在 js/ 目录",目的是让独立 IIFE 文件与主构建产物的路径约定一致,避免开发态(源路径 /web_src/js/iife.ts)与生产态(js/iife.<hash>.js)行为漂移。

其余值得记录的配置点:

  • base: './'------所有资源 URL 相对化,支持子路径部署(配合 setting.AppSubURL)。
  • appType: 'custom'------"Go serves all HTML, disable Vite's HTML handling",Vite 不接管任何 HTML。
  • emptyOutDir: false------不整体清空 public/assets,因为 img/(含 img/svg/,由 make svg 生成)必须保留。
  • server.fs.allow: ['assets', 'node_modules', 'public', 'web_src']------注释写明"dev server will be exposed to public by Gitea's web server",否则 /@fs/* 能读到 app.ini(含 INTERNAL_TOKEN)。这是安全边界,不是性能优化。
  • viteDevServerPortPlugin 写 public/assets/.vite/dev-port:Go 侧反代靠这个文件发现 dev server 实际端口(13.4.2)。
  • failOnWarningsPlugin 仅在 CI 环境启用:任何 Rolldown warning 直接让构建失败。
  • licensePlugin 生产态合并 assets/go-licenses.json 与 JS 依赖许可证,输出 public/assets/licenses.txt(页脚的 Licenses 链接指向它);开发态换成 stub 写入占位文本。
  • ENABLE_SOURCEMAP 三态:true/reduced/false,生产默认 reduced,再由 reducedSourcemapPlugin 只保留 js/index.、js/iife. 等六个独立入口的 .map。
  • filterCssUrlPlugin 只处理 KaTeX 的 CSS:过滤 woff/ttf 旧字体引用,仅保留 woff2(id.includes('katex') 精确限定,避免误伤)。

13.4 构建产物的解析:manifest 驱动与开发态反代

第 3 章 3.7.2 讲过 assetfs 的分层文件系统(custom 层覆盖 builtin 层),当时的结论是"两态静态资源"。本节把第二态(开发态)的另一半机制补齐:源路径 → URL 的解析契约。

13.4.1 modules/public/manifest.go:契约的实现

模板从不直接写死哈希文件名,它们只写源路径:{{ctx.ScriptImport "web_src/js/iife.ts"}}、{{AssetCSSLinks "web_src/js/index.ts" "web_src/css/index.css"}}。把源路径翻译成 URL 的唯一入口是 public.AssetURI:

go 复制代码
// AssetURI resolves a frontend asset by its source path (the Vite manifest key, e.g.
// "web_src/js/index.ts"). Dev mode serves the source file; production resolves the hashed output.
func AssetURI(srcPath string) string {
	if IsViteDevMode() {
		return devAssetURL(srcPath)
	}
	if entry := getManifestData().entries[srcPath]; entry != nil {
		return setting.StaticURLPrefix + "/assets/" + entry.File
	}
	// The only expected manifest miss is a user's custom theme CSS, served as a static asset
	// under "/assets/css/". Anything else is a misconfigured or missing entry.
	if path.Ext(srcPath) == ".css" {
		return setting.StaticURLPrefix + "/assets/css/" + path.Base(srcPath)
	}
	log.Error("asset not found in frontend manifest: %s", srcPath)
	return setting.StaticURLPrefix + "/assets/" + path.Base(srcPath)
}

三条分支对应三种情形:开发态直接给源路径(由 Vite dev server 或反代服务);生产态命中 manifest 给哈希 URL;未命中时只有 .css 被视为合理 (用户自定义主题放在 custom/public/assets/css/,不参与 Vite 构建),其余一律记错误日志后退回裸文件名------这是"预期例外显式化"的写法,自定义主题这个合法场景不会淹没在噪声日志里。

manifest 的加载带两层节流(manifest.go:55-113):

go 复制代码
func reloadManifest(existingData *manifestDataStruct) *manifestDataStruct {
	now := time.Now()
	data := existingData
	if data != nil && now.Sub(data.checkTime) < time.Second {
		// a single request triggers multiple calls to getHashedPath
		// do not check the manifest file too frequently
		return data
	}
	...
	needReload := data == nil || fi.ModTime().UnixNano() != data.modTime
	...
}

func getManifestData() *manifestDataStruct {
	data := manifestData.Load()
	// In production the manifest is immutable (embedded in the binary).
	// In dev mode, check if it changed on disk (for watch-frontend).
	if data == nil || !setting.IsProd {
		data = reloadManifest(data)
	}
	...
}

要点有三:1 秒内的重复查询直接复用(一个请求会触发多次 AssetURI 调用,比如页头页尾的十几个 <script>/<link>,每次都 stat 文件太浪费);重载判据是 ModTime 变化而非内容哈希;生产态 manifest 已嵌入二进制,完全不查盘(只在首次为 nil 时加载一次)。

CSS 的解析不是单条目查表,而是递归收集依赖链上所有 chunk 的样式表------因为 Vite 会把公共代码拆成共享 chunk,入口的 CSS 可能散在多个文件里:

go 复制代码
func entryStyleURLs(jsEntrySrc string) []string {
	entries := getManifestData().entries
	var urls []string
	seen := make(map[string]bool)
	var walk func(key string)
	walk = func(key string) {
		entry := entries[key]
		if entry == nil || seen[key] {
			return
		}
		seen[key] = true
		for _, css := range entry.CSS {
			urls = append(urls, setting.StaticURLPrefix+"/assets/"+css)
		}
		for _, imp := range entry.Imports {
			walk(imp)
		}
	}
	walk(jsEntrySrc)
	return urls
}

seen 防环、Imports 深度优先,最终 AssetCSSLinks 把这些 URL 拼成 <link> 串(manifest.go:144-155)。开发态则完全不同:

go 复制代码
if IsViteDevMode() {
	// data-vite-dev-id makes Vite's HMR client skip injecting a duplicate <style> for this module
	return template.HTML(`<link rel="stylesheet" href="` + html.EscapeString(devAssetURL(devStylesheetSrc)) +
		`" data-vite-dev-id="` + html.EscapeString(viteDevModuleID(devStylesheetSrc)) + `">`)
}

开发态只给 index.css 一个 <link>,其余由 HMR client 在浏览器端注入,data-vite-dev-id 防止重复插 <style>。这里 AssetCSSLinks 的签名本身就编码了两态:(jsEntrySrc, devStylesheetSrc) 两个参数分别服务生产与开发。

AssetNameFromHashedPath 提供反向查找(哈希路径 → 条目名),它在主题系统里有一个具体用途:内置主题文件名带哈希(css/theme-gitea-dark.CyAaQnn5.css),需要从中还原内部名 gitea-dark(13.10)。

13.4.2 modules/public/vitedev.go:反代的两态判定

开发态下浏览器请求 /web_src/js/index.ts,这个 URL 由谁应答?答案是 Go 把匹配的请求反代给 Vite dev server。判定函数 IsViteDevRequest:

go 复制代码
func IsViteDevRequest(req *http.Request) bool {
	if req.Header.Get("Upgrade") == "websocket" {
		wsProtocol := req.Header.Get("Sec-WebSocket-Protocol")
		return wsProtocol == "vite-hmr" || wsProtocol == "vite-ping"
	}
	path := req.URL.Path

	// vite internal requests
	if strings.HasPrefix(path, "/@vite/") /* HMR client */ ||
		strings.HasPrefix(path, "/@fs/") /* out-of-root file access, see vite.config.ts: fs.allow */ ||
		strings.HasPrefix(path, "/@id/") /* virtual modules */ {
		return true
	}

	// local source requests (VITE-DEV-SERVER-SECURITY: don't serve sensitive files outside the allowed paths)
	if strings.HasPrefix(path, "/node_modules/") ||
		strings.HasPrefix(path, "/public/assets/") ||
		strings.HasPrefix(path, "/web_src/") {
		return true
	}
	...
	switch path {
	case "/assets/codemirror-languages.json":
		return true
	}
	return false
}

这份白名单值得逐条读:/@vite/(HMR client)、/@fs/(越根文件访问,受 fs.allow 约束)、/@id/(虚拟模块)是 Vite 的内部协议路径;/web_src/、/node_modules/、/public/assets/ 是源码路径;WebSocket 单独按 Sec-WebSocket-Protocol 判定(HMR 心跳)。而 /assets/codemirror-languages.json 这条特例旁边留着一段自我批评的注释:

go 复制代码
// - {AssetFS} is a layered filesystem from "{RepoRoot}/public" or embedded assets, ...
// - "{RepoRoot}/assets/*.json" just happens to live under the dir name "assets"; it is not related to frontend assets
// - BAD DESIGN: indeed it is a "conflicted and polluted name" sample

仓库根的 assets/(放 go-licenses.json 等)与前端的 public/assets/ 同名不同物,这个历史遗留命名冲突被直接标注为 BAD DESIGN 写进源码------本章把它如实转述,也顺带回应 13.1 表格第 8 条为何产物必须强调是 public/assets/*。

反代本身(ViteDevMiddleware)注册在非生产模式,端口发现靠 dev-port 文件:

go 复制代码
const viteDevPortFile = "public/assets/.vite/dev-port"

func getViteDevServerBaseURL() string {
	portFile := filepath.Join(setting.StaticRootPath, viteDevPortFile)
	portContent, _ := os.ReadFile(portFile)
	port := strings.TrimSpace(string(portContent))
	if port == "" {
		return ""
	}
	return "http://localhost:" + port
}

这个文件由 vite.config.ts 的 viteDevServerPortPlugin 在 httpServer.listening 时写入(13.3.2)------文件系统成为两个独立进程间的发现协议 ,避免端口写死(strictPort: false,端口冲突时 Vite 会换端口,固定端口必然翻车)。反代的 ModifyResponse 给每个响应加 X-Gitea-Vite-Dev-Server 头,方便开发者确认请求确实被代理了;Transport 特意设了 5 秒 IdleConnTimeout,源码注释记录了原因:Vite dev server 的 keep-alive 行为会让 Go 标准库打出 "Unsolicited response received on idle HTTP channel" 的怪日志,缩短空闲超时绕开它。

IsViteDevMode 是"dev server 是否在跑"的权威判定,带 1 秒缓存与主动探活:

go 复制代码
req := httplib.NewClientRequest(http.MethodGet, viteDevServerBaseURL+"/web_src/js/__vite_dev_server_check")
resp, _ := req.Response()
...
isDev := resp != nil && resp.StatusCode == http.StatusOK

探活路径正由 iifePlugin 应答(13.3.2),两端由此闭合。判定结果被 AssetURI、AssetCSSLinks、主题加载(13.10)、页脚 ViteDevMode 标签({{if $.ViteModeIsDev}})共同消费。

开发态的完整链路可以画成时序:

sequenceDiagram participant Dev as 开发者终端 participant V as Vite dev server (:3001+) participant G as Gitea Web (Go) participant B as 浏览器 Dev->>V: make watch-frontend (NODE_ENV=development) V->>V: viteDevServerPortPlugin 写 public/assets/.vite/dev-port Dev->>G: make watch-backend (air 热重载) B->>G: GET /user/dashboard G->>G: AssetURI(&#34;web_src/js/index.ts&#34;) → IsViteDevMode? 探活 1s 缓存 G->>G: viteDevSourceURL → /web_src/js/index.ts G-->>B: HTML(script src=/web_src/js/index.ts) B->>G: GET /web_src/js/index.ts G->>G: IsViteDevRequest → /web_src/ 前缀命中 G->>V: ReverseProxy 转发(+X-Gitea-Vite-Dev-Server) V-->>B: 源码(esbuild 转译) B->>V: WebSocket vite-hmr(Upgrade 头判定) V-->>B: HMR 更新 V->>V: iife.ts 变更 → 重建内存 IIFE → full-reload B->>G: 刷新页面重新走全流程

而第 3 章 3.7 的结论在这里闭环:生产态 AssetURI 给出 /assets/js/index.<hash>.js,请求落到 assetfs.Layered 的 builtin 层(二进制嵌入)或 custom 层(运维覆盖),再经 httpcache 的条件请求政策;开发态则整条流量被 ViteDevMiddleware 截走。两态在 AssetURI 这一个函数上分岔,其余代码------模板、路由、缓存------完全不感知自己处在哪一态。

13.4.3 从模板到资源 URL 的完整调用链

把本节与第 3 章串起来,一条 <script> 从模板文本到浏览器执行的完整链路是:

  1. templates/base/head_script.tmpl 写 {{ctx.ScriptImport "web_src/js/iife.ts"}};
  2. ScriptImport(services/context/context_template.go:102-110)取 CSP nonce、调 public.AssetURI(path) 拼 <script nonce="..." src="...">,非 module 类型不加 type,未知类型直接 panic(宁可崩在开发期也不要产出错误标签);
  3. AssetURI 按 13.4.1 的三分支解析;
  4. 生产态 URL 形如 /assets/js/iife.<hash>.js,由静态路由(第 3 章 3.7.1)交给 assetfs,httpcache 处理 If-Modified-Since;
  5. 浏览器执行 IIFE:bootstrap.ts 解析 #global-window-config,globals.ts 挂 jQuery,之后 footer.tmpl 的 index.ts module 入口异步执行全部 init 函数。

任何一个环节失配都会留下可诊断的痕迹:manifest miss 打 error 日志、dev 探活失败回退静态态、frontendInited 未置位时生产模式弹 alert(13.5.4)。这套"失败可见"的设计贯穿整个前端体系。

13.5 服务端模板:没有继承的"显式组合"

Gitea 的模板体系有一个违反直觉的决定:不用 {{define}}/{{block}} 继承。13.1 已用 Grep 证明全仓库 0 处,替代方案是每个页面模板像搭积木一样显式调用共享片段。

13.5.1 页面骨架的拼装方式

templates/home.tmpl 是最简单的页面模板,骨架只有三行结构:

gotemplate 复制代码
{{template "base/head" .}}
<div role="main" aria-label="{{if .IsSigned}}{{ctx.Locale.Tr "dashboard"}}{{else}}{{ctx.Locale.Tr "home_title"}}{{end}}" class="page-content home">
	<div class="tw-mb-8 tw-px-8">
	...
{{template "base/footer" .}}

templates/base/head.tmpl 自身再往下拼:

gotemplate 复制代码
<!DOCTYPE html>
<html lang="{{ctx.Locale.Lang}}" data-theme="{{ctx.CurrentWebTheme.InternalName}}">
<head>
	{{ctx.HeadMetaContentSecurityPolicy}}
	...
	{{template "base/head_opengraph" .}}
	{{template "base/head_style" .}}
	{{template "base/head_script" .}}
	{{template "custom/header" .}}
</head>
<body>
	{{template "custom/body_outer_pre" .}}
	<div class="full height">
		<noscript>{{ctx.Locale.Tr "enable_javascript"}}</noscript>
		{{template "custom/body_inner_pre" .}}
		{{if not .PageIsInstall}}
			{{template "base/head_navbar" .}}
		{{end}}
{{if false}}
	{{/* to make html structure "likely" complete to prevent IDE warnings */}}
	</div>
</body>
</html>
{{end}}

注意 {{if false}} ... {{end}} 里塞着闭合标签------head 模板只输出到 <body> 开头,收尾由 footer.tmpl 完成,为了让编辑器不报未闭合标签的警告,用永不求值的 if false 块补上"看起来完整"的结构(footer.tmpl 开头有镜像的同款技巧)。这是无继承架构的直接代价,也说明 {{template}} 只是文本嵌入,不提供 block 覆盖点。

head 里 {{template "custom/header" .}} 与 body 前后的 custom/body_outer_pre、custom/body_inner_pre、footer.tmpl 里的 custom/body_inner_post、custom/body_outer_post、custom/footer 等,是 templates/custom/ 下的 9 个空钩子模板 (另有 extra_links、extra_links_footer、extra_tabs)。管理员用同名文件覆盖 custom/public/templates/custom/header.tmpl(走 assetfs custom 层,第 3 章 3.7.2)即可注入自定义内容,而无需改动上游模板------这就是 Gitea 版本升级时"定制不被冲掉"的机制:定制点是文件系统层面的覆盖,不是代码层面的分叉。

模板组合层级表:一棵倒过来的"继承树"

没有 {{block}} 不等于没有结构。把 {{template}} 调用关系展开,得到一张实际的组合层级表------它就是本章对"模板继承层级"这一问题的正面回答(与 Vue SFC 的 <template #slot> 覆盖、Jinja2 的 {% extends %} 对照,语义差别在表后说明):

层级 模板 被谁调用 职责
L0 页面模板 home.tmpl、repo/issue/view.tmpl 等(585 个中的页面级文件) 路由 ctx.HTML(status, name) 决定渲染哪个页面,第一行与最后一行分别是 {{template "base/head" .}} 与 {{template "base/footer" .}}
L1 骨架 base/head.tmpl、base/footer.tmpl 所有 L0 页面 HTML 文档的前半与后半;head 输出到 <body> 开口即止,footer 负责闭合
L2 骨架内件 base/head_opengraph、base/head_style、base/head_script、base/head_navbar、base/footer_content、base/head_banner、base/head_impersonate_banner、base/disable_form_autofill、base/alert、base/paginate、base/modal_actions_confirm、base/markup_codepreview 等(templates/base/ 共 16 个 .tmpl) L1 与页面模板均可调用 meta/CSS/JS 注入、导航栏、页脚内容、通用 alert 与分页控件;页面模板可跳过 L1 直接复用(如 modal 片段)
L3 定制钩子 templates/custom/ 9 个:header、body_outer_pre、body_inner_pre、body_inner_post、body_outer_post、footer、extra_links、extra_links_footer、extra_tabs L1/L2 显式调用 默认全空(或仅注释),是管理员文件覆盖的注入点
L4 业务片段 repo/header/watch.tmpl、repo/issue/comments/*.tmpl 等 页面模板按功能拼装 单一交互单元,13.8 的两条调用链就发生在这一层

用一张 mermaid 图表达这四层加钩子的嵌套顺序(以 home 页为例,箭头方向 = {{template}} 调用方向):

flowchart TB subgraph page[&#34;L0 页面模板 home.tmpl&#34;] H[&#34;首行调用 base/head&#34;] ~~~ F[&#34;末行调用 base/footer&#34;] end subgraph skeleton[&#34;L1 骨架&#34;] HEAD[&#34;base/head.tmpl<br/>(输出到 body 开口)&#34;] --> OP[&#34;base/head_opengraph&#34;] HEAD --> HS[&#34;base/head_style<br/>manifest CSS 链&#34;] HEAD --> HF[&#34;base/head_script<br/>window.config JSON + CSP nonce&#34;] HEAD --> NAV[&#34;base/head_navbar&#34;] FOOT[&#34;base/footer.tmpl&#34;] --> FC[&#34;base/footer_content<br/>主题选择器 / 语言菜单&#34;] end subgraph hooks[&#34;L3 custom 钩子(默认空,可被文件覆盖)&#34;] CH[&#34;custom/header&#34;] --> C1[&#34;custom/body_outer_pre&#34;] C1 --> C2[&#34;custom/body_inner_pre&#34;] C2 --> C3[&#34;custom/body_inner_post&#34;] C3 --> C4[&#34;custom/body_outer_post&#34;] C4 --> CF[&#34;custom/footer&#34;] end H --> HEAD F --> FOOT HEAD -.调用.-> CH HEAD -.调用.-> NAV FOOT -.调用.-> FC

三条对照说明这套层级与"真继承"的差别:

  1. 没有覆盖点,只有插入点。 {{block "x"}}/{{define}} 允许子模板替换父模板的某段内容;{{template}} 只做文本嵌入,L0 不能"重写" L1 的任何一行。想改 head 输出,只能改 base/head.tmpl 本身或用 L3 钩子在固定位置插入------覆盖语义被推迟到了文件系统层(assetfs custom 层)。
  2. 同一片段可被多处复用,无菱形继承问题。 base/paginate 既被 issue 列表调用也被仓库列表调用,互不影响;这正是 Gitea 放弃 {{block}} 的动机之一:Go 的 html/template 块继承要求模板间有严格的父子关系,多页面共享同一部件时容易出现"改 A 页的 block 意外影响 B 页"。
  3. 层级是逻辑分层,不是解析顺序。 Go 解析阶段所有 .tmpl 平铺进同一个 template set(13.5.2 的 collectTemplateNames),L0--L4 只是调用关系上的分层;这也解释了为什么模板名是"去后缀的相对路径"------它必须全局唯一,否则 {{template "repo/header/watch" .}} 会撞名。

这个结构与第 3 章的 assetfs 定制层是同一个设计哲学:不给你改上游代码的接口,只给你放同名文件的位置。

13.5.2 模板命名与渲染入口

模板名 = 去掉 .tmpl 后缀的相对路径。PageRenderer(modules/templates/page.go:62-99)用 sync.OnceValue 保证全进程单例,初始化时列出所有模板名:

go 复制代码
var PageRenderer = sync.OnceValue(func() *pageRenderer {
	rendererType := util.Iif(setting.IsProd, "static", "auto-reloading")
	log.Debug("Creating %s HTML Renderer", rendererType)

	assetFS := AssetFS()
	tr := &tmplRender{
		collectTemplateNames: func() ([]string, error) {
			names, err := assetFS.ListAllFiles(".", true)
			...
			names = slices.DeleteFunc(names, func(file string) bool {
				return strings.HasPrefix(file, "mail/") || !strings.HasSuffix(file, ".tmpl")
			})
			for i, file := range names {
				names[i] = strings.TrimSuffix(file, ".tmpl")
			}
			return names, nil
		},
		readTemplateContent: func(name string) ([]byte, error) {
			return assetFS.ReadFile(name + ".tmpl")
		},
	}
	...
	if !setting.IsProd {
		go AssetFS().WatchLocalChanges(graceful.GetManager().ShutdownContext(), func() {
			if err := tr.recompileTemplates(pr.funcMapDummy()); err != nil {
				log.Error("Template error: %v\n%s", err, log.Stack(2))
			}
		})
	}
	return pr
})

三处细节:mail/ 模板被排除出页面渲染器(邮件模板有自己的 FuncMap,见 mail.go 导出的 SanitizeHTML);非生产态起一个 goroutine 监听模板文件变化即时重编译(与 Vite dev server 的 HMR 对偶------后端模板热重载、前端模块热替换);AssetFS() 即第 3 章的 assetfs.Layered(CustomAssets(), BuiltinAssets())(modules/templates/base.go),custom 在上。

渲染方法 HTML 挂在 pageRenderer 上(page.go:47-60):设 Content-Type: text/html; charset=utf-8、写状态码、TemplateLookup 拿执行器、Execute。而路由侧的入口是 services/context/context_response.go:79 的 ctx.HTML(status, name)------渲染错误转 500;且在 fetch-action 请求(带 X-Gitea-Fetch-Action: 1 头)下错误改为 JSON 错误体(13.8)。

13.5.3 FuncMap:模板能用的全部函数

newFuncMapWebPage()(modules/templates/helper.go:26-129)是页面模板的函数总表,按职责分组摘录(这是对 13.1 表格第 4 条的正面回答):

go 复制代码
func newFuncMapWebPage() template.FuncMap {
	return map[string]any{
		"DumpVar": dumpVar,
		"NIL":     func() any { return nil },

		// html/template related functions
		"dict":        dict, // it's lowercase because this name has been widely used. ...
		"Iif":         iif,
		"Eval":        evalTokens,
		"HTMLFormat":  htmlFormat,
		"QueryEscape": queryEscape,
		"QueryBuild":  QueryBuild,
		"PathEscape":         url.PathEscape,
		"PathEscapeSegments": util.PathEscapeSegments,

		// utils
		"StringUtils": NewStringUtils,
		"SliceUtils":  NewSliceUtils,
		"JsonUtils":   NewJsonUtils,
		"DateUtils":   NewDateUtils,

		// svg / avatar / icon / color
		"svg":           svg.RenderHTML,
		"MigrationIcon": migrationIcon,
		"ActionIcon":    actionIcon,
		"SortArrow":     sortArrow,
		"ContrastColor": util.ContrastColor,

		// time / number / format
		"ShortSha":       base.ShortSha,
		"FormatByteSize": util.FormatByteSize,
		"CountFmt":       countFmt,
		"Sec2Hour":       util.SecToHours,
		"TimeEstimateString": timeEstimateString,
		"LoadTimes": func(startTime time.Time) string { ... },

		"AssetURI":      public.AssetURI,
		"AssetCSSLinks": public.AssetCSSLinks,
		... // AppName/AppSubUrl/AssetUrlPrefix/AppVer/Meta* 等 setting 读取
		"RenderCodeBlock": renderCodeBlock,
		"ReactionToEmoji": reactionToEmoji,
		"CommentMustAsDiff":     gitdiff.CommentMustAsDiff,
		"FilenameIsImage": filenameIsImage,
		"TabSizeClass":    tabSizeClass,
	}
}

几个函数的实现体现了防 XSS 的设计取向:

htmlFormat 缺参数直接 panic:

go 复制代码
func htmlFormat(s any, args ...any) template.HTML {
	if len(args) == 0 {
		// to prevent developers from calling "HTMLFormat $userInput" by mistake which will lead to XSS
		panic("missing arguments for HTMLFormat")
	}
	...
}

HTMLFormat 的语义是"用参数填充受信任的 HTML 模板串",如果只传一个用户输入,它会把用户输入当模板渲染------缺参数 panic 是把最常见的误用在运行时(开发态立刻可见)打死,而不是让它静默产出 XSS。

QueryBuild 是模板里拼查询串的完整状态机 (helper.go:196-275,约 80 行):奇数个参数时首参是路径/既有查询串,偶数个是纯键值对;空值(nil/false/0/"")被剔除 而非输出空值------这正对应 ctx.FormXxx 的零值语义:

go 复制代码
// QueryBuild builds a query string from a list of key-value pairs.
// It omits the nil, false, zero int/int64 and empty string values,
// because they are default empty values for "ctx.FormXxx" calls.
// If 0 or false need to be included, use string values: "0" and "false".

替换已有参数时用 strings.Index(s, "&"+k=") 定位旧 k=v& 段做原位替换,所以 {{QueryBuild "page" $newPage .QueryKeywords}} 这类"改一个参数保留其余"的写法在模板里天然成立。

dict/Iif/Eval 补齐模板缺的表达式能力:dict 构造字典传给子 {{template}};Iif 是免泛型的 inline-if({{iif expr trueVal}} 可省 falseVal);Eval 逐 token 求值({{ $int64 := Eval $var "+" 1 }}),结果类型可直接进 {{if lt $int64 10}}。

这些函数经由 pageRenderer.funcMap(ctx) 注入一个特殊键:

go 复制代码
func (r *pageRenderer) funcMap(ctx context.Context) template.FuncMap {
	pageFuncMap := newFuncMapWebPage()
	pageFuncMap["ctx"] = func() any { return ctx }
	return pageFuncMap
}

ctx 让模板能取到请求上下文的方法(ctx.Locale.Tr、ctx.WindowConfig、ctx.ScriptImport、ctx.CurrentWebTheme、ctx.CspScriptNonce......),13.6 单独展开。而编译期用的是 funcMapDummy()------ctx 返回 nil,只保证模板能解析通过,真实 FuncMap 推迟到执行期(这正是 13.5.4 要讲的 scopedtmpl 机制的前提)。

13.5.4 scopedtmpl:按模板裁剪执行集

html/template 的一个痛点:一旦执行阶段才提供 FuncMap,就和它"编译期完成上下文转义"的模型冲突;而 Gitea 又需要每请求注入 ctx。modules/templates/scopedtmpl/scopedtmpl.go 用三步解决:

第一步,Freeze 占位 (scopedtmpl.go:54-62):

go 复制代码
func (t *ScopedTemplate) Freeze() {
	t.frozen = true
	// reset the exec func map, then `escapeTemplate` is safe to call `Execute` to do escaping
	m := template.FuncMap{}
	for k := range t.parseFuncs {
		m[k] = func(v ...any) any { return nil }
	}
	t.all.Funcs(m)
}

把所有函数换成返回 nil 的占位实现,这样 escapeTemplate 可以安全地 t.Execute(io.Discard, nil)------html/template 只有真正执行一次才会完成全部上下文转义(URL/JS/CSS/HTML 属性各上下文的转义规则),占位函数让这一步不依赖真实数据。

第二步,收集可达模板集 (newScopedTemplateSet,scopedtmpl.go:141-211):从目标模板的语法树出发,递归遍历 parse.TemplateNode({{template "x" .}} 调用)、ListNode、IfNode、RangeNode、WithNode(含各自 ElseList),把可达子模板全收进 htmlTemplates;html/template 生成的内部模板(如 $delimDoubleQuote)落进 textTemplates。每个被收集的模板同样过一遍 escapeTemplate。

第三步,unsafe 桥接执行 (newExecutor,scopedtmpl.go:213-231 + ptr 泛型):

go 复制代码
func ptr[T, P any](ptr *P) *T {
	// https://pkg.go.dev/unsafe#Pointer
	// (1) Conversion of a *T1 to Pointer to *T2. ...
	return (*T)(unsafe.Pointer(ptr))
}
...
func (ts *scopedTemplateSet) newExecutor(funcMap map[string]any) TemplateExecutor {
	tmpl := texttemplate.New("")
	tmplPtr := ptr[textTemplate](tmpl)
	tmplPtr.execFuncs = map[string]reflect.Value{}
	maps.Copy(tmplPtr.execFuncs, ts.execFuncs)
	if funcMap != nil {
		tmpl.Funcs(funcMap)
	}
	// after escapeTemplate, the html templates are also escaped text templates, ...
	for _, t := range ts.htmlTemplates {
		_, _ = tmpl.AddParseTree(t.Name(), t.Tree)
	}
	...
	return tmpl.Lookup(ts.name)
}

既然转义已经完成,同一棵语法树放进 text/template 执行得到的输出与 html/template 一致------而 text/template 支持执行前 挂任意 FuncMap。ptr 用 unsafe 把 *template.Template 的内存布局重解释为内部 textTemplate 结构(htmlTemplate 结构体里就一个 text *texttemplate.Template 字段,布局对齐是 Go 标准库的实现细节),从而把内部 execFuncs 搬运出来。这是一处明确押注标准库内部布局 的代码,代价是 Go 升级有兼容风险,收益是每个请求都能注入真实的、带请求上下文的 ctx 函数而不必重编译模板。

配合 Executor 的双检锁缓存(scopedtmpl.go:68-91),每个页面模板名只在首次请求时做一次收集与转义,之后所有请求复用同一个 scopedTemplateSet,仅新的 funcMap 实例化执行器。tmplRender.recompileTemplates(htmlrenderer.go)在模板变化时重建并 atomic 存储,错误经 templateErrorPrettier 美化出错误行与 ^ 指示符,让开发态的模板错误直接可读。

整套渲染管线总结成图:

flowchart TB A[&#34;ctx.HTML(200, 'home')&#34;] --> B[&#34;pageRenderer.HTML&#34;] B --> C[&#34;TemplateLookup('home', templateCtx)&#34;] C --> D[&#34;ScopedTemplate.Executor(name, funcMap+ctx)&#34;] D --> E{&#34;scopedTemplateSets 命中?&#34;} E -- 否 --> F[&#34;newScopedTemplateSet<br/>escapeTemplate + 语法树可达性收集&#34;] E -- 是 --> G[&#34;newExecutor(realFuncMap)&#34;] F --> G G --> H[&#34;text/template 执行<br/>(转义已完成的 html 模板树)&#34;] H --> I[&#34;Execute(w, data)&#34;] I --> J[&#34;Content-Type: text/html; charset=utf-8&#34;]

13.6 后端到前端的数据通道:TemplateContext、window.config 与 CSP

模板里的 ctx.XXX 与浏览器里的 window.config.XXX 是同一条数据通道的两端,中间夹着 CSP 安全策略。

13.6.1 TemplateContext:ctx 的真身

ctx 函数返回的是 TemplateContext------注意它不是 *context.Base,而是一个 map[string]any(services/context/context_template.go:25-35):

go 复制代码
type TemplateContext map[string]any

var _ context.Context = TemplateContext(nil)

func NewTemplateContext(ctx reqctx.RequestContext, req *http.Request) TemplateContext {
	return TemplateContext{"_ctx": ctx, "_req": req}
}

它实现 context.Context 接口(Deadline/Done/Err/Value 全部委托给 _ctx),并以方法形式挂上模板需要的能力。用 map 而非结构体,是为了让每个请求只构造模板真正用到的那部分 ,同时保持方法签名的自由度。NewTemplateContextForWeb(services/context/context.go)在此之上注入 Locale、AvatarUtils、RenderUtils、MiscUtils、ActionsUtils、RootData、Consts 等字段。

两个高频方法值得展开:

CurrentWebTheme() 的三级回退:

go 复制代码
func (c TemplateContext) CurrentWebTheme() *webtheme.ThemeMetaInfo {
	var themeName string
	if webCtx := GetWebContext(c); webCtx != nil {
		if webCtx.Doer != nil {
			themeName = webCtx.Doer.Theme
		}
	}
	if themeName == "" {
		themeName = middleware.GetSiteCookie(c.req(), middleware.CookieTheme)
	}
	return webtheme.GuaranteeGetThemeMetaInfo(themeName)
}

登录用户的数据库偏好 → cookie → GuaranteeGetThemeMetaInfo 兜底(内部保证永 nil,找不到就退回 setting.UI.DefaultTheme,13.10)。结果被 <html data-theme="{{ctx.CurrentWebTheme.InternalName}}"> 写进根元素,CSS 选择器与 JS 都以它为锚点。

WindowConfig() 是后端→前端的结构化数据总线:

go 复制代码
func (c TemplateContext) WindowConfig() map[string]any {
	locale := c["Locale"].(translation.Locale)
	return map[string]any{
		"appUrl":                     c.AppFullLink("/"),
		"appSubUrl":                  setting.AppSubURL,
		"assetUrlPrefix":             setting.StaticURLPrefix + "/assets",
		"runModeIsProd":              setting.IsProd,
		"customEmojis":               setting.UI.CustomEmojisMap,
		"pageData":                   c.parentContext().GetData()["PageData"],
		...
		// This global i18n object should only contain general texts.
		// for specialized texts, it should be provided inside the related modules by:
		// (1) API response (2) HTML data-attribute (3) PageData
		"i18n": map[string]any{
			"error_occurred":   locale.Tr("error.occurred"),
			... // 共 7 个通用键
		},
	}
}

它在 templates/base/head_script.tmpl 里被序列化进一个非执行脚本标签:

gotemplate 复制代码
<script type="application/json" id="global-window-config">{{ctx.WindowConfig}}</script>
{{ctx.ScriptImport "web_src/js/iife.ts"}}

用 type="application/json" 而不是 window.config = {...} 赋值语句,是因为后者要过 CSP 的 script 检查且存在注入风险;JSON 数据块由 bootstrap.ts 用 JSON.parse(textContent) 读取,html/template 的 JS 上下文转义同时兜底。window.config 的类型定义在 web_src/js/globals.d.ts,前端读取一律走 const {appSubUrl, runModeIsProd} = window.config;。

注意注释里那三行 i18n 设计说明(13.9 会展开):全局 i18n 只放 7 个通用键,专用文案必须走 API 响应 / HTML data-attribute / PageData 三通道,且预留了"未来带版本号的前端 i18n 缓存"的演进方向------这是"无动态翻译端点"(13.1 第 5 条)的官方理由。

13.6.2 CSP:nonce 如何贯穿两端

WebContentSecurityPolicy 生成的策略(context_template.go:116-143):

go 复制代码
return `default-src * data: blob:;` +
	// enforce nonce for all scripts, disallow inline scripts
	`script-src * 'nonce-` + scriptNonce + `';` +
	// it seems that Vue needs the unsafe-inline, and our custom colors (e.g.: label) also need it
	`style-src * 'unsafe-inline';`

default-src * 看似宽松,但 script-src 强制所有脚本带 nonce------注释直白承认两个现实:"Vue 需要 unsafe-inline 的 style"与"自定义颜色(label)也需要",而"没有简单办法让最终用户的 CSP source 完全正确"(自定义主题、CDN、PlantUML 外部渲染都会破坏白名单),所以留了两条未来路线(让反代加 CSP 头 / app.ini 配置项)但都未实施。这是诚实的工程取舍,不是疏忽。

nonce 本身(services/context/base.go:191-203):

go 复制代码
func CspScriptNonce(ctx reqctx.RequestContext) (ret string) {
	// Generate a random nonce for each request and cache it in the context ...
	ret, _ = ctx.Value("_cspScriptNonce").(string)
	if ret == "" {
		ret = util.FastCryptoRandomHex(32) // 16 bytes / 128 bits entropy
		ctx.SetContextValue("_cspScriptNonce", ret)
	}
	return ret
}

每请求 128 bit 随机数,缓存在请求上下文里,ScriptImport、head_script.tmpl 的内联错误缓冲脚本、footer.tmpl 的 frontendInited 检查脚本统一取用。head.tmpl 还通过 {{ctx.HeadMetaContentSecurityPolicy}} 输出 <meta http-equiv> 版本(与响应头双保险)。源码里那句 "these tags are written as <script nonce> to help developers ... when they grep the codebase for 'script'" 解释了为什么所有 script 标签都显式带 nonce 属性------grep 可见性本身是规范的一部分。

13.6.3 错误缓冲:IIFE 抢在错误处理器之前

head_script.tmpl 最前面的内联脚本是整个前端错误处理的地基:

gotemplate 复制代码
<script nonce="{{ctx.CspScriptNonce}}">
	{{/* before our JS code gets loaded, use arrays to store errors, then the arrays will be switched to our error handler later */}}
	window.addEventListener('error', function(e) {window._globalHandlerErrors=window._globalHandlerErrors||[]; window._globalHandlerErrors.push(e);});
	window.addEventListener('unhandledrejection', function(e) {window._globalHandlerErrors=window._globalHandlerErrors||[]; window._globalHandlerErrors.push(e);});
</script>

时间线是:这个内联脚本(阻塞、带 nonce)先执行 → IIFE 的 bootstrap.ts 加载并安装真正的错误上报处理器 → 处理器把 window._globalHandlerErrors 数组里积压的早期错误回放 上报。这样即使 index.ts(module、延迟执行)在初始化阶段抛错,错误也不会丢------配合 footer.tmpl 的自检:

gotemplate 复制代码
<script nonce="{{ctx.CspScriptNonce}}" type="module">
if (!window.config?.frontendInited && window.config?.runModeIsProd) alert("Frontend is not initialized, check console errors or asset files.");
</script>

index.ts 最后一行 window.config.frontendInited = true(13.7.1);若模块因资源 404(典型:忘记 make frontend 或 manifest 过期)没执行完,生产态直接弹 alert 提醒管理员。三段脚本(错误缓冲 → IIFE → 自检)在模板里从头到尾排成一条有意为之的执行序。

13.7 Vue 渐进增强:14 个挂载点的克制使用

Gitea 不是"Vue 应用",而是"服务端渲染 + Vue 局部增强"。全仓库 createApp( 只出现 16 处(14 个业务挂载 + 2 个测试),且 docs/guidelines-frontend.md 明确推荐这种混合形态、禁止 JSX。本节先看初始化骨架,再看 Vue 挂载的具体形态。

13.7.1 index.ts:初始化清单与执行顺序

web_src/js/index.ts(约 180 行)的结构是"import 约 70 个 init 函数 → 传入 callInitFunctions → 最后启动全局观察器":

ts 复制代码
import '../fomantic/build/fomantic.js';
import '../css/index.css';
... // 约 70 个 import

const initStartTime = performance.now();
const initPerformanceTracer = callInitFunctions([
  initGiteaFomantic,          // 必须第一:设置 Fomantic 全局 settings
  initGlobalComponent,
  initGlobalDropdown,
  initGlobalFetchAction,
  ... // 按域分组的 init 函数
  initDevtest,
]);

// it must be the last one, then the "querySelectorAll" only needs to be executed once for global init functions.
initGlobalSelectorObserver(initPerformanceTracer);
if (initPerformanceTracer) initPerformanceTracer.printResults();

const initDur = performance.now() - initStartTime;
if (initDur > 500) {
  console.error(`slow init functions took ${initDur.toFixed(3)}ms`);
}

window.config.frontendInited = true;

三个顺序约束都有注释背书:initGiteaFomantic 排第一是全局 settings 前置;initGlobalSelectorObserver 必须最后 ------所有 registerGlobalSelectorFunc 注册的处理器在观察器启动时统一做一次 querySelectorAll(注册晚于启动则要逐个补查,observer.ts:31-34 为此写了补偿逻辑);超过 500ms 的整体初始化打 error,把性能退化变成 CI/控制台可见的告警。

callInitFunctions(modules/init.ts)还包着 InitPerformanceTracer:访问 ?_ui_performance_trace=1 时逐函数输出耗时。这个"自带剖析器"的细节说明初始化耗时是被持续管理的指标,而非一次性设计。

13.7.2 iife.ts 与 index.ts 的分工

ts 复制代码
// web_src/js/iife.ts(全文 11 行)
// This file is the entry point for the code which should block the page rendering, it is compiled by our "iife" vite plugin

// bootstrap module must be the first one to be imported, it handles global config and errors
import './bootstrap.ts';

// many users expect to use jQuery in their custom scripts ...
// so load globals (including jQuery) as early as possible
import './globals.ts';

import './webcomponents/index.ts';
import './modules/user-settings.ts'; // templates also need to use localUserSettings in inline scripts

对照 footer.tmpl 的 {{ctx.ScriptImport "web_src/js/index.ts" "module"}},两入口的分工是:

维度 iife.ts(head 引入) index.ts(footer 引入)
执行时机 阻塞解析,赶在首个绘制前 type="module",文档解析完后异步
打包格式 IIFE 单文件(iifePlugin) ESM,可 code-split、动态 import
内容 bootstrap(解析 window.config、接管错误缓冲)、jQuery 全局、webcomponents、user-settings Fomantic 初始化 + 全部页面 init 函数 + Vue 挂载
失败影响 页面基础功能不可用(localUserSettings、jQuery 插件失效) 增强失效,HTML 仍可读(noscript 也提示了 JS 依赖)

user-settings.ts 进 IIFE 是因为"templates also need to use localUserSettings in inline scripts"------模板内联脚本(如 dashboard 的 pageData 构造)要同步读到本地设置,不能等 module。这是构建形态服从运行时需求的例子。

13.7.3 MutationObserver 全局初始化框架

Gitea 没有统一的"组件挂载生命周期",取而代之的是 modules/observer.ts 的三类注册表:

ts 复制代码
export function registerGlobalEventFunc<T extends HTMLElement, E extends Event>(event: string, name: string, func: GlobalEventFunc<T, E>) {
  globalEventFuncs[`${event}:${name}`] = func as ...;
}

export function registerGlobalSelectorFunc<T extends Element>(selector: string, handler: (el: T) => void) { ... }

export function registerGlobalInitFunc<T extends HTMLElement>(name: string, handler: GlobalInitFunc<T>) {
  globalInitFuncs[name] = handler as ...;
  // They must be ready before initGlobalSelectorObserver is called.
  if (globalSelectorObserverInited) throw new Error('registerGlobalInitFunc() must be called before initGlobalSelectorObserver()');
}

三者分别对应模板里的三种声明方式:

  1. data-global-init="initRepoWatchMenu" → callGlobalInitFunc 按名字查 globalInitFuncs 执行,且用 _giteaGlobalInited 标记去重("when an element node is removed and added again, it should not be re-initialized again")------这正与 fetch-action 的 DOM 替换配合,重插入的元素不会二次初始化;
  2. data-global-click="onSomeElemClick" → document 级委托,closest('[data-global-click]') 找目标再分发;
  3. 选择器注册(如 .ui.dropdown:not(.custom)、[data-fetch-url])→ 首次全量 querySelectorAll + MutationObserver 增量补扫。

initGlobalSelectorObserver 的观察器对每个新增节点做两件事:initAriaLabels(addedNode)(无障碍属性补全)+ 遍历所有选择器处理器(自身匹配 + 子树查询)。注释里 ATTENTION: For most cases, it's recommended to use registerGlobalInitFunc instead, Because this selector-based approach is less efficient------框架自己标注了性能梯度,鼓励显式 data-global-init 而非隐式选择器。这个观察器是 fetch-action 局部 DOM 替换后能"自动复活"新元素的机制基础(13.8 的第二条链路依赖它)。

13.7.4 Vue 挂载点的三种数据注入形态

14 个业务挂载点形态一致:服务端模板放一个空 <div id="...">,前端 createApp(Comp, props).mount(el)。数据从哪来?源码里有三种通道,各举一例。

通道一:data-attribute + API 请求(heatmap) ------web_src/js/features/heatmap.ts:

ts 复制代码
export async function initHeatmap() {
  const el = document.querySelector<HTMLElement>('#user-heatmap');
  if (!el) return;
  try {
    const url = el.getAttribute('data-heatmap-url')!;
    const resp = await GET(url);
    ...
    const textTotalContributions = trString(el.getAttribute('data-locale-total-contributions')!, totalFormatted);
    const locale = {
      heatMapLocale: {months: ..., days: ..., more: el.getAttribute('data-locale-more'), less: el.getAttribute('data-locale-less')},
      tooltipUnit: 'contributions',
      textTotalContributions,
      noDataText: el.getAttribute('data-locale-no-contributions'),
    };
    const {default: ActivityHeatmap} = await import('../components/ActivityHeatmap.vue');
    const View = createApp(ActivityHeatmap, {values, locale});
    View.mount(el);
    el.classList.remove('is-loading');
  } catch (err) { ... }
}

值得注意的四点:URL 与全部翻译文案都来自 data-locale-* 属性(后端模板写入,13.9 的通道二);Vue 组件是动态 import (await import),被拆成按需 chunk,不进首屏;挂载前元素有 is-loading 状态,挂载成功才移除------加载失败时 HTML 仍是合法占位;无 Pinia,状态就是 props。

通道二:模板内联 script 构造 pageData(dashboard 仓库列表) ------templates/user/dashboard/repolist.tmpl 在模板里直接写 <script type="module"> 把约 55 行翻译键塞进 window.config.pageData.dashboardRepoList,紧跟着 <div id="dashboard-repo-list"> 挂载点。翻译数据随页面 HTML 一次送达,前端 initDashboardRepoList(features/dashboard.ts)读取后传入 Vue------这是 pageData 通道(13.6.1)的典型消费者。

通道三:动态 import + data-locale-*(repo-actions CI 视图) ------features/repo-actions.ts 挂载 RepoActionView 时,从模板的 60 余个 data-locale-* 属性收集翻译;features/repo-issue-pull.ts 则演示按需加载:await import('./comp/PullRequestMergeForm.ts') 只在用户滚到 merge box 才拉组件,且 merge box 的定时刷新带 "interacted" 保护(用户正在操作时暂停自动刷新,避免打断输入)。

Vue 组件本身(23 个 .vue)受官方规范约束:无 JSX (模板字符串 + SFC)、无路由、无全局 store;tools/shared.ts 的 vueDefines 关掉 __VUE_OPTIONS_API__、__VUE_PROD_DEVTOOLS__ 等编译期开关,减小产物。定位非常清楚:服务端渲染负责内容,Vue 负责局部交互的复杂度,两者在挂载点交接。

(13.8 接续:服务端 HTML 如何在不整页刷新的前提下被局部更新------fetch-action 框架与两条真实调用链。)

13.8 端到端交互链路:fetch-action 框架与两条真实调用链

Gitea 介于"整页提交的传统服务端应用"与"单页应用"之间,发明了自己的第三条路:fetch-action ------用 data-fetch-* 属性声明式地把表单/链接/元素变成 fetch 请求,服务端仍返回 HTML 片段,前端按伪选择器命令局部替换。docs/guidelines-frontend.md 明确推荐优先使用它,只有需要精细控制时才手写 JS。

13.8.1 fetch.ts:被 lint 强制的请求封装

先看底层。web_src/js/modules/fetch.ts 全文仅 33 行:

ts 复制代码
export function request(url: string, {method = 'GET', data, headers = {}, ...other}: RequestOpts = {}): Promise<Response> {
  let body: string | FormData | URLSearchParams | undefined;
  let contentType: string | undefined;
  if (data instanceof FormData || data instanceof URLSearchParams) {
    body = data;
  } else if (isObject(data) || Array.isArray(data)) {
    contentType = 'application/json';
    body = JSON.stringify(data);
  }

  headers = new Headers(headers);
  if (!headers.has('content-type') && contentType) {
    headers.set('content-type', contentType);
  }
  return fetch(url, { // eslint-disable-line no-restricted-globals -- this is the wrapper the rule points to
    method,
    headers,
    ...other,
    ...(body && {body}),
  });
}

export const GET = (url: string, opts?: RequestOpts) => request(url, {method: 'GET', ...opts});
export const POST = (url: string, opts?: RequestOpts) => request(url, {method: 'POST', ...opts});
... // PATCH / PUT / DELETE 同型

职责只有三件:FormData/URLSearchParams 原样透传(浏览器自设 multipart/content-type),普通对象序列化为 JSON 并补 application/json 头,统一 Headers 合并。唯一的行内 eslint-disable 注释是元说明 ------这条规则禁止的正是绕过本封装裸用 fetch,封装自身是唯一合法的底层调用点。上层所有代码(fetch-action、heatmap、主题选择器......)都从这里走,于是"请求头策略、错误约定、日志埋点"有了单一收口。

13.8.2 fetch-action 框架:453 行的声明式状态机

modules/fetch-action.ts 是前端最大的单体模块(453 行),开头的注释块就是它的规格说明书(摘录):

ts 复制代码
export function initGlobalFetchAction() {
  // The "fetch-action" framework is a general approach for elements to trigger fetch requests:
  // show confirm dialog (if any), show loading indicators, send fetch request, and redirect or update UI after success.
  //
  // Attributes:
  // * data-fetch-method: the HTTP method to use(GET 是 [data-fetch-url] 默认,POST 是 .link-action 默认)
  // * data-fetch-url: the URL for the request
  // * data-fetch-trigger: "click" | "change" | "load" | "every 5s" | "fetch-reload"
  // * data-fetch-indicator: the loading indicator element selector ...
  // * data-fetch-sync: "$this" / "$innerHTML" / "$morph" / "$body #the-id .the-class" / "$closest(tr) td"
  // * data-modal-confirm: a "confirm modal dialog" will be shown before taking action.
  ...
  addDelegatedEventListener<HTMLFormElement, SubmitEvent>(document, 'submit', '.form-fetch-action', async (el, e) => {
    e.preventDefault();
    await submitFormFetchAction(el, {formSubmitter: e.submitter});
  });

  addDelegatedEventListener(document, 'click', '.link-action', async (el, e) => {
    e.preventDefault();
    await performLinkFetchAction(el);
  });

  registerGlobalSelectorFunc('[data-fetch-url]', initFetchActionTrigger);
  ...
}

三个入口的默认值各有讲究:表单以实际 method 属性 为准(默认 GET),data-fetch-sync 默认空(表单提交通常靠 JSON redirect 而非片段替换);.link-action 是 data-fetch-trigger="click" data-fetch-method="post" 的语法糖,默认 POST(改动状态的动作应是幂等语义外的 POST);[data-fetch-url] 元素默认 GET + $this(响应替换自身)。

请求阶段 (performFetchActionRequest,fetch-action.ts:155-179):

ts 复制代码
const attrIsLoading = 'data-fetch-is-loading';
if (el.getAttribute(attrIsLoading)) return null;   // 防重入
el.setAttribute(attrIsLoading, 'true');
toggleLoadingIndicator(el, opt, true);
try {
  const url = buildFetchActionUrl(el, opt);
  const headers = new Headers(opt.headers);
  headers.set('X-Gitea-Fetch-Action', '1');        // 关键:通知后端这是 fetch-action
  const resp = await request(url, {method: opt.method, data: opt.data, headers});
  if (resp.ok) return resp;
  await handleFetchActionError(el, resp);
} catch (err) { ... } finally {
  toggleLoadingIndicator(el, opt, false);
  el.removeAttribute(attrIsLoading);
}

X-Gitea-Fetch-Action: 1 头是前后端的第二个契约(第一个是 manifest)。加载指示器策略:$this 时按钮直接 disabled,普通元素加 is-loading 类(高度 <50px 再加 loading-icon-2px 微调)------禁用按钮比 spinner 更符合"这个操作暂时不可重复点"的语义。buildFetchActionUrl 借鉴 htmx 的规则:元素自身有 name/value 时(如单选框)并入查询串(ref: https://htmx.org/attributes/hx-get/ 注释在源码里)。

成功分派 (handleFetchActionSuccess)按响应 Content-Type 二分:JSON 走 handleFetchActionSuccessJson(有 redirect 字段则 fetchActionDoRedirect,否则 window.location.reload());HTML 走 handleFetchActionSuccessSync 执行伪选择器命令。错误分派 (handleFetchActionError)读 errorMessage 字段弹 toast(renderFormat === 'html' 时允许 HTML 正文),有 errorFields 则给表单字段标红------注释坦承历史混乱:"sometimes the backend uses 'err', sometimes it uses 'error', and even 'user_error' ... as a new approach, we only use 'errorMessage'"。

伪选择器命令 (execPseudoSelectorCommands,fetch-action.ts:290-319)是这套框架的点睛之笔:

ts 复制代码
export function execPseudoSelectorCommands(el: Element, fullCommand: string): PseudoSelectorCommandResult {
  const cmds = fullCommand.split(' ').map((s) => s.trim()).filter(Boolean) || [];
  let targets = [el], cmdInnerHTML = false, cmdMorph = false;
  for (const cmd of cmds) {
    if (cmd === '$this') { targets = [el]; }
    else if (cmd === '$body') { targets = [document.body]; }
    else if (cmd === '$innerHTML') { cmdInnerHTML = true; }
    else if (cmd === '$morph') { cmdMorph = true; }
    else if (cmd.startsWith('$closest(') && cmd.endsWith(')')) {
      const selector = cmd.substring('$closest('.length, cmd.length - 1);
      ... // 对每个 target 取 closest
    } else {
      ... // 对每个 target 做 querySelectorAll
    }
  }
  return {targets, cmdInnerHTML, cmdMorph};
}

命令按空格分隔顺序执行 ,$ 前缀是私有命令,其余当普通选择器逐级下钻------于是 "$body #repo-header-watch" 表示"从 body 里找 #repo-header-watch","$closest(tr) td .the-class" 表示"先 closest 到 tr 再往里找"。执行结果若 0 个或多于 1 个目标都抛错 (handleFetchActionSuccessSync):局部替换必须精确命中唯一目标,宁可报错也不做不确定的更新。应用方式三选一:Idiomorph.morph(保留既有 DOM 状态的细粒度合并,支持 morphStyle: innerHTML/outerHTML)、直接 target.innerHTML = respText、直接 target.outerHTML = respText。替换完成后 fetchActionReloadOutdatedElements 再 GET 一次当前页,把带 data-fetch-trigger~="fetch-reload" 且有 id 的"过期区域"从新文档里抠出来逐个替换------为"改一处、旁边多处联动"的场景提供了声明式解法。

data-fetch-trigger 的 every 5s(或 ms 单位)触发用 activePageTimerRefresh 管理,回调里检查 document.contains(el),元素被移除则间隔归 0 自动停止------防止局部替换后残留定时器泄漏。

13.8.3 链路一:issue 评论提交(JSON redirect 链)

完整链路从模板到页面更新共 8 步:

  1. 模板声明 (templates/repo/issue/view_content.tmpl):
gotemplate 复制代码
<form class="ui form form-fetch-action" id="comment-form" action="{{$.RepoLink}}/issues/{{.Issue.Index}}/comments" method="post">

表单带 form-fetch-action 类,被全局委托捕获;action 属性注意用 .Issue.Index 拼真实路径(common-button.ts 里有段 GOLANG-HTML-TEMPLATE-URL-ESCAPING 提示:表单 URL 必须写成 data-modal-form.url 而非 .action,否则 Go 模板对 action 属性的 URL 转义规则可能漏掉变量------这是模板层与 JS 层协同的坑,官方用约定绕开)。

  1. 前端拦截 :initGlobalFetchAction 的 submit 委托 preventDefault 后调 submitFormFetchAction,prepareFormFetchActionOpts 收集 new FormData(formEl)(含 submitter 的 name/value)、按 form 的 method/action 构造请求,默认 loadingIndicator: '$this'(整表单变 loading)。

  2. 请求发出 :performFetchAction 先过 confirmFetchAction(data-modal-confirm 若有则弹确认框),再带 X-Gitea-Fetch-Action: 1 头 POST 到 /owner/repo/issues/1/comments。

  3. 后端处理 (routers/web/repo/issue_comment.go:32 的 NewComment):表单错误 → ctx.JSONError;权限检查;CreateIssueComment 落库;成功 → ctx.JSONRedirect(redirect),其中 redirect 含 #hash 定位到新评论。

  4. 响应形态 (services/context/context.go:239-292 与 base.go:164):JSONRedirect 返回 {"redirect": "..."}(HTTP 200);JSONError 返回 {"errorMessage": ..., "renderFormat": "text|html"},JSONErrorWithField 再加 errorFields。而 base.go 的 Redirect 方法在检测到 fetch-action 请求时改写行为:

go 复制代码
// In case the request is made by "fetch-action" module, make JS redirect to the new location
// Otherwise, the JS fetch will follow the redirection and read a "login" page, embed it to the current page, which is not expected.
if httplib.IsGiteaFetchActionRequest(b.Req) {
	b.JSON(http.StatusOK, map[string]any{"redirect": location})
	return
}

注释解释了为什么必须这样:fetch 会自动跟随 302,把登录页 HTML 当成功响应读回来嵌进当前页------用 JSON 指令让 JS 自己决定去哪,才是局部更新的正确语义。

  1. 前端分派 :handleFetchActionSuccessJson 拿到 redirect 调 fetchActionDoRedirect:
ts 复制代码
function fetchActionDoRedirect(redirect: string) {
  // In production, if the link can be directly navigated by browser, we just do normal redirection, which is faster.
  // Otherwise, need to use backend to do redirection: ...
  const needBackendHelp = redirect.includes('#');
  if (runModeIsProd && !needBackendHelp) {
    window.location.assign(redirect);
    return;
  }
  // use backend to do redirection, which can bypass the browser's limitations of "location"
  const form = createElementFromHTML<HTMLFormElement>(html`<form method="post"></form>`);
  form.action = `${appSubUrl}/-/fetch-redirect?redirect=${encodeURIComponent(redirect)}`;
  document.body.append(form);
  form.submit();
}

为何带 # 的重定向要绕道后端 :直接 location.assign 到同页不同 hash 不会触发完整导航,而这里需要重新加载评论时间线(服务端已生成新内容);且注释指出开发态也走后端路径,"make sure the redirection logic is always tested by real users"。于是 7. 落到 routers/init.go:187 注册的 /-/fetch-redirect → common.FetchRedirectDelegate(routers/common/redirect.go):仅接受 POST,且 IsCurrentGiteaSiteURL 校验目标是本站 URL(防开放重定向),通过后返回 303 See Other。8. 浏览器跟随 303 整页导航到带 hash 的地址,新评论滚入视野。

这条链路的边界情况在 e2e 测试里有直接证据(tests/e2e/issue-comment.test.ts):注释写明"表单重初始化前点 Close 会原生提交落到 JSON 页面"------即 JS 尚未接管时,原生表单提交会把用户带到裸 JSON 响应页;这正是为什么 footer 的 frontendInited 自检与 IIFE 的时序如此重要(13.6.3)。

13.8.4 链路二:watch 按钮的局部重渲染(HTML 片段链)

第二条链路不走 JSON,走服务端渲染局部片段 + 伪选择器替换。

  1. 模板声明 (templates/repo/header/watch.tmpl):
gotemplate 复制代码
<div id="repo-header-watch" class="flex-text-block">
	{{if $.IsSigned}}
		<button type="button" class="ui dropdown custom compact small basic button" data-global-init="initRepoWatchMenu" aria-label="{{$buttonText}}">
			{{svg (Iif $isIgnoring "octicon-eye-closed" "octicon-eye")}}
			<span class="not-mobile" aria-hidden="true">{{$buttonText}}</span>
			<span class="ui small label">{{$count}}</span>
			...
		</button>
		<div class="tippy-target">
			<a class="item{{if $isParticipating}} active{{end}}" role="menuitem" aria-label="{{$testParticipating}}"
				data-fetch-method="post" data-fetch-url="{{$.RepoLink}}/action/participate" data-fetch-sync="$body #repo-header-watch"
			>...</a>
			<a class="item{{if $isAll}} active{{end}}" ...
				data-fetch-method="post" data-fetch-url="{{$.RepoLink}}/action/watch" data-fetch-sync="$body #repo-header-watch"
			>...</a>
			<a class="item{{if $isIgnoring}} active{{end}}" ...
				data-fetch-method="post" data-fetch-url="{{$.RepoLink}}/action/ignore" data-fetch-sync="$body #repo-header-watch"
			>...</a>
			...(另有 show-modal 的 custom 模式入口)
		</div>
	{{else}}...{{end}}
</div>

注意 data-fetch-sync="$body #repo-header-watch"------替换目标不是点击的 <a> 自身,而是从 body 重新定位整个 watch 模块 :因为响应片段(整个 #repo-header-watch div)包含按钮状态、计数、菜单 active 项的联动变化,只替换菜单项会留下旧计数。

  1. 前端触发 :.item 元素带 data-fetch-url,被 registerGlobalSelectorFunc('[data-fetch-url]', initFetchActionTrigger) 注册;无 data-fetch-trigger 时默认绑 click 并 preventDefault(阻止 <a> 跳转)。默认 successSync 是 $this,但本例显式给了 $body #repo-header-watch。

  2. 后端路由 (routers/web/web.go:1755):m.Post("/action/{action:watch|participate|ignore}", ..., repo.ActionWatch)------正则限定只接受三个 action,其余 404。

  3. 后端渲染局部片段 (routers/web/repo/watch.go):ActionWatch 处理模式切换后 ctx.HTML(200, tplWatch),其中 tplWatch templates.TplName = "repo/header/watch"。同一个模板文件 既被整页渲染时经由 {{template}} 嵌入 repo 头部,又可作为独立模板名直接渲染出片段------这正是"无继承、纯组合"架构的红利:任何片段天然可独立渲染,不需要像 block 继承体系那样先抽出可复用 partial。

  4. 替换执行 :响应是 HTML(无 JSON content-type),走 handleFetchActionSuccessSync:$body 把 target 置为 document.body,#repo-header-watch 下钻命中唯一目标,非 morph/非 innerHTML 模式下 target.outerHTML = respText 整块替换。

  5. 复活 :替换进来的 DOM 是全新的,initGlobalSelectorObserver 的 MutationObserver 检测到 addedNodes,重新跑选择器------新的 data-global-init="initRepoWatchMenu" 按钮被初始化、[data-fetch-url] 菜单项被注册(_giteaGlobalInited 去重保证不会给旧节点重复绑)。13.7.3 的框架在这里完成闭环。

13.8.5 JSONRedirect/JSONError 的统一错误面与 fetch-redirect 安全

把服务端契约集中列出(services/context/context.go):

方法 响应体 典型用途
JSONRedirect(url) {"redirect": url} (200) 成功后跳转(含 hash 场景)
JSONError(msg, renderFormat) `{"errorMessage", "renderFormat":"text html"}` (原状态码)
JSONErrorWithField(...) 再加 "errorFields": [...] 字段级标红(handleFetchActionErrorFields 按 name 归一化匹配)
Redirect(fetch-action 检测下) 自动改写为 {"redirect"} 所有存量代码不改即兼容 fetch-action

handleFetchActionErrorFields 的字段匹配有个细节:normalizeFieldName 把字段名去非字母数字并转小写 再比对(name.replace(/[^A-Za-z0-9]/g, '').toLowerCase()),容忍 repo_name/repoName 的命名差异;且仅当触发元素是 FORM 才生效------按钮/链接类请求没有字段可标。错误态清理也对称:请求前 resetFormErrorFields 移除旧的 .field.error。

安全边界在 FetchRedirectDelegate:POST-only + IsCurrentGiteaSiteURL(redirect) 校验,挡住了"fetch-action 重定向被构造成外站跳转"的开放重定向向量。两条链路合起来看,fetch-action 实际定义了一个小型协议 :请求头标识身份、响应体(JSON/HTML 两种 content-type)决定行为、伪选择器决定落点、fetch-reload 决定联动------后端只要遵守这四条,就能用纯模板渲染实现 SPA 式交互。

13.9 i18n:28 个 locale 文件与三通道分发

翻译是前后端共享的体系,事实源是 options/locale/locale_*.json(28 个语言文件),Go 与前端消费同一份 JSON(前端不直接读文件,而是经由后端转述,见下)。

13.9.1 Go 侧:modules/translation 与 Locale 解析

modules/translation/translation.go 的 InitLocales 加载全部 locale 文件,缺失键回退 en-US;非生产态还带热重载。两个细节决定行为:trNLangRules 为不同语言注册复数规则(en-US/ru-RU/zh-CN/fr-FR 等------俄语六复数、中文单复数同形,用 golang.org/x/text/feature/plural 规则表驱动);PrettyNumber 走 msgPrinter 做本地化数字分组。

请求级 locale 由 modules/web/middleware/locale.go 按固定优先级解析:

  1. URL ?lang= 参数(最高,footer_content.tmpl 的语言菜单就是 href="{{AppSubUrl}}/?lang={{.Lang}}");
  2. cookie lang;
  3. Accept-Language 头------截断 200 字符再解析(注释未写但代码明确防 DoS:超长头会让 BCP 47 解析器做无谓功);
  4. 命中后 SetLocaleCookie 固化选择。

模板侧消费极简单:ctx.Locale.Tr "key"(base.Tr 返回 template.HTML,翻译值本身视为可信 HTML)、ctx.Locale.TrN(复数)、{{ctx.Locale.Lang}} 写进 <html lang>。footer_content.tmpl 的语言菜单遍历 .AllLangs,当前项加 selected 类。

13.9.2 前端侧:trString/trN 与"无动态端点"

modules/i18n.ts 全文(对齐 Go 的 TrString 语义):

ts 复制代码
/** frontend `Locale.TrN`: pick the `_1` or `_n` form for `count` and interpolate `%d` */
export function trN(count: number, form1: string, formN: string, {lang = getCurrentLocale()}: {lang?: string} = {}): string {
  const text = new Intl.PluralRules(lang).select(count) === 'one' ? form1 : formN;
  return trString(text, String(count));
}

/**
 * Fills variables in a translated string (like TrString in Golang)
 * Only `%s`, `%d` or one-based position like `%[1]s` are supported.
 */
export function trString(s: string, ...args: Array<string | number>) {
  // the same behavior (almost) as backend TrString
  let curIdx = 0;
  return s.replace(/%%|%(?:\[([1-9]\d*)\])?([sd])/g, (match, indexed: string) => {
    if (match === '%%') return '%';
    const argIndex = indexed ? Number(indexed) - 1 : curIdx++;
    if (argIndex < 0 || argIndex >= args.length) return match;
    return String(args[argIndex]);
  });
}

trN 用 Intl.PluralRules(浏览器原生复数规则,与 Go 的 plural 规则表覆盖同一语言集合)选 form;trString 的正则精确对齐 Go 的 TrString:支持 %% 转义、顺序 %s/%d、带索引 %[1]s------两端格式语法一致 意味着同一份 locale JSON 的值可以原样在两侧使用(后端 TrString 填过一遍的模板,前端拿到原始值再填一次也不会语法冲突)。

关键设计(13.6.1 已见官方注释):不存在 /api/i18n/... 动态翻译端点,前端文案按三个通道分发:

通道 机制 适用 实例
window.config.i18n 7 个通用键随页面下发 全局 toast/modal 基础文案 error_occurred、modal_confirm、copy_success
HTML data-locale-* 属性 模板把某组件的全部文案写在元素上 组件级文案(最常用) heatmap 5 个键、RepoActionView 60+ 键
API 响应 后端在 JSON 里附 i18n 字段 异步加载才知道用哪些文案 content-history/overview 返回 resp.i18n.textEdited 等 4 键

三个通道各有取舍:全局键省请求但语言切换需刷新页面(initFooterLanguageMenu 里 GET(data-url) 后 window.location.reload()------服务端按新 cookie 渲染,翻译全量重发);data-attribute 让文案与结构同源、模板即是 i18n 的消费者,代价是属性膨胀;API 通道把翻译延迟到按需。反面教材也在源码里:heatmap.ts 保留了一行历史注释 // last heatmap tooltip localization attempt https://github.com/go-gitea/gitea/pull/24131/commits/...------热力图 tooltip 曾经用过别的方案失败过,现在改用 data-locale 收敛。repo-issue-content.ts 则演示 API 通道的完整形态:GET(.../content-history/overview) 返回 resp.i18n 四个键存模块级变量,供后续动态构造的菜单/对话框使用。

globals.d.ts 给 window.config.pageData 定义了各页面键的类型(含 dashboardRepoList 的翻译字典),TypeScript 层保证了 pageData 通道的键名安全。这套"三通道 + 双端共享 JSON + 格式语法对齐"的体系,是对"要不要做前端翻译端点"这个问题给出的 Gitea 式回答:不做,因为翻译的产生时刻总是伴随数据的产生时刻------数据在哪,文案就在哪。

13.10 主题系统与暗色模式:CSS 变量的元信息自描述

9 个内置主题(web_src/css/themes/):gitea-light、gitea-dark、gitea-auto 三个基底 × 色觉无障碍变体(protanopia-deuteranopia、tritanopia 各自的 light/dark/auto 版本)。主题系统分三层:CSS 文件自描述元信息、Go 服务解析与选择、前端下拉菜单应用。

13.10.1 CSS 自描述:gitea-theme-meta-info

每个主题 CSS 头部有一个特殊选择器块,如 theme-gitea-dark.css:

css 复制代码
/* theme-gitea-dark.css(节选) */
gitea-theme-meta-info {
	--theme-display-name: "Gitea Dark";
	--theme-color-scheme: dark;
}
:root {
	--color-fg: ...; /* 一整套语义颜色变量 */
	...
}

Go 侧用正则解析(services/webtheme/webtheme.go 的 parseThemeMetaInfoToMap),注释解释了为什么不用 :root:

go 复制代码
/*
	The theme meta info is stored in the CSS file's variables of `gitea-theme-meta-info` element,
	which is a privately defined and is only used by backend to extract the meta info.
	Not using ":root" because it is difficult to parse various ":root" blocks when importing other files,
	it is difficult to control the overriding, and it's difficult to avoid user's customized overridden styles.
*/

:root 块会散落在 import 进来的多个文件里、覆盖关系难控、还会被用户自定义样式污染------所以元信息单独用一个只为后端解析存在 的假元素选择器承载(浏览器遇到未知元素直接忽略该块,零运行时开销)。解析器从最后一次 出现 gitea-theme-meta-info 的位置向后截取(strings.LastIndex------后写的覆盖先写的,与 CSS 语义一致),正则逐条抓 --var: value,处理双引号/单引号/裸值三种形式与 \" 转义。读出的键:--theme-display-name、--theme-colorblind-type(red-green/blue-yellow,决定图标 gitea-colorblind-redgreen)、--theme-color-scheme(dark/light)。

主题文件名到内部名的映射有个坑被 defaultThemeMetaInfoByFileName 精巧解决:生产态主题文件带内容哈希(theme-gitea-dark.CyAaQnn5.css),直接 Trim 前后缀会得到 gitea-dark.CyAaQnn5。解法是查 manifest 反向表:

go 复制代码
// For built-in themes, the manifest knows the unhashed entry name (e.g. "theme-gitea-dark")
// which lets us correctly strip the content hash without guessing.
// Custom themes are not in the manifest and never have content hashes.
if name := public.AssetNameFromHashedPath("css/" + fileName); name != "" {
	internalName = strings.TrimPrefix(name, fileNamePrefix)
}

即 13.4.1 的 AssetNameFromHashedPath 在此兑现用途------manifest 成了主题系统与构建系统的共享契约第三位消费者(前两位是 ScriptImport/AssetCSSLinks)。

13.10.2 主题加载的两态与列表服务

loadThemesFromAssets(isViteDevMode) 按态选目录:

go 复制代码
if isViteDevMode {
	// In vite dev mode, Vite serves themes directly from source files.
	themeDir, themePath = os.DirFS(setting.StaticRootPath), "web_src/css/themes"
} else {
	// Without vite dev server, use built assets from AssetFS.
	themeDir, themePath = public.AssetFS(), "assets/css"
}

开发态直接扫源码目录(文件名无哈希),生产态扫构建产物目录(有哈希,靠上一节的 manifest 反查)。getAvailableThemes 做 1 秒缓存 + atomic 存储 + usingViteDevMode 状态记录(态翻转时强制重载,比如 dev server 中途启动)。两个生产态启动检查把配置错误前移:可用主题为空、或 [ui].DEFAULT_THEME 不在可用列表里,都 LogStartupProblem(ERROR)。排序规则也带语义:默认主题永远第一,其次按色觉类型分组,再按显示名。setting.UI.Themes 配置可白名单过滤可用主题。

GuaranteeGetThemeMetaInfo 的三级保证(默认主题 → unavailable 占位对象)让模板端永不解 nil ------{{ctx.CurrentWebTheme.InternalName}} 不需要 {{if}} 包裹。

13.10.3 主题选择的完整交互

路由两条(routers/web/web.go:539-540):/-/web-theme/list 与 /-/web-theme/apply,由 routers/web/misc/webtheme.go 实现:

  • list 返回 Fomantic apiSettings 期望的 {results: [...]} 结构,供页脚下拉远程填充 菜单项(主题数量不定,且要带图标/描述/色觉标记------RenderThemeItem 渲染);
  • apply 区分身份:登录用户更新数据库 User.Theme(跨设备生效),游客写 theme cookie(承接 13.6.1 的三级回退顺序:DB → cookie → 默认)。

前端(features/common-page.ts 的 initFooterThemeSelector):

ts 复制代码
const $dropdown = fomanticQuery(elDropdown);
$dropdown.dropdown({
  direction: 'upward',
  apiSettings: {url: `${appSubUrl}/-/web-theme/list`},
});
addDelegatedEventListener(elDropdown, 'click', '.menu > .item', async (el) => {
  const themeName = el.getAttribute('data-value')!;
  await POST(`${appSubUrl}/-/web-theme/apply?theme=${encodeURIComponent(themeName)}`);
  window.location.reload();
});

选择后 location.reload() 整页刷新------主题是服务端渲染的(<html data-theme> + head_style.tmpl 的主题 <link>),没有客户端动态换肤的复杂度,代价是一次刷新。

暗色模式的实现 :gitea-auto 主题不是 JS 切换,而是纯 CSS------theme-gitea-auto.css 用 @import ... (prefers-color-scheme) 让浏览器媒体查询在 light/dark 间自动选择;<html data-theme="gitea-auto"> 仅标识身份。用户偏好深浅的表达是选主题 (light/dark/auto 三选),不是独立的开关。CSS 变量(--color-*)承载全部语义颜色,切换主题 = 换一套变量值,所有组件零感知。

13.10.4 Tailwind 与主题变量的桥接

tailwind.config.ts 开头做了一件关键事------运行时从 light/dark 主题提取 :root 变量名:

ts 复制代码
function extractRootVars(css: string) {
  const root = parse(css);
  const vars = new Set<string>();
  root.walkRules((rule) => {
    if (rule.selector !== ':root') return;
    rule.each((node) => {
      if (node.type === 'decl' && node.value && node.prop.startsWith('--')) {
        vars.add(node.prop.substring(2));
      }
    });
  });
  return Array.from(vars);
}

const vars = extractRootVars([读入 theme-gitea-light.css, theme-gitea-dark.css].join('\n'));
...
theme: {
  colors: {
    // make `tw-bg-red` etc work with our CSS variables
    ...Object.fromEntries(vars.filter((prop) => prop.startsWith('color-')).map((prop) => {
      const color = prop.substring(6);
      return [color, `var(--color-${color})`];
    })),
    ...
  },
  borderRadius: {'DEFAULT': 'var(--border-radius)', 'md': 'var(--border-radius-medium)', ...},
  fontFamily: {sans: 'var(--fonts-regular)', mono: 'var(--fonts-monospace)'},
  ...
},

于是 tw-bg-red 编译成 background-color: var(--color-red)------Tailwind 的调色板与主题的 CSS 变量是同一套 ,主题切换时 Tailwind 类自动跟随变色,无需为暗色模式维护第二套 utility。important: true 的理由写在配置里:"the frameworks are mixed together, so tailwind needs to override other framework's styles"(Fomantic 与 Tailwind 并存,utility 必须赢)。配套的 .hidden.hidden 双类选择器插件也有详细注释:[hidden] 特异性不够、.hidden 已被 Fomantic 污染、inline style 难调------所以自定义 tw-hidden 的实现是双类叠加 提特异性,并规定显隐只能用它或 utils/dom.ts 的 showElem/hideElem/toggleElem。

CSS 侧三个前缀并存,各有分工:tw-(Tailwind utility)、gt-/g-(helpers.css 手写 helper)、ui/Fomantic 语义类。guidelines-frontend.md 明确新代码优先 tw-,存量 Fomantic 类继续维护------又一个"混用但有序"的现实主义设计。

13.11 质量工具链:四道防线与数据流终图

最后一节把 lint、单测、e2e、模板校验串成链,并用一张全程数据流图收束本章。

13.11.1 ESLint:架构约束器

eslint.config.ts(flat config,约 1237 行)的分层结构:

  1. globalIgnores(['web_src/fomantic', 'public/assets/js'])------硬分叉代码与构建产物不 lint;
  2. 基础块:TS parser + project: true(类型感知 lint)、6 个插件组(stylistic/typescript-eslint/import-x/regexp/unicorn/wc)+ 自定义规则 gitea/unescaped-html-literal (tools/eslint-rules/unescaped-html-literal.ts,拦截 html`<div>${userInput}</div> 这类未转义插值------与 Go 侧 htmlFormat panic 是同一 XSS 防线的前端镜像);
  3. tests/e2e/** 用 playwright flat/recommended;
  4. **/*.vue 挂 vue flat/recommended + vue-scoped-css(含 vue/no-ref-object-reactivity-loss、vue/require-typed-ref 等强约束);
  5. **/*.test.ts 挂 vitest 插件,同时关闭 gitea/unescaped-html-literal(测试里塞 HTML 字面量是常态);
  6. web_src/** 追加 no-restricted-globals(fetch/localStorage)与 no-restricted-properties(window.fetch 等)------13.2 讲过的架构收口在这一块生效;
  7. worker 文件禁用 window(sharedworker 只能用 self)。

另有两条易被忽略的配置:import-x/no-cycle 开启且 maxDepth: 1(模块循环依赖深度限 1,防止 init 函数图谱退化成泥球);@stylistic/quotes: single + avoidEscape + allowTemplateLiterals、comma-dangle: always-multiline 等细节锁死了代码风格,CI 里 reportUnusedDisableDirectives: 2 让失效的 eslint-disable 注释本身报错------注释不留尸体。

make lint-js 在 eslint 之后还跑 pnpm exec vue-tsc(全量类型检查,tsconfig.json 的 strict: true + noEmit + erasableSyntaxOnly + verbatimModuleSyntax------最后两个约束保证 TS 编译产物可被 Vite 直接擦除类型运行,没有装饰器等需要降级的语法)。make lint-css 用 stylelint --max-warnings=0(警告即失败),stylelint.config.ts 忽略 web_src/fomantic,Vue 文件经 postcss-html 解析。make lint-templates 双管齐下:node tools/lint-templates-svg.ts 校验模板里 svg 调用的图标名都存在,djlint 做模板格式化检查。

13.11.2 vitest:真实浏览器里的 51 个单测

vitest.config.ts 定义两个 project:

ts 复制代码
projects: [
  {
    test: {
      name: 'browser',
      include: ['web_src/**/*.test.ts'],
      setupFiles: ['web_src/js/vitest.setup.ts'],
      browser: {
        enabled: true,
        provider: playwright(),
        headless: true,
        screenshotFailures: false,
        instances: ((env.PLAYWRIGHT_BROWSERS || 'chromium firefox').split(' ') ...).map(...),
      },
    },
  },
  {
    test: {name: 'node', include: ['tools/**/*.test.ts'], environment: 'node'},
  },
],

浏览器项目用 playwright provider 跑 chromium + firefox 双浏览器 (PLAYWRIGHT_BROWSERS 环境变量可裁剪)------因为被测对象是 DOM 行为(伪选择器、观察器、转义函数),jsdom 模拟不出真实差异。testTimeout: 20000、maxWorkers: '50%'(浏览器实例吃资源)。51 个 web_src/**/*.test.ts 覆盖的是可隔离的纯逻辑 :fetch-action 的伪选择器解析、i18n.ts 的 trString 边界(%%、索引越界)、DOM 工具函数等------这与工作区规范"prefer unit tests where logic is testable in isolation"一致。make test-frontend 前置依赖 playwright(装浏览器),跑 pnpm exec vitest。

13.11.3 Playwright e2e:31 个场景测试

playwright.config.ts:testDir: tests/e2e、chromium + firefox、timeout: 5000 × factor(基础 5 秒,CI 环境系数放大)。tests/e2e/ 31 个 .test.ts,utils.ts 提供 login、apiCreateRepo 等夹具。典型用例(issue-comment.test.ts)体现语义定位与真实交互:

ts 复制代码
// (节选自 tests/e2e/issue-comment.test.ts 的结构)
await login(page);                          // 助手完成登录态
... page.getByPlaceholder('Leave a comment')  // 语义定位(可访问性名称),非 CSS 选择器
// 注释解释竞态:表单被 fetch-action 重初始化前点 Close,会走原生提交落到 JSON 页面

这条注释与 13.8.3 的结论互为印证:e2e 测试不仅验证 happy path,还固化了框架接管时序 这个隐性契约。运行方式遵循工作区规范:GITEA_TEST_E2E_FLAGS='<filepath>' make test-e2e 跑单文件。eslint 侧对应有 eslint-plugin-playwright 的 flat/recommended(expect-expect 关闭------部分用例以断言异常而非 expect 组织)。

13.11.4 全程数据流总图

用一张图收束第 13 章:从开发者改一行代码到用户看到界面的完整回路(含生产与开发两态的分岔):

flowchart TB subgraph Dev[&#34;开发态(make watch)&#34;] W1[&#34;watch-frontend: pnpm exec vite&#34;] --> W2[&#34;Vite dev server<br/>写 .vite/dev-port&#34;] W3[&#34;watch-backend: air 热重载 Go&#34;] --> W4[&#34;模板 WatchLocalChanges 重编译<br/>manifest 1s 轮询&#34;] W2 -->|反代| W5[&#34;IsViteDevRequest 白名单<br/>X-Gitea-Vite-Dev-Server 头&#34;] end subgraph Build[&#34;生产态(make frontend)&#34;] B1[&#34;pnpm exec vite build&#34;] --> B2[&#34;js|css|fonts 哈希产物&#34;] B1 --> B3[&#34;manifest.json(iifePlugin 手工补条目)&#34;] B1 --> B4[&#34;licenses.txt(合并 Go+JS 许可证)&#34;] end subgraph SSR[&#34;服务端渲染&#34;] R1[&#34;ctx.HTML(status, tpl)&#34;] --> R2[&#34;scopedtmpl<br/>escape→收集→unsafe 执行&#34;] R2 --> R3[&#34;FuncMap: newFuncMapWebPage + ctx&#34;] R3 --> R4[&#34;TemplateContext.WindowConfig<br/>CSP nonce / 主题 / locale&#34;] R4 --> R5[&#34;head_script: JSON 数据块 + IIFE<br/>head_style: AssetCSSLinks + 主题 link<br/>footer: index.ts module&#34;] end subgraph FE[&#34;浏览器端&#34;] E1[&#34;内联错误缓冲&#34;] --> E2[&#34;iife.ts: bootstrap/jQuery/webcomponents&#34;] E2 --> E3[&#34;index.ts: ~70 个 init<br/>MutationObserver 启动(必须最后)&#34;] E3 --> E4[&#34;fetch-action / Vue 挂载 / 主题菜单 / i18n 三通道&#34;] E4 -->|局部更新| E5[&#34;伪选择器: outerHTML / $morph / $innerHTML<br/>observer 复活新节点&#34;] E4 -->|整页跳转| E6[&#34;JSON redirect → /-/fetch-redirect 303&#34;] end subgraph QA[&#34;质量防线(CI)&#34;] Q1[&#34;lint-js: eslint + vue-tsc<br/>lint-css: stylelint --max-warnings=0<br/>lint-templates: svg 校验 + djlint&#34;] Q2[&#34;test-frontend: vitest<br/>browser project (chromium+firefox)&#34;] Q3[&#34;test-e2e: playwright<br/>tests/e2e 31 个用例&#34;] end W5 --> E3 B2 -->|assetfs.Layered| R5 B3 --> R5 R5 --> E1 E5 --> R1 E6 --> R1 Q1 -.约束.-> E3 Q2 -.验证.-> E5 Q3 -.验证.-> E4

13.12 本章关键问答

Q1:为什么不用 {{define}}/{{block}} 做模板继承?换了什么代价与收益? 收益有三:片段天然可独立渲染(watch 片段既是页头的一部分又能单独作为 ctx.HTML 的目标,13.8.4);定制点是文件系统覆盖(custom/ 9 个空钩子经 assetfs custom 层覆盖,升级不冲突,13.5.1);scopedtmpl 的可达性收集在纯 {{template}} 图上是简单 DAG(没有 block 的动态绑定,13.5.4)。代价是要用 {{if false}} 伪造闭合标签哄编辑器,且页面模板要自己按顺序摆 head/footer------代价被限制在"模板作者多写两行",而收益触及渲染与运维,这笔账是划算的。

Q2:为什么需要 unsafe 把 html/template 转 text/template 执行? 因为 ctx 必须每请求 注入(它捕获 request),而 html/template 的模型是编译期绑定 FuncMap、执行期只给数据;每次请求重编译 585 个模板不可接受。转义在 escapeTemplate(占位 FuncMap 下 Execute(io.Discard, nil))一次性完成后,语法树就是"纯文本模板",text/template 执行结果等价,FuncMap 却可晚绑。风险是押注标准库内部内存布局(ptr[T,P] unsafe 转换),源码把这条风险写在了 ptr 函数的 pkg.go.dev/unsafe#Pointer 规则引用里,属于有意为之的受控风险。

Q3:为什么不做一个 /api/i18n 端点拉全量翻译? 官方注释给了三条理由(13.6.1):全局只该有通用文本;专用文案与专用数据同源(数据在哪文案在哪,避免两端键名漂移);若将来需要,可做带版本号的缓存(localStorage 存翻译、版本变更才更新),而不是简单端点。当前三通道(7 个全局键 / data-locale-* / API i18n 字段)已覆盖全部场景,语言切换靠服务端重渲染(刷新页面),简单且无缓存一致性问题。

Q4:开发态为什么 Vite 要单独进程,而不是 Go 内嵌? 进程隔离让 pnpm exec vite 与 go run air 独立重启互不牵连;端口经 dev-port 文件发现(strictPort: false 容忍端口漂移);Go 反代统一入口让浏览器只面对 Gitea 端口------源码注释点明动机:"It is needed because there are container-based development, only Gitea web server's port is exposed"。若内嵌,Go 热重载会连带重启 JS 编译管线,反之亦然。

Q5:fetch-action 的 data-fetch-sync 为什么限制"恰好一个目标"? 0 个目标说明选择器写错或 DOM 已变,静默不更新是隐藏 bug;多目标的批量更新语义不明确(全换?换第一?),$morph 与 outerHTML 在多目标下行为难推理。抛错让问题在开发/测试期暴露(handleFetchActionSuccessSync 的 throw new Error(...) 带完整命令文本)。需要多点联动时,框架给的正解是 $body 收敛到一个容器,或用 fetch-reload 声明过期区域。

Q6:jQuery 4 还在依赖里,为什么不移除 Fomantic? 三个组件(api/dropdown/modal)支撑着几十个模板里的 .ui.dropdown/.ui.modal 类名与存量初始化代码,全量替换的回归面是 240 个 repo 模板。当前策略是收敛而非替换 :硬分叉只保留三个组件(178 字节入口)、GITEA-PATCH 标记全部改动、initGiteaFomantic 统一 settings 与 aria 补丁、ESLint 禁止新增 jquery import(no-restricted-imports 只允许 type import)。新代码用 tw- 与 fetch-action,旧组件冻结在分叉层里------迁移是渐进的,但每一步都被 lint 挡住回退。

13.13 本章小结与导航

本章覆盖了第 1 章 1.8 节对第 13 章的全部承诺,并把第 3 章 3.7 的 assetfs 两态接续到了浏览器最后一公里:

  • 技术栈与构建 :Vite 8(Rolldown)双入口------iife.ts 阻塞渲染、index.ts 模块延迟;产物 public/assets/{js,css,fonts} + manifest.json 契约;开发态经 dev-port 文件发现 + ViteDevMiddleware 反代,生产态经 assetfs 分层 + httpcache(13.2--13.4)。
  • 服务端模板 :585 个 .tmpl,无 block/define 继承,纯 {{template}} 组合 + 9 个 custom 钩子;newFuncMapWebPage 函数表 + 每请求 ctx(TemplateContext);scopedtmpl 的 escape→收集→unsafe 执行三步走(13.5--13.6)。
  • 渐进增强:两个入口的执行序、MutationObserver 三类注册表、14 个 Vue 挂载点、无 Pinia 的 props+pageData 传参(13.7)。
  • 交互链路 :fetch-action 声明式框架、X-Gitea-Fetch-Action 头契约、JSON redirect 与 HTML 片段替换两条完整调用链、/-/fetch-redirect 的开放重定向防护(13.8)。
  • i18n 与主题 :28 个 locale 文件双端共享、三通道文案分发、无动态端点;9 主题的 CSS 自描述元信息、manifest 反查哈希、DB→cookie→默认三级回退、prefers-color-scheme 的 auto 主题、Tailwind 与 CSS 变量同源(13.9--13.10)。
  • 质量工具链 :ESLint 架构收口(禁裸 fetch/localStorage、防 XSS 字面量规则)、vue-tsc/stylelint/djlint、vitest 双浏览器单测、Playwright e2e,外加 500ms 初始化告警与 _ui_performance_trace 自带剖析(13.11)。

回到 13.1 的九条纠正:它们不是清单游戏,每一条背后都是一次真实的设计取舍------没有 Pinia 是因为状态本来就该在服务端;没有 block 继承是因为片段要能独立渲染;没有翻译端点是因为文案与数据同源。理解 Gitea 前端的钥匙不是某个框架的用法,而是这个贯穿始终的判断:服务端渲染负责完整性,声明式属性负责局部性,构建工具链负责把两者缝合成一个可演进的整体。

后续章节导航(依第 1 章 1.8 节路线图):第 14 章将进入 Git 操作与仓库管理层,第 15 章收束全书的部署与运维形态;本章涉及的 assetfs/缓存细节回看第 3 章 3.7,HTTP 路由中间件回看第 3 章,CI/测试基建与第 12 章的质量体系互为表里。

相关推荐
鶴哥只手遮天1 小时前
Gitea源码深度剖析-第9章 REST API v1 体系
git·开源
鶴哥只手遮天1 小时前
Gitea源码深度剖析-第8章 Pull Request 全生命周期:Gitea 最复杂的子系统
git·开源
TTATTC1 小时前
告别开源 Agent 的本地运维折磨:拆解阿里 QoderWork 的沙盒隔离架构与三级渐进式 Skills 编排实践
docker·开源
除不掉的灰色1 小时前
git回退提交前
git
武雄(小星Ai)1 小时前
Hermes Agent 周报:v0.21.6 发布,多账号连接器上线,6 项安全修复
开源·llm·周报·ai agent·hermes
认真恰饭2 小时前
Git push时警告邮箱暴露,推送失败
git
程序员老赵2 小时前
Docker 部署 Redmine:轻松搭建私有化项目管理平台
docker·容器·开源
银河麒麟操作系统3 小时前
中国操作系统创新技术成果亮相国际开源顶会
开源
A心有千千结4 小时前
通过 SSH 反向代理让服务器借用本机网络访问 GitHub 的最佳实践
linux·服务器·网络·git·ssh·github