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 个分布在子目录),按目录分布:repo240、user75、package58、admin52、shared40、org39、devtest25、base16、mail13、custom9、explore6、projects3、status3、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):
关键路径上每个方框在后文都有对应小节: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}})共同消费。
开发态的完整链路可以画成时序:
而第 3 章 3.7 的结论在这里闭环:生产态 AssetURI 给出 /assets/js/index.<hash>.js,请求落到 assetfs.Layered 的 builtin 层(二进制嵌入)或 custom 层(运维覆盖),再经 httpcache 的条件请求政策;开发态则整条流量被 ViteDevMiddleware 截走。两态在 AssetURI 这一个函数上分岔,其余代码------模板、路由、缓存------完全不感知自己处在哪一态。
13.4.3 从模板到资源 URL 的完整调用链
把本节与第 3 章串起来,一条 <script> 从模板文本到浏览器执行的完整链路是:
templates/base/head_script.tmpl写{{ctx.ScriptImport "web_src/js/iife.ts"}};ScriptImport(services/context/context_template.go:102-110)取 CSP nonce、调public.AssetURI(path)拼<script nonce="..." src="...">,非module类型不加type,未知类型直接 panic(宁可崩在开发期也不要产出错误标签);AssetURI按 13.4.1 的三分支解析;- 生产态 URL 形如
/assets/js/iife.<hash>.js,由静态路由(第 3 章 3.7.1)交给 assetfs,httpcache处理If-Modified-Since; - 浏览器执行 IIFE:
bootstrap.ts解析#global-window-config,globals.ts挂 jQuery,之后footer.tmpl的index.tsmodule 入口异步执行全部 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}} 调用方向):
三条对照说明这套层级与"真继承"的差别:
- 没有覆盖点,只有插入点。
{{block "x"}}/{{define}}允许子模板替换父模板的某段内容;{{template}}只做文本嵌入,L0 不能"重写" L1 的任何一行。想改 head 输出,只能改base/head.tmpl本身或用 L3 钩子在固定位置插入------覆盖语义被推迟到了文件系统层(assetfs custom 层)。 - 同一片段可被多处复用,无菱形继承问题。
base/paginate既被 issue 列表调用也被仓库列表调用,互不影响;这正是 Gitea 放弃{{block}}的动机之一:Go 的html/template块继承要求模板间有严格的父子关系,多页面共享同一部件时容易出现"改 A 页的 block 意外影响 B 页"。 - 层级是逻辑分层,不是解析顺序。 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 美化出错误行与 ^ 指示符,让开发态的模板错误直接可读。
整套渲染管线总结成图:
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()');
}
三者分别对应模板里的三种声明方式:
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 替换配合,重插入的元素不会二次初始化;data-global-click="onSomeElemClick"→ document 级委托,closest('[data-global-click]')找目标再分发;- 选择器注册(如
.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 步:
- 模板声明 (
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 层协同的坑,官方用约定绕开)。
-
前端拦截 :
initGlobalFetchAction的 submit 委托preventDefault后调submitFormFetchAction,prepareFormFetchActionOpts收集new FormData(formEl)(含 submitter 的 name/value)、按 form 的 method/action 构造请求,默认loadingIndicator: '$this'(整表单变 loading)。 -
请求发出 :
performFetchAction先过confirmFetchAction(data-modal-confirm若有则弹确认框),再带X-Gitea-Fetch-Action: 1头 POST 到/owner/repo/issues/1/comments。 -
后端处理 (
routers/web/repo/issue_comment.go:32的NewComment):表单错误 →ctx.JSONError;权限检查;CreateIssueComment落库;成功 →ctx.JSONRedirect(redirect),其中 redirect 含#hash定位到新评论。 -
响应形态 (
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 自己决定去哪,才是局部更新的正确语义。
- 前端分派 :
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,走服务端渲染局部片段 + 伪选择器替换。
- 模板声明 (
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 项的联动变化,只替换菜单项会留下旧计数。
-
前端触发 :
.item元素带data-fetch-url,被registerGlobalSelectorFunc('[data-fetch-url]', initFetchActionTrigger)注册;无data-fetch-trigger时默认绑 click 并preventDefault(阻止<a>跳转)。默认successSync是$this,但本例显式给了$body #repo-header-watch。 -
后端路由 (
routers/web/web.go:1755):m.Post("/action/{action:watch|participate|ignore}", ..., repo.ActionWatch)------正则限定只接受三个 action,其余 404。 -
后端渲染局部片段 (
routers/web/repo/watch.go):ActionWatch处理模式切换后ctx.HTML(200, tplWatch),其中tplWatch templates.TplName = "repo/header/watch"。同一个模板文件 既被整页渲染时经由{{template}}嵌入 repo 头部,又可作为独立模板名直接渲染出片段------这正是"无继承、纯组合"架构的红利:任何片段天然可独立渲染,不需要像 block 继承体系那样先抽出可复用 partial。 -
替换执行 :响应是 HTML(无 JSON content-type),走
handleFetchActionSuccessSync:$body把 target 置为document.body,#repo-header-watch下钻命中唯一目标,非 morph/非 innerHTML 模式下target.outerHTML = respText整块替换。 -
复活 :替换进来的 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 按固定优先级解析:
- URL
?lang=参数(最高,footer_content.tmpl的语言菜单就是href="{{AppSubUrl}}/?lang={{.Lang}}"); - cookie
lang; Accept-Language头------截断 200 字符再解析(注释未写但代码明确防 DoS:超长头会让 BCP 47 解析器做无谓功);- 命中后
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(跨设备生效),游客写themecookie(承接 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 行)的分层结构:
globalIgnores(['web_src/fomantic', 'public/assets/js'])------硬分叉代码与构建产物不 lint;- 基础块: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 侧htmlFormatpanic 是同一 XSS 防线的前端镜像); tests/e2e/**用 playwright flat/recommended;**/*.vue挂 vue flat/recommended + vue-scoped-css(含vue/no-ref-object-reactivity-loss、vue/require-typed-ref等强约束);**/*.test.ts挂 vitest 插件,同时关闭gitea/unescaped-html-literal(测试里塞 HTML 字面量是常态);web_src/**追加no-restricted-globals(fetch/localStorage)与no-restricted-properties(window.fetch 等)------13.2 讲过的架构收口在这一块生效;- 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 章:从开发者改一行代码到用户看到界面的完整回路(含生产与开发两态的分岔):
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 章的质量体系互为表里。