diff --git a/.dockerignore b/.dockerignore index 557f4ea..a0d3814 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,3 +8,7 @@ .artifacts apps/docs/public/figures/**/*.webp .env* + +**/*.sqlite +**/*.sqlite-* +**/*.db diff --git a/.gitea/workflows/ci-deploy.yml b/.gitea/workflows/ci-deploy.yml index 59b3211..1b8a614 100644 --- a/.gitea/workflows/ci-deploy.yml +++ b/.gitea/workflows/ci-deploy.yml @@ -22,6 +22,7 @@ jobs: with: github-server-url: ${{ gitea.server_url }} persist-credentials: false + fetch-depth: 0 - name: Verify with persistent caches or reuse verified image run: python3 scripts/ci.py - name: Deploy latest main with health checks and rollback diff --git a/.gitignore b/.gitignore index 785350d..b6504da 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,8 @@ __pycache__/ # Browser raster derivatives are generated on the confirmed remote runner. /apps/docs/public/figures/**/*.webp + +# Application data and content exports belong in private persistent storage. +*.sqlite +*.sqlite-* +*.db diff --git a/AGENTS.md b/AGENTS.md index ab3fbac..c44c3b7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,9 +1,9 @@ # Tabularium working agreements -- 本仓库专门维护 Endor 文档与制图脚本。支持本地开发;CI/CD 使用现有 ces-control Runner,在 main 通过验证后自动部署。不得修改其他项目、共享 Runner 配置或清理共享 Docker 缓存。 +- 本仓库专门维护 Endor 文档与制图脚本。支持本地开发;CI/CD 仅在 main 使用现有 ces-control Runner,验证通过后自动部署;工作分支与 PR 不运行 CI,确认 Runner 不构成扩大触发范围的授权。不得修改其他项目、共享 Runner 配置或清理共享 Docker 缓存。 - Web 技术栈为 TypeScript、React、Tailwind CSS、Next.js Pages Router;不要切换到 App Router。 - 使用 pnpm 管理 JavaScript workspace,使用 uv 管理 `tools/figures` 的 Python 项目,保留两套锁文件。 -- Markdown 正文放在 `content/docs`,Python 图形源码放在 `tools/figures/src/endor_figures`,可复现的 SVG 输出放在 `apps/docs/public/figures`。 +- 文档正文、目录元数据与修订记录以站点现有 SQLite 数据库为唯一权威源,使用 docs 管理工具直接更新;不得把新正文、目录 JSON、数据库文件或日常内容快照提交到仓库。历史 Git 文档只用于显式的一次性迁移,不作为运行时种子。Python 图形源码放在 `tools/figures/src/endor_figures`,可复现的 SVG 输出放在 `apps/docs/public/figures`。 - 未确认的架构提议明确标注为草案,不能表述为现有实现。 - 本地开发与预览只监听 `127.0.0.1`。部署容器监听 `0.0.0.0:3000`,服务器入口默认 18135,可通过独立 production.env 配置。 - 对 gitea.entroia.com 仅使用 codex-bot,提交身份为 Codex Bot 。SSH 使用隔离配置:`ssh -F /dev/null -o IdentitiesOnly=yes -i /Users/zzyang/.codex/credentials/gitea-entroia/codex_entroia_ed25519 -p 22222`。 diff --git a/README.md b/README.md index ee59b68..8705bbf 100644 --- a/README.md +++ b/README.md @@ -1,55 +1,67 @@ # Endor · Tabularium -Endor 文档与制图 monorepo。Tabularium 取古罗马档案馆之意。支持本地开发,并由 Gitea Actions 在 ces-control 上验证与部署。 +Endor 文档站、研究工作台与制图程序。应用代码在 Git 中评审;全部文档正文、目录元数据和内容修订以站点 SQLite 数据库为唯一权威源。日常写作不需要 commit、构建或部署。 -## 本地开发 +## 存储与读取 -需要 Node.js 22.13+(推荐 LTS)、pnpm 11.25、Python 3.11+ 和 uv。 -首次安装依赖需要访问软件包源。本地开发服务只监听 127.0.0.1。 +沿用 `AUTH_CONFIG_FILE` 同目录的 `login-state.sqlite` 和 `/auth` 持久挂载。`documents` 存正文及分类、合集、分组、摘要、草案与推荐状态;`document_aliases` 保留旧地址;`document_revisions` 保存每次修改;`document_imports` 标记一次性迁移。没有第二套文档数据库。 + +首页、阅读页、目录和全文搜索按请求查询数据库,使用 `private, no-store`。旧文件式地址与改名前的语义地址通过数据库别名跳转,保留查询参数;不再在构建阶段枚举文档。Markdown 原文通过受认证保护的 `/api/documents/source?slug=...` 获取。 + +仓库不保存日常正文、目录 JSON、数据库文件或内容快照;历史 Git 记录仅用于迁移。页面不再读取 `content/docs` 或 `content/catalog.json`。Markdown 与 GFM 表格仍可用,不解析原始 HTML。图片在写入时检查压缩格式;图形程序与版本化静态资源仍在代码仓库,本轮不迁移媒体存储。研究业务数据留待下一步。 + +## 在服务器上管理文档 + +部署安装 `/opt/endorphin-tabularium-docs/docs`,通过当前容器执行管理 CLI。需要服务器的运维权限;不增加可公开写入的 API,不改变现有动态码登录。 ```sh -pnpm install --frozen-lockfile -uv sync --project tools/figures --locked -pnpm dev +sudo /opt/endorphin-tabularium-docs/docs list +sudo /opt/endorphin-tabularium-docs/docs get endor/endor-purpose-draft +sudo /opt/endorphin-tabularium-docs/docs history 04-endor-purpose-draft ``` -访问 http://127.0.0.1:3000 ,先使用验证器动态码登录。首次本地使用需按下文初始化私有认证配置;缺少配置时正文关闭访问。站点无外部字体或 CDN 资源。 +新建或修改使用标准输入传入完整 JSON。必填字段:`slug`、`sourceSlug`、`title`、`category`、`collection`、`group`、`description`、`draft`、`featured`、`content` 和 `revision`。新建时 `revision: 0`;修改时先 get 并使用读到的 revision,避免覆盖别人刚写入的版本。`sourceSlug` 是稳定标识,修改公共 slug 时保持它不变,旧地址继续可用。调整目录直接修改 category、collection、group 等字段。 -```text -apps/docs/ Next.js Pages Router + React + TypeScript + Tailwind CSS -content/docs/ Markdown 文档源,支持目录,路径成为 /docs/ 路由 -tools/figures/ 独立 Python 项目,使用 drawsvg 编程制图 +```sh +sudo /opt/endorphin-tabularium-docs/docs get endor/endor-purpose-draft > /private/work/purpose.json +# 在私有文件中修改正文或目录字段,保留读到的 revision。 +sudo /opt/endorphin-tabularium-docs/docs save < /private/work/purpose.json ``` -## 命令 +路径仅为操作示例,应选择实际存在且受保护的目录。运行成功后刷新网页即可看到正文、导航和搜索更新。版本冲突时重新读取并合并修改;不要把旧 revision 改成新数字强行覆盖。草案是现有展示标记,不是按分支或用户隔离的访问控制。 -- `pnpm dev`:本地开发,修改 Markdown 后刷新页面即可读取新内容。 -- `pnpm figures`:运行 Python 制图,更新 `apps/docs/public/figures`。 -- `pnpm lint` / `pnpm typecheck`:前端静态检查。 -- `pnpm build`:构建文档站。 -- `pnpm start`:在本地预览已构建站点。 -- `pnpm check`:依次执行 lint、类型检查、制图与构建。 +新增正文不提交回 Git。奇思妙想这类内容同样通过数据库写入;本次用户明确要求汇总四个 PR,因此该草案已纳入首次迁移清单,保留草案状态。代码分支与数据发布是不同动作,其他分支的内容不会自动发布。 -站点默认浅色,使用本地系统字体。全站搜索支持正文关键词、`⌘/Ctrl K`、方向键与 Enter;代码块提供复制与反馈。桌面提供分组导航和滚动高亮的页内目录,小屏可展开文档菜单与页内目录。首页入口、草案提示、前后篇导航均引用现有 Markdown 内容和资料目录配置。顶部提供文档切换,阅读页左侧只展示当前文档,并有独立图标、名称与返回入口;搜索框标明当前文档范围。界面主要通过留白、轻渐变与状态反馈分层,支持减少动态效果的系统偏好。 +## 首次迁移与备份 -参考设计截图与研究记录保存在 `design/references`;浏览器验收截图与检查说明保存在 `design/verification`。这些文件不作为站点静态资源发布。 +本次汇总先合入 PR #14、#15、#16,再应用 PR #17 的数据库迁移。最新研究正文及奇思妙想草案合计 51 篇,连同目录可从固定历史提交 `cffca6942b1bc0005321e1685f38593fb0b853d5` 完整导出;其中 AI 公司研究三篇采用汇总期间追加的 `74af205b2959e1bb6a1fe9f567bc87069ac6d12c` 历史版本,保留 Wabi 更新: -文档标题取首个 `# 标题`,文档排序按相对路径。支持多层目录(例如 `content/docs/pretext/01-chapter.md` 对应 `/docs/pretext/01-chapter`);旧文档地址保持可用。`content/catalog.json` 管理合集、分类、摘要、草案与推荐,新增资料无需修改页面代码。首页推荐最多3篇,目录每页8条,可按合集与标题筛选;全站搜索仍覆盖全文。相邻篇目限于当前合集。 +```sh +python3 scripts/export-legacy-documents.py --output .artifacts/documents-migration.json +``` -Pretext 教程位于 `content/docs/pretext`,包含导读与12章,来自本地 Pretext 研究成果。源码链接锁定上游研究版本;实验脚本与原始记录位于 `apps/docs/public/materials/pretext`,和正文一样经过登录验证。普通 Markdown 与 GFM 表格可用,嵌入图使用 `/figures/.svg`。不解析原始 HTML。 +导出文件位于 Git 忽略目录、权限 0600,不进入镜像。脚本也支持显式 `--ref`,但不能用未评审分支替换生产迁移基线。它不会在站点读取时执行。CI 使用历史导出验证全部正文与目录的逐项一致性,构建镜像只含程序,不含文档快照或数据库。 -语言设计问题集位于 `content/docs/language-design`,是第三个资料合集,包含总览与原编号02–14的13个分类,共271项议题。原讨论已确认26项,其余保持待讨论;来源映射和本次状态记录位于 `content/language-design-sources.json`。维护时同步章节、总览计数与来源状态;不会自动从聊天同步,也不把设计记录等同于已实现功能。 +首次部署先备份整个共享数据库,再显式事务导入到空文档表。普通部署使用 `import --if-empty`,已有内容时跳过,不以历史数据覆盖数据库编辑;重复同一导入也不会重置内容。导入失败则事务整体回滚,既有登录与研究表保留,部署中止。 -生产构建会生成文档路由;新增或修改文档后重新运行 build。开发模式按请求读取内容。 -main 推送通过 CI 验证后自动部署;Pull Request 只验证,不部署。工作流也支持手动触发。 +```sh +sudo /opt/endorphin-tabularium-docs/docs backup /auth/manual-backup.sqlite +sudo /opt/endorphin-tabularium-docs/docs export /auth/current-documents.json +``` -## Endor 概念图 +backup 是包含登录、研究、正文、别名和修订的完整 SQLite 一致性备份;export 只导出当前正文与目录,作为空库初始化/外部迁移快照,不代替完整备份或恢复修订历史。输出路径均在容器内,文件已存在时拒绝覆盖。备份含认证状态,应留在私有挂载或受控备份位置,不能放到公开静态目录、仓库或日志。 -`content/docs/00-endor-concepts-draft.md` 解释全球入口、区域网关与 Runtime、分片、CDN、身份及跨区恢复的概念设计,页面为 `/docs/00-endor-concepts-draft`。 +代码回退不会撤销数据库编辑,但回退到旧文件式站点会显示旧正文。恢复完整数据库会同时影响登录和研究状态,必须先停止写入、保留当前备份并由管理员明确选择恢复点;部署脚本不会自动恢复数据库或删除新内容。 -图形源码位于 `tools/figures/src/endor_figures/architecture.py`,由 `pnpm figures` 生成 `apps/docs/public/figures/endor-architecture.svg`。Python 负责可复现的节点、全连接和复制路径,正文直接显示图片,点击可放大;大图支持原尺寸查看、滚动和 Escape/空白处关闭。无需外部图标、字体或绘图服务。图中的数量是示意,身份服务/CDN 接入属于建议,所有未实现的关系均以草案说明。 +## 代码验证 -拓扑检查:`uv run --locked --project tools/figures python -m unittest discover -s tools/figures/tests -v`。容器检查流程会运行同一检查。 +使用 React、Next.js Pages Router、TypeScript 和 Tailwind CSS。JavaScript 用 pnpm,制图 Python 用 uv。编译、构建、打包和构建产物全部在已确认的 ces-control Runner 执行;`pnpm test:docs` 等测试脚本会调用 esbuild,也只能在 Runner 执行。 + +工作分支与 PR 不运行 CI,仅做不会触发编译、构建的轻量检查。用户手动合并 main 后,才在 ces-control 执行 lint、类型检查、数据库事务/版本测试、现有认证与研究测试、图形检查、构建与容器冒烟;验证通过才沿用既有部署流程。 + +冒烟使用隔离数据库卷:完整导入后逐项核对全部正文与目录,验证新建、更新、重命名、搜索立即变化及容器重启后的持久化,并覆盖全部文档地址、认证、媒体和原文端点。测试卷不会触及生产数据库。 + +`tools/figures/src/endor_figures` 与 `apps/docs/public/figures` 仍保存图形源码及可复现的静态资源。图形构建也在远程执行。 ## 动态码登录与二维码 @@ -68,13 +80,7 @@ sudo /opt/endorphin-tabularium-docs/auth verify `qr`只允许交互终端显示;`qr-png`用于通过SSH将PNG直接重定向到私有本地文件,不在CI中运行。二维码等同登录凭据,不放入本仓库或网页。只有明确执行 `auth rotate --confirm` 才更换密钥并使旧二维码、会话失效;重新显示或下载二维码不会轮换。 -本地先构建CLI,再初始化独立的本地测试配置: - -```sh -pnpm build -AUTH_CONFIG_FILE=/private/tmp/tabularium-auth/config.json node apps/docs/.next/standalone/apps/docs/auth-cli.cjs init -AUTH_CONFIG_FILE=/private/tmp/tabularium-auth/config.json AUTH_COOKIE_SECURE=false pnpm dev -``` +构建、打包与会触发构建的测试只在用户确认的 ces-control Runner 上执行。本地不执行 `pnpm dev`、`pnpm build`、`pnpm typecheck` 或打包测试。 当前服务器入口为HTTP,与参考项目一致使用 `AUTH_COOKIE_SECURE=false`;配置HTTPS后应在独立production.env设置 `AUTH_COOKIE_SECURE=true`。HTTP本身不加密动态码和会话传输。 @@ -87,7 +93,7 @@ AUTH_CONFIG_FILE=/private/tmp/tabularium-auth/config.json AUTH_COOKIE_SECURE=fal 公司明细、收入观测、原始证据和数据核查记录。所有页面与 `GET /api/research/ai-companies` 均使用文档站现有的登录认证。 -研究数据与登录状态**共用文档站现有的 SQLite 数据库文件**: +研究业务数据本轮暂不迁移,仍沿用版本化 JSON 导入机制。研究数据、文档与登录状态**共用文档站现有的 SQLite 数据库文件**: `AUTH_CONFIG_FILE` 同目录下的 `login-state.sqlite`。该文件名是历史名称,未创建第二个数据库。 `login_state` 保留登录限流与重放状态,新增的 `research_*` 表保存数据版本、分类、 公司、经营指标、证据与核查记录。沿用现有 `/auth` 持久挂载,数据库不会写入镜像或公开目录。 @@ -98,11 +104,16 @@ AUTH_CONFIG_FILE=/private/tmp/tabularium-auth/config.json AUTH_COOKIE_SECURE=fal 同一版本内容变化会拒绝导入,避免静默读取旧数据。回退代码时继续读取对应旧版本。 所有修改都经过工作分支和 PR,部署仍等待人工合并主线。 -当前资料包含 1,015 条公司/产品记录、10 类业务及待分类组、1,091 条证据记录和 183 条经营与技术观测。 -其中保留 86 家个案资料、913 条目录基础资料,并新增 16 个平台产品/业务样本;目录项不等于完成财务和技术核验。 +当前资料包含 1,021 条公司/产品记录、10 类业务及待分类组、1,201 条证据记录和 187 条经营与技术观测。 +其中保留 93 家个案资料、912 条目录基础资料,并新增 16 个平台产品/业务样本;目录项不等于完成财务和技术核验。 +Product Hunt两年档案各首批19张、合计38张发布/推广卡片以`candidateBatches`保存;公司成立年待核者不自动新增公司。其中18条仍待核成立年;排除依据区分正式成立字段与2025前已运营证据,精确注册日期未核者明确标注。候选元数据与其他研究记录同库持久化,覆盖报告逐条展示状态、缺项和公司关联。Kilo Code与KiloClaw两项映射一个已被收购的2025公司个案,采用规模、token吞吐及同名健康公司的收入不作为其ARR。 +机器之心首批4条公司线索以独立候选批次记录:直达正文未读,经公司公开主页、官方代码或发起方公告补核PrismShadow与Stargate;SSI用2024官方历史更新排除,Utopai仍待核。不是两年中文来源全量。 +TechCrunch栏目首批3条公司候选已追溯原始材料;Wabi据两个公开名录2025年份入表,精确注册日未核。官方条款称当前免费,收入仍未知,不填零。DreamHQ及Thrive Holdings成立年仍待核,后者2025公开发布公告另存。DreamHQ与名录同名摄像头公司区分,ADT拟收购尚待交割,融资、估值和目标公司业绩不当新AI公司的ARR。分类接口与RSS访问403及两年档案缺项保留。 补充ThinkWheel与中国NaiveAI;MiroMind成立年未核,公开研发年份单列。保留灵初智能成立年排除、深度原理日期待核和Naive近名实体复核记录。 -新增7条Speedrun个案,保留标题/正文冲突、七位数数量级、币种不明、合同管线与履历归属排除记录;相对日期不充当同期收入时点。核查页优先按人工确认的公司ID关联明细,旧版本保留按证据归属回退;54条核查中43条指向具体公司、3条属于全局方法、8条是未纳入候选。明细反向列出关联核查;币种不明的声明保留原值,不绘制美元收入长度条。 +新增7条Speedrun个案,保留标题/正文冲突、七位数数量级、币种不明、合同管线与履历归属排除记录;相对日期不充当同期收入时点。核查页优先按人工确认的公司ID关联明细,旧版本保留按证据归属回退;68条核查中52条指向具体公司、4条属于全局方法、12条是未纳入候选。明细反向列出关联核查;币种不明的声明保留原值,不绘制美元收入长度条。 +Lightfield按Magical Tome主体与2025前历史业务列为CRM转型对照,成立年未知,不计新成立公司;Fundraisly成立年待核,其客户融资和创始人历史ARR不转移为公司收入。 Bud保留为2025成立且YC标记已收购的案例;Rocket.new按前身2021成立、2025产品上线的转型对照记录,历史ARR不混入新成立公司同期统计。 +原候选36088 EveryApp从AI不明经补证入表,`directoryRechecks`保存后续复核,原1,428条台账及928条原始新增计数不改。OpenSEO与EveryApp不重复计公司;定价、星标、客户数和数据成本加价均不转为ARR。 验收按2026-10-30确认的公开来源清单边界逐条核对并标明缺项;YC候选池有完整读取台账,其他来源目前为定向补充,不能把来源出现次数当作覆盖完成。收入披露不足且统计时点不统一,不代表全部 AI 公司。 界面将公司自述、第三方估算、融资、客户收入和履历收入分开,未知收入不记为零。 保留被撤回的声明作为核查记录,并排除收入比较;并购后业务、管理层预测与历史里程碑单列。 @@ -121,9 +132,9 @@ MRR×12仅作为显示单位转换,不改写为公司ARR;公司ARR、估算 平台同步时间、连接过期状态、业务描述的可编辑性和产品/法律主体未去重限制均保留。 覆盖审计页位于 `/docs/other-research/ai-company-research/coverage`,分类链接 `overview?category=` 的筛选数据。 -`python3 scripts/audit-ai-research.py --check` 检查候选池逐条计数与覆盖报告一致性;CI在ces-control运行此检查,未更改触发范围。 +`python3 scripts/audit-ai-research.py --check` 检查候选池逐条计数与来源审计一致性;CI在ces-control运行此检查。 收入快照报告位于 `/docs/other-research/ai-company-research/income-snapshot`,样本名称链接 `overview?company=` 的公司明细。 -`python3 scripts/summarize-ai-income.py --check` 独立复核统计与报告一致性;不触发编译或构建。数据变更后先审查方法,再运行不带参数的命令更新固定快照。 +`python3 scripts/summarize-ai-income.py --check` 独立复核统计与参考快照一致性;不触发编译或构建。数据变更后先审查方法,再运行不带参数的命令生成 `.artifacts/reports` 下的候选正文,使用 docs 管理工具显式更新数据库;不再生成受 Git 管理的正文。 默认7日样本14个产品;90日窗口新增Gojiberry的8月历史点,营销样本的均值和CV随之明显变化。 这提示先核查时间与样本构成,不能将高离散度直接解释为造假。两个近名营养应用按不同App Store ID分别建档。 @@ -131,6 +142,7 @@ MRR×12仅作为显示单位转换,不改写为公司ARR;公司ARR、估算 逐条读取对应 YC 官方公开页面,以页面中的成立年份、团队字段和业务描述筛选。 962 条初筛匹配,其中 34 条与原表 URL 重合,实际新增 928 条;268 条年份不符或缺失、 192 条 AI 业务待确认、6 条成立年份晚于批次年份未纳入本批新增。 +`content/research/ai-companies.sources.json` 保留最初12项来源与后续补充渠道、用途、覆盖边界及下一步;覆盖报告逐渠道复算证据、公司及观测数量。背景报告、入口可读和候选池逐条读取分别标记,零计数不表示没有公司。 `content/research/ai-companies.coverage.json` 保留逐条筛选结果、来源与响应摘要, 数据快照中的 `coverage` 也随版本进入同一 SQLite 数据库。只覆盖该候选池,不能称全球全量。 @@ -143,7 +155,7 @@ MRR×12仅作为显示单位转换,不改写为公司ARR;公司ARR、估算 仍有3条因简介不足保留待分类。人工复核分类不代表独立验证技术水平、客户效果或财务数据。 AI 关键词可能出现在业务背景中,收入、买家和交付状态仍须逐家深查,不能把目录项的未知收入当作未披露。 技术专项评审用独立字段记录层级、日期、摘要与证据:未评审、已读技术自述、公开代码/模型材料可访问、有第三方独立评测记录。 -当前7条完成该字段的专项记录,其中3条确认材料可访问、1条记录第三方已发表评测(Gradium TTS Sep 2026);本轮未自行运行模型测试。缺字段不表示无技术能力,代码或模型卡可读不证明性能。 +当前13条完成该字段的专项记录,其中6条确认材料可访问、1条记录第三方已发表评测(Gradium TTS Sep 2026);本轮未自行运行模型测试。缺字段不表示无技术能力,代码或模型卡可读不证明性能。 界面支持按资料深度、记录团队规模和技术验证证据筛选;证据列表每页 30 条,避免一次渲染全部来源。 `pnpm test:research` 在远程 Runner 上验证同库持久化、重复读取、版本导入与回退、 @@ -152,17 +164,18 @@ AI 关键词可能出现在业务背景中,收入、买家和交付状态仍 ## CI/CD 与缓存 -`.gitea/workflows/ci-deploy.yml` 使用已有 ces-control Runner,不修改共享 Runner 配置。 -当前工作流仅在 main 推送或针对 main 的手动运行时执行。工作分支PR完成本地静态核查后, -完整类型、测试、构建与容器检查在用户手动合并后的主线CI运行;报告须区分未运行与已通过。 +文档内容已退出构建输入。若需对照研究数据检查数据库中的报告正文,可先导出当前文档,再给研究脚本传入 `--document-export`;默认 `--check` 只核对研究数据本身的统计和审计不变量,避免把历史迁移快照继续当作日常正文源。 -1. 对实际构建输入计算 SHA-256:前端源码、Markdown、图片、Python 制图源码、依赖锁文件、Dockerfile 和构建脚本。 +`.gitea/workflows/ci-deploy.yml` 使用已有 ces-control Runner,不修改共享 Runner 配置。 +工作流仅在 main 推送或针对 main 的手动运行时执行,工作分支与 PR 不触发 CI。确认 ces-control 是构建位置,不构成新增分支 CI 的授权;报告须区分轻量检查与主线 CI。 + +1. 对实际构建输入计算 SHA-256:前端源码、图片、Python 制图源码、研究业务快照、依赖锁文件、Dockerfile 和构建脚本。 2. 命中 `verified--<摘要>` 镜像时跳过依赖安装和 Next.js build,只做认证、实际路由与资源冒烟检查。 3. 输入变化时运行 lint、类型检查、制图一致性检查与构建。pnpm store、uv 和 `.next/cache` 使用持久 Docker 卷,main 与每个 PR 独立;额外保留 Next.js SWC 下载缓存。 4. 生产镜像只包含 Next.js standalone 产物;Python 留在检查容器,不进入运行镜像。镜像通过动态码认证、重放/限流、重启持久化、健康、全部文档、JS/CSS、SVG 和 404 检查后才标记为 verified。 5. 部署前检查是否仍为最新 main,使用 `--no-build` 启动,失败恢复上一版镜像。 -根 README、工作流或部署配置等非构建输入变化不会触发 Next.js 重建;Markdown 正文变化会更新静态页面,仍需要一次增量构建。首次构建必须安装依赖并完整生成产物。缓存可能因基础镜像、工具链或锁文件变化而失效;不要清理共享 Docker 缓存或卷。 +根 README、工作流或部署配置等非构建输入变化不会触发 Next.js 重建;数据库中的正文和目录变化直接通过请求读取,不触发任何构建。首次构建必须安装依赖并完整生成产物。缓存可能因基础镜像、工具链或锁文件变化而失效;不要清理共享 Docker 缓存或卷。 默认入口为服务器的 **18135** 端口。容器监听 3000,使用独立 Compose 项目 `endorphin-tabularium-docs`。部署前先检查端口是否空闲(或属于本服务),不会停止其他服务以抢占端口。服务器配置位于 `/etc/endorphin-tabularium-docs/production.env`: diff --git a/apps/docs/package.json b/apps/docs/package.json index 2330f52..fa6fcea 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -11,7 +11,8 @@ "lint": "eslint . --max-warnings=0", "typecheck": "NEXT_TELEMETRY_DISABLED=1 next typegen && tsc --noEmit", "test:auth": "node scripts/test-auth.mjs", - "test:research": "node scripts/test-research.mjs" + "test:research": "node scripts/test-research.mjs", + "test:docs": "node scripts/test-docs.mjs" }, "dependencies": { "next": "16.4.3", diff --git a/apps/docs/scripts/auth-cli.ts b/apps/docs/scripts/auth-cli.ts index 4224810..ff38bf8 100644 --- a/apps/docs/scripts/auth-cli.ts +++ b/apps/docs/scripts/auth-cli.ts @@ -18,6 +18,7 @@ import { totp, isLoginRedirect, } from "../src/lib/auth"; +import { readDocument, saveDocument } from "../src/lib/docs"; import { consumeCode, initializeStore } from "../src/lib/auth-store"; async function main() { @@ -132,11 +133,6 @@ async function main() { join(__dirname, ".next/BUILD_ID"), "utf8", ).trim(); - const routes = Object.keys( - JSON.parse( - readFileSync(join(__dirname, ".next/prerender-manifest.json"), "utf8"), - ).routes, - ) as string[]; for (const path of [ "/", "/docs/00-endor-concepts-draft", @@ -160,7 +156,7 @@ async function main() { path, ); } - for (const path of ["/api/private", "/api/unknown", "/api/search", "/api/research/ai-companies"]) + for (const path of ["/api/private", "/api/unknown", "/api/search", "/api/documents/source?slug=endor/endor-purpose-draft", "/api/research/ai-companies"]) assert.equal((await request(path)).status, 401, path); assert.equal( ( @@ -185,10 +181,10 @@ async function main() { assert.equal(researchResponse.status, 200, "authenticated research API"); assert.ok(researchResponse.headers.get("cache-control")?.includes("no-store")); const research = await researchResponse.json(); - assert.equal(research.companies.length, 1015); + assert.equal(research.companies.length, 1021); assert.equal(research.categories.length, 11); - assert.equal(research.evidence.length, 1091); - assert.equal(research.observations.length, 183); + assert.equal(research.evidence.length, 1201); + assert.equal(research.observations.length, 187); assert.equal((await request("/api/research/ai-companies", { method: "POST", headers })).status, 405); const researchPage = await request("/docs/other-research/ai-company-research/overview", { headers }); assert.equal(researchPage.status, 200); @@ -209,6 +205,33 @@ async function main() { assert.equal(destination.searchParams.get("from"), "legacy"); assert.ok(legacy.headers.get("cache-control")?.includes("no-store")); } + const routes = ["/", ...searchIndex.map((doc: { slug: string }) => `/docs/${doc.slug}`)]; + const raw = await request("/api/documents/source?slug=endor/endor-purpose-draft", { headers }); + assert.equal(raw.status, 200); + assert.ok((await raw.text()).startsWith("# Endor")); + if (process.env.DOCUMENT_SMOKE_TEST) { + const sourceSlug = "ci-live-document"; + const previous = readDocument(sourceSlug); + if (command === "exercise") { + const first = saveDocument({ slug: "endor/ci-live-document", sourceSlug, + title: "Runtime document", category: "Endor", collection: "Endor 文档", group: "CI", + description: "Isolated smoke test", draft: true, featured: false, + content: "# Runtime document\n\nInitial body" }, previous?.revision ?? 0); + assert.equal((await request(`/docs/${first.slug}`, { headers })).status, 200); + saveDocument({ ...first, slug: "endor/ci-live-document-renamed", content: "# Runtime document\n\nUpdated without rebuilding" }, first.revision); + } + const stored = readDocument(sourceSlug); + assert.ok(stored, "Smoke document must exist after creation or restart"); + assert.ok(stored.content.includes("Updated without rebuilding"), "DB content survives restart"); + const live = await request(`/docs/${stored.slug}`, { headers }); + assert.equal(live.status, 200); + assert.ok((await live.text()).includes("Updated without rebuilding")); + const alias = await request("/docs/endor/ci-live-document?live=1", { headers }); + assert.equal(alias.status, 308); + assert.ok(alias.headers.get("location")?.includes("ci-live-document-renamed?live=1")); + const updatedIndex = await (await request("/api/search", { headers })).json(); + assert.ok(updatedIndex.some((doc: { searchText: string }) => doc.searchText.includes("Updated without rebuilding"))); + } let home = ""; let purpose = ""; for (const route of routes) { diff --git a/apps/docs/scripts/build-auth.mjs b/apps/docs/scripts/build-auth.mjs index b28a834..b648e0d 100644 --- a/apps/docs/scripts/build-auth.mjs +++ b/apps/docs/scripts/build-auth.mjs @@ -7,3 +7,8 @@ await build({ format: "cjs", outfile: ".next/standalone/apps/docs/auth-cli.cjs", }); + +await build({ + entryPoints: ["scripts/docs-cli.ts"], bundle: true, platform: "node", target: "node22", + format: "cjs", outfile: ".next/standalone/apps/docs/docs-cli.cjs", +}); diff --git a/apps/docs/scripts/build-doc-images.mjs b/apps/docs/scripts/build-doc-images.mjs index 8989bf7..cdedcca 100644 --- a/apps/docs/scripts/build-doc-images.mjs +++ b/apps/docs/scripts/build-doc-images.mjs @@ -23,19 +23,6 @@ for (const name of await files(path.join(root, "apps/docs/public"))) { throw new Error(`Original raster exposed in public: ${path.relative(root, name)}. Move it to tools/figures/assets and run images.`); } } -for (const name of await files(path.join(root, "content/docs"))) { - if (!name.endsWith(".md")) continue; - const content = await readFile(name, "utf8"); - const images = [ - ...content.matchAll(/!\[[^\]]*\]\(]+)/g), - ...content.matchAll(/]*\bsrc=["']([^"']+)/gi), - ]; - for (const [, source] of images) { - if (/\.(?:png|jpe?g|tiff?|bmp)(?:[?#]|$)/i.test(source)) { - throw new Error(`${path.relative(root, name)} embeds an original raster: ${source}. Use the compressed WebP.`); - } - } -} await mkdir(output, { recursive: true }); for (const source of await files(sources)) { diff --git a/apps/docs/scripts/docs-cli.ts b/apps/docs/scripts/docs-cli.ts new file mode 100644 index 0000000..a3f6f50 --- /dev/null +++ b/apps/docs/scripts/docs-cli.ts @@ -0,0 +1,44 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { openSiteDatabase } from "../src/lib/site-database"; +import { allDocuments, readDocument, saveDocument, importDocuments, documentHistory } from "../src/lib/docs"; + +process.umask(0o077); +const [command, argument] = process.argv.slice(2); +try { + switch (command) { + case "backup": { + if (!argument) throw Error("Provide a private backup filename"); + const db = openSiteDatabase(); + try { db.prepare("VACUUM INTO ?").run(argument); } finally { db.close(); } + console.log("Full site database backup created; existing files are never overwritten"); break; + } + case "list": + console.log(JSON.stringify(allDocuments().map(({ content: _, ...doc }) => { void _; return doc; }), null, 2)); break; + case "get": { + const doc = readDocument(argument ?? ""); + if (!doc) throw Error("Document not found"); + console.log(JSON.stringify(doc, null, 2)); break; + } + case "save": { + const { revision, ...input } = JSON.parse(readFileSync(0, "utf8")); + const doc = saveDocument(input, revision); + console.log(JSON.stringify({ slug: doc.slug, revision: doc.revision })); break; + } + case "history": + console.log(JSON.stringify(documentHistory(argument ?? ""), null, 2)); break; + case "import": { + const bundle = JSON.parse(readFileSync(0, "utf8")); + console.log(JSON.stringify({ imported: importDocuments(bundle, argument === "--if-empty") })); break; + } + case "export": { + if (!argument) throw Error("Provide a private export filename"); + writeFileSync(argument, JSON.stringify({ format: "tabularium-documents-v1", documents: allDocuments() }), + { flag: "wx", mode: 0o600 }); + console.log("Document export created; existing files are never overwritten"); break; + } + default: throw Error("Usage: docs backup | list | get | save < input.json | history | import [--if-empty] < export.json | export "); + } +} catch (error) { + console.error(error instanceof Error ? error.message : "Document operation failed"); + process.exitCode = 1; +} diff --git a/apps/docs/scripts/test-docs.mjs b/apps/docs/scripts/test-docs.mjs new file mode 100644 index 0000000..339d95b --- /dev/null +++ b/apps/docs/scripts/test-docs.mjs @@ -0,0 +1,14 @@ +import { build } from "esbuild"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; + +const dir = await mkdtemp(join(tmpdir(), "tabularium-docs-tests-")); +try { + const file = join(dir, "docs.test.cjs"); + await build({ entryPoints: ["tests/docs.test.ts"], bundle: true, + platform: "node", target: "node22", format: "cjs", outfile: file }); + const result = spawnSync(process.execPath, ["--test", file], { stdio: "inherit" }); + process.exitCode = result.status ?? 1; +} finally { await rm(dir, { recursive: true, force: true }); } diff --git a/apps/docs/src/components/IncomeDistribution.tsx b/apps/docs/src/components/IncomeDistribution.tsx index 611b3d0..cd2cc64 100644 --- a/apps/docs/src/components/IncomeDistribution.tsx +++ b/apps/docs/src/components/IncomeDistribution.tsx @@ -20,6 +20,9 @@ export default function IncomeDistribution({ data, onSelect }: { }), [data, dateWindow, year, annualize]); const groups = useMemo(() => groupIncome(sample.points, splitModels).sort((a, b) => a.category.localeCompare(b.category) || a.provider.localeCompare(b.provider) || a.model.localeCompare(b.model)), [sample.points, splitModels]); + const categoryGroups = [...new Set(groups.map((group) => group.category))].map((category) => ({ + category, rows: groups.filter((group) => group.category === category), + })); const categories = new Map(data.categories.map((category) => [category.id, category.name])); const metric = annualize ? "MRR×12参考年化" : "平台MRR"; const values = sample.points.map((point) => point.value); @@ -41,6 +44,7 @@ export default function IncomeDistribution({ data, onSelect }: {
+

