静态网站

构建出的宪法也发布成静态网页,英文在 site/index.html,中文在 site/zh/index.html,各自的 agent 版在下面的 agent/ 里 (site/agent/index.html、site/zh/agent/index.html);本设计也发布在 design/ 下 (site/design/index.html、site/zh/design/index.html)。页面由 Hugo 构建,只需 hugo-0.167.0 -s web 一条命令。CI 运行三步:uv run count-tokens (统计 token,见下文)、hugo-0.167.0 --source web --cleanDestinationDir --panicOnWarning,以及链接检查 uv run check-site-anchors site。工作流 .github/workflows/pages.yml 把网站发布到 GitHub Pages 上的 https://agent-constitution.luolc.com/,并把这个地址作为 --baseURL 传给 Hugo。

  • 发布:每次推送到 main,以及手动运行时,job build 统计 token (见下文),像 CI 一样构建并检查网站,然后上传;job deploy 发布网站,但只在仓库是公开的、并且仓库变量 PAGES_DEPLOY 为 true 时才运行,否则跳过。网站上线算一次发布 (规定 must-ask-items):设置这个变量,就是人同意发布此后每一次推送到 main 的内容;删除它,就停止发布。自定义域名在仓库的 Pages 设置里配置,来源选「GitHub Actions」;由工作流发布时会忽略 CNAME 文件,所以仓库里没有这个文件。在发布的构建里,页面之间的链接保持相对路径 (relativeURLs);canonical 链接、各语言的 alternate 链接和 og:url 是绝对地址,用的是这个域名。web/hugo.toml 里的 baseURL 保持为 /:如果是绝对地址,主题会给样式表链接加上 integrity 和 crossorigin,以文件方式打开的页面就会丢掉样式。
  • 输入:版目录 generated/ 下生成的 constitution.<lang>.md (宪法),而不是源文件:引用、编号和锚点只由 constitution-build 渲染一次。web/hugo.toml 把它们和版目录的 index.toml 从 constitution/ 挂载为 asset,partial web/layouts/_partials/constitution/source.html 按名字找到每个文件,除非恰好有一个文件匹配,否则构建失败;Python 不生成任何页面。content adapter web/content/_content.gotmpl 把每个文件做成对应语言的首页:去掉文件头那一行,标题成为页面的标题,每个标题里的锚点 (## <a id="general-provisions"></a>Article I. ...) 成为该标题的 id,这样目录就能链接到它,页面还以 params 的形式拿到长度 (见下文)。文件开头不是文件头那一行、一个空行和 # 标题的,构建失败。布局 (web/layouts/home.html) 放置标题、标题下的列表,以及 <div class="constitution"> 里的正文,样式表以这个元素为根。标题 (web/layouts/_partials/constitution/title.html) 没有主题加的 # 链接,名称和括号部分各自保持在一行内,所以窄屏上只会在括号前换行;它的字号选得让它在 1400 像素宽的桌面窗口里能排在一行。index.toml 中的修订号后面,在 status 为 draft 时跟着 (draft) ((草案))。这些字样的中英文写在 web/i18n/ 里。标题下面是一个不带项目符号的列表:修订号和长度 (见下文),各自在标题下居中;正文里有星号时,还有星号说明,从栏的左边缘开始,比上面两行更小、更淡:先是星号,然后说明标了星号的部分是写给人看的背景 (宪法从哪里来、怎样组织、怎样修改),以及带链接的 agent 版不收录这些部分。星号用强调色,小号加粗,不用上标,也不撑开行距;它往下移,直到中线略高于项编号括号的中线,顶部明显低于编号的顶部。星号说明和目录里的星号显示方式相同。
  • agent 版:每份生成的 constitution.<lang>.agent.md 成为对应语言首页下的 agent/ 页面,由同一个 content adapter 生成 (布局 web/layouts/page.html),但正文保持原样:没有锚点,没有链接,只写给人类读者的条文只保留编号和一句指向完整版的说明。它的标题带 {anchor=false},所以主题不给它们加 # 链接。标题下是一个与首页类似的列表,两行都居中:修订号,以及一个回到完整版的链接。侧边栏、语言切换 (切到另一种语言的 agent 版)、配色切换和目录都与首页相同,目录链接到 Hugo 给标题生成的 ID。
  • 设计:docs/design/ 里的文件成为各语言 design/ 下的页面,原地读取:web/hugo.toml 把 docs/design/*.md 和 docs/design/zh/*.md 挂载为 asset,由 content adapter web/content/design/_content.gotmpl 生成页面,不另存副本。README.md 就是 design/ 这个页面本身,其他文件各是 design/<name>/,顺序按 README 的目录列表,侧边栏里的页面名取自列表里的链接文字;列表与目录里的文件对不上时构建失败。每个文件的 # 标题成为页面的标题。英文页面读 docs/design/,中文页面读 docs/design/zh/ 里的译文:去掉第一行,即英文文件的 SHA-256 (设计译文检查,见检查程序),每个标题的锚点 ## <a id="checks"></a>检查 成为该标题的 id,与英文标题的相同。某个文件还没有译文时,它的中文页面显示英文原文,上面有一行说明还没有译文 (本页还没有中文译文,下面是英文原文。);缺译文从不让构建失败,中文侧边栏的页面名取自中文 README,没有中文 README 时取自英文的。布局 (web/layouts/design/all.html) 把标题和正文放在 <div class="design"> 里,字体、字号、栏宽和配色都与宪法相同,但排成普通文档:标题靠左,用主题的项目符号、表格和代码块。目录列出二级标题,与宪法页面相同;设计里目前没有更深的标题。
  • 设计里的链接:链接是按 GitHub 上看到的文件写的,网站由链接渲染钩子 (web/layouts/_markup/render-link.html) 改写,源文件保持原样。相对链接按页面所读文件的目录解析,即 docs/design/ 或 docs/design/zh/,所以译文里指向另一份设计文件的链接照英文写 (rules.md#rules),指出 docs/design/ 的链接多加一层 ../。指向设计文件 (两个目录都算) 的链接,改指它在当前页面语言下的页面;指向生成的宪法 constitution/<edition>/generated/constitution.<lang>.md 的,改指该语言的首页;指向仓里其他文件的,改指 GitHub 上的文件 (params.SourceRepo 加 /blob/main/)。片段保留。指向一份没有页面的设计文件,或者指出仓库之外的链接,构建失败;指向不存在的文件的链接,交给设计译文检查。宪法页面上的链接,以及各处的绝对链接和单独的片段,保持原样。
  • 长度:列表的第二项给出 agent 版的长度,不加说明,因为占用 agent 上下文的就是它:去掉文件头那一行的生成文件,两种语言都给字符数,英文还给按空白分隔的词数 (ASCII 空白,文件里只有这一种),以及 token 数,由 Anthropic 的 count_tokens API (POST /v1/messages/count_tokens,只用标准库) 按 web/hugo.toml 中 params.tokenCount.model 那个模型统计。字符数和词数由 content adapter 统计;token 数由 count-tokens 统计,写进 web/data/lengths.json (不提交),同时写入所统计的每段文本的 SHA-256 和运行时所在提交的完整 SHA。只有 SHA-256 与正在构建的文本一致时,网站才显示 token 数和那个提交,所以别的提交留下的文件不会显示过时的数字;没有这个文件时,页面说 token 未统计。长度后面有一个信息按钮,鼠标悬停、点击或轻触都会打开一个弹窗;它是一个展开按钮 (aria-expanded),可以用键盘到达,按 Escape 关闭 (web/assets/count-info.js,内联在 <head> 里)。弹窗紧贴按钮挂下来,中间没有空隙,在长度下方居中,至少与长度一样宽,这样指针移到弹窗里的链接上时不会离开它;窄屏上它占满整栏。弹窗先说明它所在的那个长度是 agent 版的,再用一张小表给出两个版本的长度 (用带 ARIA 表格角色的 span,读屏软件能读出列名;完整版是去掉文件头那一行的生成文件,锚点和链接定义都算在内;没有统计的 token 数显示为一条短横线),表格与弹窗同宽;表下是一个比表格更淡的项目列表 (用带 ARIA 列表角色的 span):agent 版略去了什么,哪个 API 为哪个模型统计了 token、或者为什么没有统计 (模型除了 ID,还用 params.tokenCount.name 写出它的名称),以及统计时的修订号和提交,提交缩写为七个字符,链接到源仓库里的那个提交,在新标签页中打开 (没有这段文本的统计,或者不在 git 里时,不显示);最后单独一行,是 agent 版页面的链接。弹窗文字与标题下星号说明的字号相同,表格里的数字略大一些。每个页面的两个版本分别统计,所以带凭证的一次构建要调用四次 API。
  • 凭证:只从环境变量读取,从不来自本仓里的文件,放在两个变量之一中。CONSTITUTION_TOKEN_COUNT_API_KEY 存放 API key,以 x-api-key 发送。CONSTITUTION_TOKEN_COUNT_ACCESS_TOKEN 存放一个短期有效的访问令牌,由工作流通过 Anthropic 的 Workload Identity Federation 取得 (用它的 OIDC 令牌在 POST /v1/oauth/token 换取),以 Authorization: Bearer 发送;两个都设置时用令牌,这样遗留的 key 不会盖过 federation。凭证从不出现在输出、日志或页面里;失败时只打印一行,写 HTTP 状态码或异常的类名。没有凭证,或者调用失败,或者连接或读取卡住 10 秒,页面就说 token 未统计,构建继续;带 count-tokens --require-tokens 时则统计失败,不写文件。.github/workflows/pages.yml 的 job build 只在 main 上运行 (federation 规则接受本仓任何分支上的运行,不只是 main;只发布 main 是本仓自己的选择),而且不在任何 environment 里运行,因为那会改变令牌的 subject:它向 GitHub 要这次运行的 OIDC 令牌 (audience 为 https://api.anthropic.com),用 federation 规则、组织、服务账号和 workspace 的 ID 把它换成访问令牌,这些 ID 存为仓库 secret (ANTHROPIC_FEDERATION_RULE_ID、ANTHROPIC_ORGANIZATION_ID、ANTHROPIC_SERVICE_ACCOUNT_ID、ANTHROPIC_WORKSPACE_ID;它们是标识符,不是凭证,但公开仓库的日志谁都能看,而日志会把 secret 遮成 ***,变量则会原样打印),用 --require-tokens 统计 token,像 CI 一样构建并检查网站,再把两个页面的长度那一行写进 job summary 和日志;与 summary 不同,日志可以通过 API 读取。换取令牌或统计失败,运行就失败,什么都不发布。pull request 上的 CI 拿不到凭证,来自 fork 的 pull request 也拿不到。本地构建想要统计 token,就只为那一条命令设置 key 变量,key 从维护者的密钥管理器里读取。因此,同一个提交的页面在有凭证和没有凭证时会有差别,差别只在那一行和弹窗里。
  • 生成器:Hugo,一个单独的可执行文件,在各工作流和 tools/site/tests/test_site.py 里固定版本,输出到 site/ (publishDir);主题是 hugo-book (MIT 许可),作为 git submodule 放在 web/themes/hugo-book,固定在某个提交。主题提供语言切换,浅色、深色和自动三种配色,侧边栏的页面列表,以及右侧的目录。侧边栏列出的是页面而不是条,按 web/hugo.toml 的 menus 中命名的组排列:每组是一个顶层条目,组名加粗,前面有它自己的一个 Octicons 图标 (params.BookIcon,web/assets/icons/ 里的一个文件),组内的页面是它的子条目。共有两组:宪法,即首页,图标是天平 (law);设计,即它的 README 页面,图标是一本打开的书 (book),组下是 content adapter 列出的各个文件 (见上文「设计」)。设计组可以收起 (组的 menu 参数里的 collapse):除设计自己的页面外,每个页面上它都是收起的;在设计的页面上它展开,并标出当前页。它用的是主题文件树里的 checkbox、label 和箭头;收起时箭头朝右,展开时朝下,键盘可以切换。宪法组不收起。宪法组下面是它的各版,新的在前:content adapter 把每个页面的条目加进菜单,名称取自 index.toml,写作「2026-10 Edition」/「2026-10 版」,status 为 draft 时后面跟着「(draft)」/「(草案)」,权重是这一版的年月取负。目前只有一版,即首页;等有了好几版,每版一个页面,这还没有实现。agent 版不列出,因为它不是给人读的;首页链接到它 (星号说明和长度的弹窗里),content adapter 用 bookHidden 把它从主题自己的列表里隐去,设计的各页面也这样隐去,由菜单列出。目录列出页面的二级标题,即序言和各条 (web/hugo.toml 中的 markup.tableOfContents),上面有它自己的标题;窄屏上,主题把它藏在顶栏右侧的一个按钮后面,点开后显示在正文上方,就像左侧的按钮打开侧边栏一样。目录会标出读者当前所在的条 (aria-current),由 web/assets/toc-current.js 设置,内联在 <head> 里:取位于窗口高度 30% 那条线上方的最后一个标题,或者正文末尾进入视野后取最后一个标题,用 IntersectionObserver 监测,当前所有浏览器都支持它;滚动时不读取任何东西。只用 CSS (scroll-target-group 和 :target-current) 也能做到,但查看时 (2026-10-13) 只有 Chromium 支持。网站覆盖了主题的侧边栏 (layouts/_partials/docs/menu.html),在站名和语言切换之间放一个指向源仓库的链接,带 Octicons 里 GitHub 的标志 (MIT 许可);并通过主题的 inject partial 加入两样东西:语言切换那一行右端的配色切换按钮,以及字体。配色切换是一个按钮,像 Docusaurus 的那样在跟随系统、浅色和深色之间循环;它用一个 Octicons 图标 (device-desktop、sun、moon) 显示当前设置,title 和无障碍名称写明当前设置和下一个设置,文字取自 web/i18n。选择保存在 localStorage 里,并在页面绘制之前于 <head> 中设置。用 submodule 可以让主题的文件不出现在本仓的 diff 里;用 Hugo module 的话,每台构建的机器上都需要 Go。
  • 原始 HTML:Goldmark 的 unsafe 渲染选项是打开的,因为锚点 (<a id="...">) 和星号都是原始 HTML。内容只有本仓自己构建出的宪法和设计,从来没有外部输入。
  • 锚点:条、款或项的 ID:#general-provisions、#terms、#terms.principal,两种语言相同,Markdown 里也相同。引用是指向它的引用式链接。:target 会让锚点所在的元素闪一下。
  • 样式:web/assets/styles/custom.css,由主题在它自己的样式之后加载。第一部分只用页面自己的标记,可以原样搬到别的生成器;最后一部分把它映射到 hugo-book 的变量上。条标题居中,款和项的编号加粗 (Markdown 里就是粗体),英文左对齐、右边不齐,中文两端对齐,打印样式不带链接装饰。正文、标题下和弹窗里的每个链接都像引用一样加下划线:文字用正文颜色,下划线是分隔线颜色的浅线,指针悬停时两者都变成强调色。导航 (侧边栏、目录和窄屏的顶栏) 不加下划线,但在指针悬停或键盘焦点所在时加。侧边栏用强调色标出当前页面。目录与正文区分开,颜色较淡,沿一条细线排列;当前的条加粗,用强调色,并在细线上加一道竖条,所以不只靠颜色来区分。正文字号随窗口变化,限定在下限和上限之间 (clamp()),栏宽以 em 为单位,所以窗口够宽时,英文一行约 90 个字符 (36em,按正文的整行测量:EB Garamond 的字母比 ch 窄;常用的 75 个字符对这份文本来说太窄),中文约 40 个字。窗口窄于 70rem (1120 像素) 时,三栏会让英文正文每行少于约 65 个字符,所以这时目录收到主题顶栏的按钮后面,按钮浮在右上角,点开后目录覆盖在正文右侧;侧边栏一直保留到 hugo-book 的断点 (56rem),小于它时主题也把侧边栏隐藏。正文栏在侧边栏和目录之间居中。字体:EB Garamond 和 Noto Serif SC (SIL Open Font License 1.1),从 Google Fonts 以可变字体加载,回退到本地的衬线字体。颜色写在一个调色板块里,分浅色和深色;两种配色下,正文与页面背景、正文与侧边栏背景的对比度都高于 WCAG AA,具体数值见样式表。
  • 网站本身的少量中文字符串 (web/i18n/zh.yaml,存放标签和弹窗文字,以及 hugo.toml 里的语言标签和菜单名称) 不在 limae 的检查范围内。
  • site/ 不提交:生成的页面一旦提交、进入审查,每个修正案都会改动它们,而修正案只能改动宪法源文件和它们生成的 Markdown。Hugo 需要下载,所以构建不作为提交钩子;模板的测试 (test_site.py,它会构建一部虚构的宪法和一份虚构的设计) 在没有 Hugo 时跳过,CI 除外;CI 在测试之前安装 Hugo,在每个 pull request 和每次推送上构建网站,并对它运行 check-site-anchors。