{splitModels ? "分组:业务类别 × 支付平台 × 收费与交付模式。同一类别下的多行是不同统计组,各自计算均值和中位数。" : "分组:业务类别 × 支付平台。同一平台内的收费模式已合并,不同支付平台仍分别统计。"}

资料截至 {data.asOf},纳入 {sample.points.length} 个产品,另有 {sample.excluded.length} 个 MRR 观测未纳入。{annualize ? "乘12只改变显示单位,不证明全年收入、续约或ARR;CV与相对敏感性不变。" : "MRR不等于近30日收款;平台数据连接也不等于财务审计。"}{dateWindow === "all" && " 当前包含不同时间的历史观测,不能称为同期对照。"}

{sample.points.length ? <>
@@ -48,9 +52,16 @@ export default function IncomeDistribution({ data, onSelect }: { {ticks.map((tick) => {usd(tick)})} {groups.map((group, index) => { const y = index * 70 + 40; - return - {categories.get(group.category)} - {group.provider} · {group.model} · n={group.stats.n} + const firstInCategory = index === 0 || groups[index - 1].category !== group.category; + const categoryRowCount = categoryGroups.find((entry) => entry.category === group.category)!.rows.length; + return + {firstInCategory && <> + {index > 0 && } + {categories.get(group.category)}({categoryRowCount}组) + {categoryRowCount > 1 && } + } + {group.provider} · {group.model} · n={group.stats.n} + {group.stats.n < 3 && {group.stats.n === 1 ? "仅1个产品,无法判断组内离散度" : "仅2个产品,不代表领域典型收入"}} 样本均值 {usd(group.stats.mean)} 样本中位数 {usd(group.stats.median)} @@ -62,12 +73,13 @@ export default function IncomeDistribution({ data, onSelect }: { ; })} -
圆点:产品观测 竖线:样本均值 菱形:样本中位数。按业务类别与支付来源分组,点击点或下表产品名可看原始证据。
+
圆点:产品观测 竖线:样本均值 菱形:样本中位数。业务类别标题下各行按支付平台{splitModels && "及收费与交付模式"}分别统计;n为该组产品数。点击点或下表产品名可看原始证据。
-
{groups.map((group) => - +
{metric}样本统计;金额按显示值计算,CV使用样本标准差
类别 / 来源 / 模式n均值中位数均值/中位数CV去除最大值后的均值 / 下降幅度产品与限制
{categories.get(group.category)}{group.provider} · {group.model}{group.stats.n}{usd(group.stats.mean)}{usd(group.stats.median)}{group.stats.meanMedianRatio?.toFixed(2) ?? "—"}{group.stats.cv?.toFixed(2) ?? "—"}{usd(group.stats.meanWithoutLargest)}{percent(group.stats.largestRemovalDrop)}
{categoryGroups.map(({ category, rows }) => {rows.map((group, index) => + {index === 0 && } + - )}
{metric}样本统计;每行是类别下的一个统计组,CV使用样本标准差
业务类别支付平台 / 收费模式n均值中位数均值/中位数CV去除最大值后的均值 / 下降幅度产品与限制
{categories.get(category)}{rows.length}个统计组{group.provider}{group.model}{group.stats.n}{usd(group.stats.mean)}{usd(group.stats.median)}{group.stats.meanMedianRatio?.toFixed(2) ?? "—"}{group.stats.cv?.toFixed(2) ?? "—"}{usd(group.stats.meanWithoutLargest)}{percent(group.stats.largestRemovalDrop)} {group.points.map((point) => {" "})}{group.stats.n < 3 ? "样本少于3,不能据此判断领域典型收入。" : "自选的小样本,须结合产品年龄、成本与留存评估。"}
+ )})} :

当前窗口和创立年份没有符合条件的 MRR 记录。

}
统计规则与解释

每个产品只取符合条件的最新一条平台 MRR;币种为美元,要求有效数据日期、确定数值及支付来源。公司 ARR、估算、融资、目标和近30日收入均不进入这里。未知值不补零。

CV=样本标准差÷均值,标准差分母为 n−1;n=1 不算 CV。n=2 时均值必然等于中位数,不能据此认为均值有代表性。较大的均值/中位数差距、CV或移除最大值的变化,仅提示进一步核查样本构成。

支付商与平台的退款、试用、年付、币种及指标计算可能不同(平台方法说明)。不能将 MRR 与收款差异、单个高值或高离散度直接解释为财务造假。比较商业模式还需要毛利、获客成本、留存、人工交付和客户集中度。

{sample.excluded.length > 0 &&
未纳入的 MRR 记录({sample.excluded.length})
    {sample.excluded.map(({ observation, reason }) =>
  • :{usd(observation.value)} / 月,{observation.observedAt ?? "日期未知"};{reason}
  • )}
} diff --git a/apps/docs/src/lib/ai-research.ts b/apps/docs/src/lib/ai-research.ts index 2a39825..22f5744 100644 --- a/apps/docs/src/lib/ai-research.ts +++ b/apps/docs/src/lib/ai-research.ts @@ -43,11 +43,24 @@ export type ResearchCoverage = { otherOrMissingYear: number; unclearAI: number; dateConflicts: number; failedProfiles: number; discoverySha256: string; }; +export type ResearchCandidateBatch = { + id: string; sourceId: string; url: string; accessedAt: string; scope: string; complete: boolean; + records: { + id: string; name: string; url: string | null; categoryId: string; + kind: "product" | "company" | "promotion"; profileStatus: "readable" | "unavailable" | "archive-only"; + status: "pending-year" | "linked" | "existing-outside-cohort" | "outside-cohort" | "promotion"; + companyId?: string; evidenceIds?: string[]; note: string; + }[]; +}; export type ResearchSnapshot = { version: string; asOf: string; scope: string; limitations: string[]; categories: ResearchCategory[]; companies: ResearchCompany[]; observations: ResearchObservation[]; evidence: ResearchEvidence[]; - reviews: ResearchReview[]; coverage?: ResearchCoverage; + reviews: ResearchReview[]; coverage?: ResearchCoverage; candidateBatches?: ResearchCandidateBatch[]; + directoryRechecks?: { + ycId: number; originalStatus: "ai-unclear" | "year-outside-or-missing" | "date-conflict"; + companyId: string; reviewedAt: string; evidenceIds: string[]; reason: string; + }[]; }; export function evidenceUrl(value: string): string | undefined { @@ -98,7 +111,40 @@ export function validateResearch(snapshot: ResearchSnapshot) { !Number.isFinite(o.value)) throw new Error(`Invalid research observation: ${o.id}`); } + unique((snapshot.candidateBatches ?? []).map((b) => b.id), "candidate batches"); + unique((snapshot.candidateBatches ?? []).flatMap((b) => b.records.map((r) => r.id)), "candidates"); + for (const batch of snapshot.candidateBatches ?? []) { + if (!/^S\d{2}$/.test(batch.sourceId) || !evidenceUrl(batch.url) || !batch.scope.trim() || + typeof batch.complete !== "boolean" || !/^\d{4}-\d{2}-\d{2}$/.test(batch.accessedAt) || + Number.isNaN(Date.parse(batch.accessedAt)) || + new Date(batch.accessedAt).toISOString().slice(0, 10) !== batch.accessedAt || + batch.accessedAt > snapshot.asOf) + throw new Error(`Invalid research candidate batch: ${batch.id}`); + for (const row of batch.records) { + const linked = ["linked", "existing-outside-cohort"].includes(row.status); + if (!row.name.trim() || !row.note.trim() || !categories.has(row.categoryId) || + !["product", "company", "promotion"].includes(row.kind) || + !["readable", "unavailable", "archive-only"].includes(row.profileStatus) || + !["pending-year", "linked", "existing-outside-cohort", "outside-cohort", "promotion"].includes(row.status) || + (row.kind !== "promotion" && (!row.url || !evidenceUrl(row.url))) || + (row.url !== null && !evidenceUrl(row.url)) || + linked !== !!row.companyId || (row.companyId && !companies.has(row.companyId)) || + (row.kind === "promotion") !== (row.status === "promotion") || + (row.evidenceIds ?? []).some((id) => !evidence.has(id)) || + (row.status === "outside-cohort" && !row.evidenceIds?.length)) + throw new Error(`Invalid research candidate: ${row.id}`); + } + } const coverage = snapshot.coverage; + unique((snapshot.directoryRechecks ?? []).map((row) => String(row.ycId)), "directory rechecks"); + for (const row of snapshot.directoryRechecks ?? []) { + if (!Number.isSafeInteger(row.ycId) || row.ycId <= 0 || !companies.has(row.companyId) || + !["ai-unclear", "year-outside-or-missing", "date-conflict"].includes(row.originalStatus) || + !/^\d{4}-\d{2}-\d{2}$/.test(row.reviewedAt) || Number.isNaN(Date.parse(row.reviewedAt)) || + new Date(row.reviewedAt).toISOString().slice(0, 10) !== row.reviewedAt || row.reviewedAt > snapshot.asOf || + !row.reason.trim() || !row.evidenceIds.length || row.evidenceIds.some((id) => !evidence.has(id))) + throw new Error(`Invalid directory recheck: ${row.ycId}`); + } if (coverage && (coverage.profilesRead + coverage.failedProfiles !== coverage.candidateRecords || coverage.matchingProfiles !== coverage.existingProfiles + coverage.newProfiles || coverage.matchingProfiles + coverage.otherOrMissingYear + coverage.unclearAI + coverage.dateConflicts !== coverage.profilesRead || diff --git a/apps/docs/src/lib/docs.ts b/apps/docs/src/lib/docs.ts index dbdbc27..7f14bcb 100644 --- a/apps/docs/src/lib/docs.ts +++ b/apps/docs/src/lib/docs.ts @@ -1,50 +1,147 @@ -import fs from "node:fs/promises"; -import path from "node:path"; -import catalog from "../../../../content/catalog.json"; +import { randomUUID, createHash } from "node:crypto"; +import { openSiteDatabase } from "./site-database"; -const directory = path.resolve(process.cwd(), "../../content/docs"); -const validSlug = /^[a-z0-9]+(?:-[a-z0-9]+)*(?:\/[a-z0-9]+(?:-[a-z0-9]+)*)*$/; -type Metadata = { slug?: string; category?: string; collection?: string; group?: string; description?: string; draft?: boolean; featured?: boolean }; export type DocLink = { slug: string; sourceSlug: string; category: string; title: string; searchText?: string; group: string; collection: string; description: string; draft: boolean; featured: boolean; + revision: number; }; export type Doc = DocLink & { content: string }; +export type DocInput = Omit; +export type DocumentExport = { format: "tabularium-documents-v1"; documents: Doc[] }; +export const validSlug = /^[a-z0-9]+(?:-[a-z0-9]+)*(?:\/[a-z0-9]+(?:-[a-z0-9]+)*)*$/; + +function database() { + const db = openSiteDatabase(); + db.exec(` + CREATE TABLE IF NOT EXISTS documents ( + id TEXT PRIMARY KEY, slug TEXT NOT NULL UNIQUE, source_slug TEXT NOT NULL UNIQUE, + revision INTEGER NOT NULL, payload TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS document_aliases ( + alias TEXT PRIMARY KEY, document_id TEXT NOT NULL REFERENCES documents(id) + ); + CREATE TABLE IF NOT EXISTS document_revisions ( + document_id TEXT NOT NULL REFERENCES documents(id), revision INTEGER NOT NULL, + payload TEXT NOT NULL, created_at TEXT NOT NULL, PRIMARY KEY(document_id, revision) + ); + CREATE TABLE IF NOT EXISTS document_imports ( + digest TEXT PRIMARY KEY, document_count INTEGER NOT NULL, created_at TEXT NOT NULL + ); + `); + return db; +} + +export function validateDocument(input: DocInput): void { + if (!input || typeof input !== "object") throw Error("Invalid document"); + for (const key of ["slug", "sourceSlug"] as const) + if (typeof input[key] !== "string" || !validSlug.test(input[key])) throw Error(`Invalid ${key}`); + if (input.slug.split("/").some((part) => /^\d+-/.test(part))) throw Error("Use a semantic public slug"); + for (const key of ["title", "category", "collection", "group"] as const) + if (typeof input[key] !== "string" || !input[key].trim()) throw Error(`Missing ${key}`); + if (typeof input.content !== "string" || typeof input.description !== "string" || + typeof input.draft !== "boolean" || typeof input.featured !== "boolean") throw Error("Invalid document fields"); + if (input.content.length > 2_000_000) throw Error("Document exceeds content limit"); + const images = [ + ...input.content.matchAll(/!\[[^\]]*\]\(]+)/g), + ...input.content.matchAll(/]*\bsrc=["']([^"']+)/gi), + ]; + if (images.some(([, source]) => /\.(?:png|jpe?g|tiff?|bmp)(?:[?#]|$)/i.test(source))) + throw Error("Use compressed WebP images instead of original rasters"); +} + +function write(db: ReturnType, input: DocInput, expectedRevision: number): Doc { + validateDocument(input); + const existing = db.prepare("SELECT id, revision FROM documents WHERE source_slug=?") + .get(input.sourceSlug) as { id: string; revision: number } | undefined; + if ((existing?.revision ?? 0) !== expectedRevision) throw Error("Document revision conflict"); + const id = existing?.id ?? randomUUID(); + const doc: Doc = { slug: input.slug, sourceSlug: input.sourceSlug, category: input.category, + title: input.title, group: input.group, collection: input.collection, description: input.description, + draft: input.draft, featured: input.featured, content: input.content, revision: expectedRevision + 1 }; + const payload = JSON.stringify(doc); + // Aliases remain reserved after renaming; collision rolls back the whole write. + db.prepare(`INSERT INTO documents VALUES (?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET + slug=excluded.slug, revision=excluded.revision, payload=excluded.payload`) + .run(id, doc.slug, doc.sourceSlug, doc.revision, payload); + for (const alias of new Set([doc.slug, doc.sourceSlug])) { + const prior = db.prepare("SELECT document_id FROM document_aliases WHERE alias=?") + .get(alias) as { document_id: string } | undefined; + if (prior && prior.document_id !== id) throw Error(`Document alias collision: ${alias}`); + db.prepare("INSERT OR IGNORE INTO document_aliases VALUES (?,?)").run(alias, id); + } + db.prepare("INSERT INTO document_revisions VALUES (?,?,?,?)") + .run(id, doc.revision, payload, new Date().toISOString()); + return doc; +} + +export function saveDocument(input: DocInput, expectedRevision: number): Doc { + if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) throw Error("Provide expected revision"); + const db = database(); + try { + db.exec("BEGIN IMMEDIATE"); + try { const doc = write(db, input, expectedRevision); db.exec("COMMIT"); return doc; } + catch (error) { db.exec("ROLLBACK"); throw error; } + } finally { db.close(); } +} + +export function readDocument(slug: string): Doc | null { + if (!validSlug.test(slug)) return null; + const db = database(); + try { + const row = db.prepare(`SELECT d.payload FROM documents d JOIN document_aliases a + ON a.document_id=d.id WHERE a.alias=?`).get(slug) as { payload: string } | undefined; + return row ? JSON.parse(row.payload) as Doc : null; + } finally { db.close(); } +} + +export function allDocuments(): Doc[] { + const db = database(); + try { + return (db.prepare("SELECT payload FROM documents ORDER BY source_slug").all() as { payload: string }[]) + .map((row) => JSON.parse(row.payload) as Doc); + } finally { db.close(); } +} export async function readDoc(slug: string): Promise { - if (!validSlug.test(slug)) throw new Error("Invalid document slug"); - const sourceSlug = Object.entries(catalog.documents).find(([, metadata]) => metadata.slug === slug)?.[0] ?? slug; - const content = await fs.readFile(path.join(directory, `${sourceSlug}.md`), "utf8"); - const title = content.match(/^# (.+)$/m)?.[1]?.trim() ?? slug; - const collection = catalog.collections.find((c) => !c.path || sourceSlug.startsWith(`${c.path}/`))!; - const metadata: Metadata = (catalog.documents as Record)[sourceSlug] ?? {}; - if (!metadata.slug || !metadata.category || !validSlug.test(metadata.slug) || metadata.slug.split("/").some((part) => /^\d+-/.test(part))) { - throw new Error(`Register a semantic slug and category in content/catalog.json: ${sourceSlug}`); - } - return { slug: metadata.slug, sourceSlug, category: metadata.category, title, content, searchText: content, - group: metadata.group ?? collection.title, collection: metadata.collection ?? collection.title, - description: metadata.description ?? collection.description, - draft: metadata.draft ?? false, featured: metadata.featured ?? false, - }; + const doc = readDocument(slug); + if (!doc) throw Error("Document not found"); + return doc; } export async function listDocs(): Promise { - async function walk(folder: string): Promise { - const entries = await fs.readdir(path.join(directory, folder), { withFileTypes: true }); - const files = await Promise.all(entries.map(async (entry) => { - const relative = folder ? `${folder}/${entry.name}` : entry.name; - if (entry.isDirectory()) return walk(relative); - if (entry.isFile() && relative.endsWith(".md") && validSlug.test(relative.slice(0, -3))) - return [relative.slice(0, -3)]; - return []; - })); - return files.flat(); - } - const docs = await Promise.all((await walk("")).sort().map(async (slug) => { - const { content: _, searchText: __, ...doc } = await readDoc(slug); - void _; void __; - return doc; - })); - if (new Set(docs.map((doc) => doc.slug)).size !== docs.length) throw new Error("Duplicate public document slug"); - return docs; + return allDocuments().map(({ content: _, ...doc }) => { void _; return doc; }); +} + +// Explicit, atomic bootstrap only. Runtime reads never import repository data. +export function importDocuments(bundle: DocumentExport, onlyIfEmpty = false): number { + if (bundle?.format !== "tabularium-documents-v1" || !Array.isArray(bundle.documents) || !bundle.documents.length) + throw Error("Invalid or empty document export"); + bundle.documents.forEach(validateDocument); + const digest = createHash("sha256").update(JSON.stringify(bundle)).digest("hex"); + const db = database(); + try { + db.exec("BEGIN IMMEDIATE"); + try { + const imported = db.prepare("SELECT digest FROM document_imports WHERE digest=?").get(digest); + if (imported) { db.exec("COMMIT"); return 0; } + const count = (db.prepare("SELECT COUNT(*) AS n FROM documents").get() as { n: number }).n; + if (count) { + if (!onlyIfEmpty) throw Error("Bootstrap requires an empty document database"); + db.exec("COMMIT"); return 0; + } + for (const doc of bundle.documents) write(db, doc, 0); + db.prepare("INSERT INTO document_imports VALUES (?,?,?)").run(digest, bundle.documents.length, new Date().toISOString()); + db.exec("COMMIT"); return bundle.documents.length; + } catch (error) { db.exec("ROLLBACK"); throw error; } + } finally { db.close(); } +} + +export function documentHistory(sourceSlug: string): Doc[] { + const db = database(); + try { + return (db.prepare(`SELECT r.payload FROM document_revisions r JOIN documents d + ON r.document_id=d.id WHERE d.source_slug=? ORDER BY r.revision`).all(sourceSlug) as { payload: string }[]) + .map((row) => JSON.parse(row.payload) as Doc); + } finally { db.close(); } } diff --git a/apps/docs/src/pages/api/documents/source.ts b/apps/docs/src/pages/api/documents/source.ts new file mode 100644 index 0000000..3ca3a80 --- /dev/null +++ b/apps/docs/src/pages/api/documents/source.ts @@ -0,0 +1,22 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { readDocument } from "@/lib/docs"; + +// The proxy protects Markdown just like the rendered document and search index. +export default function handler(request: NextApiRequest, response: NextApiResponse) { + response.setHeader("Cache-Control", "private, no-store"); + if (request.method !== "GET") { + response.setHeader("Allow", "GET"); + return response.status(405).end(); + } + const slug = request.query.slug; + if (typeof slug !== "string") return response.status(400).json({ error: "缺少文档地址" }); + try { + const doc = readDocument(slug); + if (!doc) return response.status(404).end(); + response.setHeader("Content-Type", "text/plain; charset=utf-8"); + response.setHeader("X-Content-Type-Options", "nosniff"); + return response.status(200).send(doc.content); + } catch { + return response.status(503).json({ error: "文档数据库暂时不可用" }); + } +} diff --git a/apps/docs/src/pages/api/search.ts b/apps/docs/src/pages/api/search.ts index e531dde..0dccf33 100644 --- a/apps/docs/src/pages/api/search.ts +++ b/apps/docs/src/pages/api/search.ts @@ -1,5 +1,5 @@ import type { NextApiRequest, NextApiResponse } from "next"; -import { listDocs, readDoc } from "@/lib/docs"; +import { allDocuments } from "@/lib/docs"; // The proxy authenticates this endpoint, including index content. export default async function handler(request: NextApiRequest, response: NextApiResponse) { @@ -8,9 +8,10 @@ export default async function handler(request: NextApiRequest, response: NextApi response.setHeader("Allow", "GET"); return response.status(405).end(); } - const docs = await listDocs(); - const index = await Promise.all(docs.map(async (doc) => ({ - ...doc, searchText: (await readDoc(doc.slug)).content, - }))); - response.status(200).json(index); + try { + const index = allDocuments().map(({ content, ...doc }) => ({ ...doc, searchText: content })); + return response.status(200).json(index); + } catch { + return response.status(503).json({ error: "文档数据库暂时不可用" }); + } } diff --git a/apps/docs/src/pages/docs/[...slug].tsx b/apps/docs/src/pages/docs/[...slug].tsx index dfb7efc..b3a91f7 100644 --- a/apps/docs/src/pages/docs/[...slug].tsx +++ b/apps/docs/src/pages/docs/[...slug].tsx @@ -1,7 +1,6 @@ import type { - GetStaticPaths, - GetStaticProps, - InferGetStaticPropsType, + GetServerSideProps, + InferGetServerSidePropsType, } from "next"; import Head from "next/head"; import dynamic from "next/dynamic"; @@ -15,7 +14,7 @@ import { } from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; -import { listDocs, readDoc, type Doc, type DocLink } from "@/lib/docs"; +import { listDocs, readDocument, type Doc, type DocLink } from "@/lib/docs"; import { Shell, Navigation } from "@/components/Shell"; import { ArchitectureFigure } from "@/components/ArchitectureFigure"; import { FigureGallery } from "@/components/FigureGallery"; @@ -26,16 +25,17 @@ import { headingsFor } from "@/lib/navigation"; type Props = { doc: Doc; docs: DocLink[] }; const AICompanyResearch = dynamic(() => import("@/components/AICompanyResearch")); -export const getStaticPaths: GetStaticPaths = async () => ({ - paths: (await listDocs()).map(({ slug }) => ({ params: { slug: slug.split("/") } })), - fallback: false, -}); -export const getStaticProps: GetStaticProps = async ({ params }) => { +export const getServerSideProps: GetServerSideProps = async ({ params, res, resolvedUrl }) => { + res.setHeader("Cache-Control", "private, no-store"); const slug = Array.isArray(params?.slug) ? params.slug.join("/") : params?.slug; if (typeof slug !== "string") return { notFound: true }; - const docs = await listDocs(); - if (!docs.some((d) => d.slug === slug)) return { notFound: true }; - return { props: { doc: await readDoc(slug), docs } }; + const doc = readDocument(slug); + if (!doc) return { notFound: true }; + if (slug !== doc.slug) { + const query = resolvedUrl.includes("?") ? resolvedUrl.slice(resolvedUrl.indexOf("?")) : ""; + return { redirect: { destination: `/docs/${doc.slug}${query}`, permanent: true } }; + } + return { props: { doc, docs: await listDocs() } }; }; function textOf(value: ReactNode): string { return Children.toArray(value) @@ -49,7 +49,7 @@ function textOf(value: ReactNode): string { export default function DocPage({ doc, docs, -}: InferGetStaticPropsType) { +}: InferGetServerSidePropsType) { const headings = headingsFor(doc.content); const [active, setActive] = useState(""); @@ -231,9 +231,9 @@ export default function DocPage({