diff --git a/.github/workflows/docs.yml b/.github/workflows/pages.yml similarity index 58% rename from .github/workflows/docs.yml rename to .github/workflows/pages.yml index 157dcef..725ce1f 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/pages.yml @@ -1,26 +1,29 @@ -name: 发布文档站 +name: 发布官网与文档站 -# 把 docs/ 用 docfx 构建成静态站点,部署到 GitHub Pages。 +# 把一个 GitHub Pages 站点发出去: # -# 为什么单独一个 workflow: -# docfx 是 .NET 工具(`dotnet tool install -g docfx`),需要在 CI 里装 .NET SDK; -# 而文档站与三个交付物的发布节奏也完全不一样 —— 改一个错别字就该上线, -# 不该为此打个安装包、发个 Release。 +# / 产品官网(website/,纯静态、无构建步骤) +# /docs/ 文档站(docfx 构建 docs/ 得到) +# +# 为什么要拼成一个站点:**一个仓库在 GitHub Pages 上只有一个站点**。两者发布节奏也不一样 +# (改个错别字就该上线,不该为此打个安装包),所以走 workflow 而不是塞进发布流程。 # # 站点地址:https://lovechengke.github.io/ClassHelper/ # 需要在仓库 Settings → Pages 里把 Source 选成「GitHub Actions」(只需做一次)。 # -# 本地预览:pnpm docs:serve +# 本地预览:pnpm dev:site(官网在 :5180,文档站挂在同一个端口的 /docs/ 下) on: push: branches: [master] paths: - # 文档内容与构建脚本 + - 'website/**' - 'docs/**' - - '.github/workflows/docs.yml' - # 顶栏那个版本徽标取自根 package.json(docs/build.mjs 通过 --metadata 注入), - # 所以版本号变了也要重新发一次文档站 + - 'scripts/build-pages.mjs' + - '.github/workflows/pages.yml' + # 两个页面上的版本号都取自根 package.json + # (官网是手写的徽章,文档站由 docs/build.mjs 用 --metadata 注入), + # 所以版本号变了也要重新发一次 - 'package.json' workflow_dispatch: @@ -59,19 +62,24 @@ jobs: # --check 把 docfx 的警告当错误:最常见的警告是"某个 .md 没被任何 toc.yml 引用", # 那种页面会被构建出来但没有侧边栏 —— 属于必须修掉的疏漏,不该悄悄上线。 # - # DOCS_BASE 是给 404 页用的:项目页挂在 /<仓库名>/ 子路径下,而浏览器按**被请求的路径** - # 解析 404 页里的相对链接,所以要把站点根钉成 。这里从仓库名算,改仓库名不用改脚本。 + # DOCS_BASE 是给文档站那份 404 用的:项目页挂在 /<仓库名>/ 子路径下,而浏览器按 + # **被请求的路径**解析 404 页里的相对链接,所以要把站点根钉成 。 env: - DOCS_BASE: /${{ github.event.repository.name }}/ + DOCS_BASE: /${{ github.event.repository.name }}/docs/ run: node docs/build.mjs --check + - name: 组装站点(官网在根,文档站在 /docs/) + env: + SITE_BASE: /${{ github.event.repository.name }}/ + run: node scripts/build-pages.mjs + - name: 配置 Pages uses: actions/configure-pages@v5 - name: 上传构建产物 uses: actions/upload-pages-artifact@v3 with: - path: docs/_site + path: .cache/pages deploy: needs: build diff --git a/AGENTS.md b/AGENTS.md index f930fc3..3dd95b4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,7 +66,10 @@ deploy/ Dockerfile / docker-compose{,.sqlite}.yml / nginx.c profile.d/ logrotate.d/ systemd/(装到 /etc 的模板) Dockerfile.linux-package(安装包内那份,--mode docker 用) classhelper.service(systemd 模板,5 个占位符)/ .env{,.sqlite}.example -docs/ **文档站**(docfx 项目,发布到 GitHub Pages;不进 pnpm workspace) +docs/ **文档站**(docfx 项目;不进 pnpm workspace) + ⚠ 与官网(website/)共用**同一个** GitHub Pages 站点:官网在根、 + 文档站在 /docs/ 下。一个仓库在 Pages 上只有一个站点, + 拼装由 scripts/build-pages.mjs 完成,见 §4.4「官网与文档站怎么发布」。 docfx.json / toc.yml(顶栏)+ 各章节自己的 toc.yml(侧栏) get-started/ app/ management/ dev/(四章内容) management/{production,linux-deploy,mysql}.md(部署与换库) @@ -83,6 +86,12 @@ docs/ **文档站**(docfx 项目,发布到 GitHub Pag website/ 产品官网(**纯静态、无构建步骤**,不进 pnpm workspace) index.html / assets/{styles.css,motion.js,main.js,icon.png,shots/} serve.mjs(本地预览)/ tools/capture-shots.mjs(实机图采集) + 404.html(**站点根** 404,含 {{BASE}} 占位符,由 build-pages.mjs 注入) + ⚠ serve.mjs 把 ../docs/_site 挂在 /docs/ 下,为的是让本地与线上同形 —— + 否则页脚那几个「文档站」链接在本地全是 404。文档站得先 pnpm docs:build。 + ⚠ 首屏标题第二行是 .reveal-grad:**它不参与逐字揭示** + (main.js 会跳过它)。拆成 per-char 的 inline-block 后每个字各自建一层合成, + 祖先的 background-clip: text 就穿不过去,渐变会整行失效。 ⚠ assets/motion.js 是动效层:曲线与弹簧 token 逐条取自 beUI (beui.dev/components/motion,见 README §7「官网」)。 改官网动效请改 token 表,不要在组件里另写 duration / cubic-bezier。 @@ -219,11 +228,13 @@ pnpm build:classisland-plugin | `pnpm dist:server` / `dist:win` / `dist:dir` / `dist:all` | 打包服务端安装程序 / 客户端安装包 / 免安装目录 / 全部 | | `pnpm dist:server:linux` | 打包 **Linux 服务端安装包**(`classhelper-server-linux-x64-<版本>.tar.gz` + `.sha256`)—— 见 §5 第 59 条 | | `pnpm icons` | 从 `build/classhelper.png` 派生全部图标(PWA / favicon / 托盘 / 安装包 / 插件 / 官网 / 文档站 / 界面内品牌标) | -| `pnpm version:check` | 逐处列出并比对 10 个落点的版本号,不一致即 exit 1(见 §4.4「版本迭代」) | -| `pnpm version:bump patch\|minor\|major` | **改版本号的唯一入口**:一次改写全部 10 处(含插件清单与 C# 里的常量) | +| `pnpm version:check` | 逐处列出并比对 13 个落点的版本号,不一致即 exit 1(见 §4.4「版本迭代」) | +| `pnpm version:bump patch\|minor\|major` | **改版本号的唯一入口**:一次改写全部 13 处(含插件清单、C# 里的常量,以及官网上手写的三处版本号) | | `pnpm docs:build` | 构建文档站到 `docs/_site`(需要 .NET SDK 8 + `dotnet tool install -g docfx`) | | `pnpm docs:serve` | 构建文档站并起本地预览 → http://127.0.0.1:5181 | | `pnpm docs:check` | 构建文档站并把 docfx 的警告当错误(发布前用;最常见的警告是"某个 .md 没被任何 toc.yml 引用") | +| `pnpm pages:build` | 把官网(根)与文档站(/docs/)拼成一份 GitHub Pages 产物到 `.cache/pages`(CI 用;见 §4.4) | +| `pnpm dev:site` | 构建文档站后起官网预览 → http://127.0.0.1:5180(文档站挂在同端口的 /docs/ 下,与线上同形) | ### 端口 @@ -285,7 +296,7 @@ pnpm dist:classisland-plugin > 算一次发布:攒够一批功能 / 修好一个用户能感知的 bug / 要给别人一份新安装包。 > 不算:改注释、改文档、重构、加测试。 -**同一个版本号写在 10 个文件里**(5 份 `package.json`、`deploy/package.runtime.json`、 +**同一个版本号写在 13 处(11 个文件)里**(5 份 `package.json`、`deploy/package.runtime.json`、 两个 `docker-compose*.yml` 的镜像 tag、插件 `manifest.yml`、以及插件 C# 里的 `PluginVersion` 常量)。 手工改必然漏一处,而漏掉的那处**不会报错** —— 只会在某个不常走的分支上表现为"版本号对不上"。 所以**改版本号的唯一入口是脚本**: @@ -350,17 +361,29 @@ gh workflow run release-linux-server.yml -f tag=v<版本> -f attach=true **已发布的版本号不要复用**:客户端把它记进「忽略此版本」,两端各有 30 分钟缓存 —— 同号换内容会让「检查更新」永久失灵。要修就 `pnpm version:bump patch` 发新号。 -#### 文档站怎么发布 +#### 官网与文档站怎么发布 -**不用做任何事。** 推 master 时只要 `docs/**`、`package.json` 或 `.github/workflows/docs.yml` -变过,`.github/workflows/docs.yml` 就会用 docfx 构建并部署到 GitHub Pages -(`https://lovechengke.github.io/ClassHelper/`)。 +**不用做任何事。** 推 master 时只要 `website/**`、`docs/**`、`scripts/build-pages.mjs` 或 +`package.json` 变过,`.github/workflows/pages.yml` 就会构建并部署到 GitHub Pages: + +``` +https://lovechengke.github.io/ClassHelper/ ← 官网(website/,纯静态无构建) +https://lovechengke.github.io/ClassHelper/docs/ ← 文档站(docfx 构建 docs/) +``` + +> **一个仓库在 GitHub Pages 上只有一个站点**,所以两者是拼在一起发的 +> (`scripts/build-pages.mjs` 把 `website/` 放到根、`docs/_site/` 放到 `docs/` 下)。 +> 官网在根是因为产品页的地址最短最好分享;文档站多一层路径没有代价。 +> 改这个布局要同时动三处:`scripts/build-pages.mjs`、本 workflow 里的 `DOCS_BASE`, +> 以及文档站模板 `docs/templates/classhelper/layout/_master.tmpl` 里那个 `{{_rel}}../` 的「官网」入口。 - 顶栏的版本徽标取自根 `package.json`(`docs/build.mjs` 用 `--metadata _chVersion=…` 注入), - **不要在文档里再写一份版本号**; + **不要在文档里再写一份版本号**;官网首屏那排芯片里的 `v1.1.2` 是手写的,发版时要一起改; - 首次启用需要在仓库 Settings → Pages 把 Source 选成「GitHub Actions」(只需一次); -- 404 页的 `` 由 workflow 按仓库名注入 —— 项目页挂在 `/<仓库名>/` 子路径下, - 而浏览器是按**被请求的路径**解析 404 页里的相对链接的。本地预览时 base 是 `./`。 +- 404 页的 `` 由 `scripts/build-pages.mjs`(本地 `./`)与 workflow(CI 传 `/<仓库名>/`)注入 —— + 项目页挂在 `/<仓库名>/` 子路径下,而浏览器是按**被请求的路径**解析 404 页里的相对链接的; +- 本地预览:`pnpm dev:site`(官网在 http://127.0.0.1:5180,文档站挂在同一个端口的 `/docs/` 下, + 与线上同形);只想单独看文档站用 `pnpm docs:serve`。 --- diff --git a/docs/dev/release.md b/docs/dev/release.md index 0e5a49d..758cd76 100644 --- a/docs/dev/release.md +++ b/docs/dev/release.md @@ -19,9 +19,9 @@ description: 改版本号、跑验收、打安装包、发 GitHub Release、发 | 要给别人一份新安装包 | 只在自己机器上跑 | | 插件单独发补丁(只动插件第 4 段版本号) | — | -## 版本号写在哪(10 处) +## 版本号写在哪(13 处 / 11 个文件) -同一个版本号出现在 10 个文件里,**手工改必然漏一处**,而漏掉的那处不会报错: +同一个版本号分散在 13 个落点上,**手工改必然漏一处**,而漏掉的那处不会报错: | 文件 | 内容 | | ---------------------------------------------------------------- | ------------------------------- | @@ -31,6 +31,10 @@ description: 改版本号、跑验收、打安装包、发 GitHub Release、发 | `deploy/docker-compose.yml`、`docker-compose.sqlite.yml` | 镜像 tag 的默认值 | | `packages/classisland-plugin/manifest.yml` | 插件清单(四段:`x.y.z.0`) | | `packages/classisland-plugin/src/Services/BridgeService.cs` | 插件上报的 `PluginVersion` 常量 | +| `website/index.html` × 3 | 官网顶栏徽标 / 首屏芯片 / 页脚 | + +最后一行值得单独说:官网是**纯静态站、没有构建步骤**,没有哪段代码会去读 `package.json`, +所以那三处只能靠脚本一起改 —— 漏了的话首页会一直挂着旧版本号,而那是别人看到的第一眼。 所以**改版本号的唯一入口是脚本**: @@ -150,13 +154,23 @@ gh release upload v1.1.3 \ gh workflow run release-linux-server.yml -f tag=v1.1.3 -f attach=true ``` -### 8. 文档站 +### 8. 官网与文档站 -**不用做任何事。** 推送 master 时只要 `docs/**` 或 docs 的 workflow 变过, -[`docs.yml`](https://github.com/LoveChengke/classhelper/blob/master/.github/workflows/docs.yml) -会自动用 docfx 构建并部署到 GitHub Pages。 +**不用做任何事。** 推送 master 时只要 `website/**`、`docs/**`、`scripts/build-pages.mjs` 或 +`package.json` 变过, +[`pages.yml`](https://github.com/LoveChengke/classhelper/blob/master/.github/workflows/pages.yml) +就会把两者构建成**同一个** GitHub Pages 站点发出去: -文档站顶栏的版本徽标取自根 `package.json`,所以第 1 步改完就跟着变了。 +``` +https://lovechengke.github.io/ClassHelper/ ← 官网 +https://lovechengke.github.io/ClassHelper/docs/ ← 文档站 +``` + +(一个仓库在 Pages 上只有一个站点,所以是拼在一起发的 —— 官网在根、文档站在 `/docs/` 下。) + +文档站顶栏的版本徽标取自根 `package.json`,所以第 1 步改完就跟着变了; +官网首屏那排芯片、顶栏徽标与页脚里的版本号也是**由 `pnpm version:bump` 一起改的** +(它们是那 13 个落点里的三个)。 ### 9. 检查 diff --git a/docs/docfx.json b/docs/docfx.json index f7baae5..07a4a6f 100644 --- a/docs/docfx.json +++ b/docs/docfx.json @@ -30,7 +30,7 @@ "_appFooter": "
班级小助手(Class Helper)· 文档·GitHub 仓库·下载与更新日志·构建于 docfx
" }, "sitemap": { - "baseUrl": "https://lovechengke.github.io/ClassHelper/", + "baseUrl": "https://lovechengke.github.io/ClassHelper/docs/", "changefreq": "weekly", "priority": 0.5 } diff --git a/docs/serve.mjs b/docs/serve.mjs index 2954fd1..f6718a6 100644 --- a/docs/serve.mjs +++ b/docs/serve.mjs @@ -1,6 +1,6 @@ /** * 文档站本地预览用的最小静态服务器(构建产物 docs/_site)。 - * 正式发布走 GitHub Pages(.github/workflows/docs.yml),不需要这个文件。 + * 正式发布走 GitHub Pages(.github/workflows/pages.yml:官网在根、文档站在 /docs/),不需要这个文件。 * * node docs/build.mjs --serve → 构建后直接预览(推荐) * node docs/serve.mjs → http://127.0.0.1:5181(要求已经构建过) diff --git a/docs/templates/classhelper/layout/_master.tmpl b/docs/templates/classhelper/layout/_master.tmpl index 7f78a89..0d13a79 100644 --- a/docs/templates/classhelper/layout/_master.tmpl +++ b/docs/templates/classhelper/layout/_master.tmpl @@ -100,6 +100,10 @@ {{/_enableSearch}} + + 官网 diff --git a/docs/templates/classhelper/public/main.css b/docs/templates/classhelper/public/main.css index e662a60..93c7b2e 100644 --- a/docs/templates/classhelper/public/main.css +++ b/docs/templates/classhelper/public/main.css @@ -108,10 +108,29 @@ header.bg-body { color: var(--bs-secondary-color); } +/* 顶栏右侧那一组(官网链接 + GitHub 图标)。 + `margin-left: auto` 只挂在组里的**第一个**元素上 —— 挂两个各推各的会散开。 */ +.ch-nav-site { + margin-left: auto; + padding: 6px 10px; + border-radius: 6px; + font-size: 14px; + color: var(--bs-secondary-color); + text-decoration: none; + white-space: nowrap; + transition: + color 0.15s ease, + background-color 0.15s ease; +} + +.ch-nav-site:hover { + color: var(--bs-body-color); + background: var(--ch-accent-soft); +} + .ch-nav-github { display: inline-flex; align-items: center; - margin-left: auto; padding: 6px 8px; border-radius: 6px; color: var(--bs-secondary-color); diff --git a/package.json b/package.json index 44d72ab..5d35ef6 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,8 @@ "version:bump": "node scripts/version.mjs --bump", "docs:build": "node docs/build.mjs", "docs:serve": "node docs/build.mjs --serve", + "pages:build": "node scripts/build-pages.mjs", + "dev:site": "pnpm docs:build && node website/serve.mjs", "docs:check": "node docs/build.mjs --check", "dist:win": "pnpm --filter @classhelper/desktop-client dist:win", "dist:dir": "pnpm --filter @classhelper/desktop-client dist:dir", diff --git a/scripts/build-pages.mjs b/scripts/build-pages.mjs new file mode 100644 index 0000000..e3368cd --- /dev/null +++ b/scripts/build-pages.mjs @@ -0,0 +1,91 @@ +#!/usr/bin/env node +/** + * 组装 GitHub Pages 站点 —— `pnpm pages:build`,CI 里由 .github/workflows/pages.yml 调用。 + * + * ## 为什么需要它 + * + * **一个仓库在 GitHub Pages 上只有一个站点**,但我们要发布两个东西: + * 产品官网(`website/`,纯静态、无构建步骤)与文档站(`docs/`,docfx 构建出来)。 + * 于是把两者拼成一个站点,按这个布局: + * + * <站点根>/ 官网(website/ 的内容) + * <站点根>/docs/ 文档站(docs/_site 的内容) + * + * 官网在根、文档站在 `/docs/` 下 —— 产品页的地址最短最好分享,文档站多一层路径没有代价。 + * + * ## 三件容易忽略的事 + * + * ① **404 页要注入站点根**。Pages 对任何未知路径都返回**站点根**的 404.html, + * 而浏览器是按**被请求的那个路径**解析页面里的相对链接的 —— 不钉 ``, + * 访问 `/ClassHelper/docs/typo` 时页面上所有链接都会指到 `/ClassHelper/docs/` 下面去。 + * (文档站自己那份 `docs/404.html` 只在「文档站被单独托管」时才用得上,合并站点里备而不用。) + * ② **官网目录里那两个开发用的东西不能发出去**:`serve.mjs`(本地预览服务器)与 + * `tools/`(实机截图采集脚本,会真的开 Electron 窗口)。它们对静态托管没有意义。 + * ③ **`.nojekyll`**:现在走的是 `actions/upload-pages-artifact`,不经过 Jekyll, + * 但留着这个文件不花任何代价,万一哪天切回分支发布也不会因为 `_` 开头的目录被吞掉。 + * + * 用法: + * node scripts/build-pages.mjs # 输出到 .cache/pages/ + * SITE_BASE=/ClassHelper/ node scripts/build-pages.mjs + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const websiteDir = path.join(root, 'website'); +const docsDir = path.join(root, 'docs', '_site'); +const outDir = path.join(root, '.cache', 'pages'); + +/** 站点根路径。CI 里按仓库名传(项目页挂在 /<仓库名>/ 下),本地预览用 './'。 */ +const BASE = process.env.SITE_BASE ?? './'; + +/** 官网目录里不发出去的东西(开发用) */ +const SKIP = new Set(['serve.mjs', '404.html']); // 404.html 要注入 base,单独处理 +const SKIP_DIRS = new Set(['tools']); + +function copyWebsite() { + for (const entry of fs.readdirSync(websiteDir, { withFileTypes: true })) { + if (entry.isDirectory() && SKIP_DIRS.has(entry.name)) continue; + if (entry.isFile() && SKIP.has(entry.name)) continue; + fs.cpSync(path.join(websiteDir, entry.name), path.join(outDir, entry.name), { recursive: true }); + } +} + +function main() { + if (!fs.existsSync(path.join(docsDir, 'index.html'))) { + console.error('[pages] 找不到 docs/_site —— 先跑 `pnpm docs:build`。'); + process.exit(1); + } + + fs.rmSync(outDir, { recursive: true, force: true }); + fs.mkdirSync(path.join(outDir, 'docs'), { recursive: true }); + + copyWebsite(); + fs.cpSync(docsDir, path.join(outDir, 'docs'), { recursive: true }); + + // 站点根的 404:把 {{BASE}} 换成站点根路径 + const notFound = fs.readFileSync(path.join(websiteDir, '404.html'), 'utf8').replaceAll('{{BASE}}', BASE); + fs.writeFileSync(path.join(outDir, '404.html'), notFound, 'utf8'); + + // 见文件头 ③ + fs.writeFileSync(path.join(outDir, '.nojekyll'), '', 'utf8'); + + const count = (dir) => + fs.readdirSync(dir, { withFileTypes: true }).reduce((sum, entry) => { + if (entry.isDirectory()) return sum + count(path.join(dir, entry.name)); + return sum + 1; + }, 0); + + console.log(`[pages] 站点已组装到 ${path.relative(root, outDir)}(base = ${BASE})`); + console.log(`[pages] 根 ← website/ ${count(outDir) - count(path.join(outDir, 'docs'))} 个文件`); + console.log(`[pages] docs/ ← docs/_site/ ${count(path.join(outDir, 'docs'))} 个文件`); +} + +try { + main(); +} catch (error) { + console.error('[pages] 失败:', error.message); + process.exit(1); +} diff --git a/scripts/version.mjs b/scripts/version.mjs index ef4580f..52e735a 100644 --- a/scripts/version.mjs +++ b/scripts/version.mjs @@ -4,15 +4,17 @@ * * ## 为什么要这个脚本 * - * 这个仓库里**同一个版本号写在 10 个地方**(5 份 package.json、运行时常量、两个 compose 文件、 - * 插件清单,以及插件 C# 源码里的一个 const)。手工改必然漏一处,而漏掉的那处**不会报错**, - * 只会在某个不常走的分支上表现为"版本号对不上": + * 这个仓库里**同一个版本号写在 13 处(11 个文件)**:5 份 package.json、运行时常量、两个 compose 文件、 + * 插件清单、插件 C# 源码里的一个 const,以及官网页面上三处手写的版本号。手工改必然漏一处, + * 而漏掉的那处**不会报错**,只会在某个不常走的分支上表现为"版本号对不上": * * - `deploy/docker-compose*.yml` 镜像 tag 落后 → 服务器上跑的还是旧镜像; * - `deploy/package.runtime.json` 容器 / 安装包里那份依赖清单的版本(人看,但会误导排查); * - `manifest.yml` + `BridgeService.cs` ClassIsland 上显示的插件版本 * (清单里的 version 与实际上报的 PluginVersion 不一致时, - * Web 端「ClassIsland 联动」页显示的版本会对不上插件市场)。 + * Web 端「ClassIsland 联动」页显示的版本会对不上插件市场); + * - `website/index.html` 的那三处 官网是纯静态站、无构建步骤,没有哪段代码会去读 + * `package.json`,漏改的后果是**首页上一直挂着旧版本号** —— 那是别人看到的第一眼。 * * 因此:**改版本号的唯一入口是这个脚本**。它按表逐个改写,任何一处没匹配到(或匹配到多处) * 就直接失败退出,不做"尽力而为"的部分改写。 @@ -71,11 +73,35 @@ const PLUGIN_TARGETS = [ }, ]; -const ALL_TARGETS = [...PRODUCT_TARGETS, ...PLUGIN_TARGETS]; +/** + * 官网首屏与页脚那三处手写的版本号。官网是纯静态站,没有构建步骤、也没有哪段代码会去读 + * `package.json`,所以这三处只能靠脚本一起改 —— 漏了的话官网会一直显示旧版本号, + * 而它是别人看到的第一眼。 + */ +const SITE_TARGETS = [ + { + file: 'website/index.html', + label: '(顶栏徽标)', + pattern: /()(\d+\.\d+\.\d+)(<\/span>)/, + }, + { file: 'website/index.html', label: '(首屏芯片)', pattern: /(
  • v)(\d+\.\d+\.\d+)(<\/li>)/ }, + { + file: 'website/index.html', + label: '(页脚)', + pattern: /(

    版本 )(\d+\.\d+\.\d+)( ·)/, + }, +]; + +const ALL_TARGETS = [...PRODUCT_TARGETS, ...PLUGIN_TARGETS, ...SITE_TARGETS]; const PRODUCT_RE = /^\d+\.\d+\.\d+$/; const PLUGIN_RE = /^\d+\.\d+\.\d+\.\d+$/; +/** 输出用的名字:同一个文件里有多个落点时带上括号里的说明,否则三行看起来一模一样 */ +function name(target) { + return target.label ? `${target.file} ${target.label}` : target.file; +} + function readTarget(target) { const source = fs.readFileSync(path.join(root, target.file), 'utf8'); const flags = target.pattern.flags.includes('g') ? target.pattern.flags : `${target.pattern.flags}g`; @@ -102,7 +128,7 @@ function writeVersion(target, next, { dry }) { const replaced = source.replace(global, (_m, prefix, _old, suffix) => `${prefix}${next}${suffix}`); fs.writeFileSync(path.join(root, target.file), replaced, 'utf8'); } - return `${target.file} ${hits[0][2]} → ${next}`; + return `${name(target)} ${hits[0][2]} → ${next}`; } function bumpProduct(current, level) { @@ -122,7 +148,7 @@ function check(product) { for (const target of ALL_TARGETS) { const version = readVersion(target); if (!version) { - console.log(`?? ${target.file.padEnd(width)} (未匹配到版本号)`); + console.log(`?? ${name(target).padEnd(width)} (未匹配到版本号)`); problems.push(`${target.file}:匹配不到版本号`); continue; } @@ -131,7 +157,7 @@ function check(product) { const ok = PLUGIN_RE.test(version) ? version.split('.').slice(0, 3).join('.') === product : version === expected; - console.log(`${ok ? ' ' : '!!'} ${target.file.padEnd(width)} ${version}`); + console.log(`${ok ? ' ' : '!!'} ${name(target).padEnd(width)} ${version}`); if (!ok) problems.push(`${target.file}:${version} ≠ ${expected}`); } @@ -149,6 +175,7 @@ function apply(next, { dry, product }) { const lines = []; for (const target of PRODUCT_TARGETS) lines.push(writeVersion(target, next, { dry })); for (const target of PLUGIN_TARGETS) lines.push(writeVersion(target, pluginNext, { dry })); + for (const target of SITE_TARGETS) lines.push(writeVersion(target, next, { dry })); console.log( `${dry ? '[dry] 将改写' : '已改写'} ${product} → ${next}(插件 ${product}.0 → ${pluginNext}):\n`, diff --git a/website/404.html b/website/404.html new file mode 100644 index 0000000..af89c73 --- /dev/null +++ b/website/404.html @@ -0,0 +1,136 @@ + + + + + + + + + 页面不存在 | 班级小助手 + + + +

    +
    404
    +

    这里没有这一页

    +

    链接可能过期了,或者这一页已经被重写进了别的章节。

    + +
    + + diff --git a/website/assets/main.js b/website/assets/main.js index 1b497d4..a32c339 100644 --- a/website/assets/main.js +++ b/website/assets/main.js @@ -60,6 +60,11 @@ const units = []; lines.forEach((line) => { + // 整行渐变填色的那一行**不拆字**,整行做一个单元(见 styles.css 的 .reveal-grad): + // 拆成 per-char 的 inline-block 后,每个字各自建一层合成,祖先的 + // `background-clip: text` 就穿不过去了,渐变会整行失效。 + if (line.classList.contains('reveal-grad')) return; + const text = line.textContent.trim(); line.textContent = ''; for (const char of Array.from(text)) { diff --git a/website/assets/styles.css b/website/assets/styles.css index ea25b36..7b649e1 100644 --- a/website/assets/styles.css +++ b/website/assets/styles.css @@ -62,7 +62,12 @@ 'Microsoft YaHei UI', 'Microsoft YaHei', 'Noto Sans SC', system-ui, sans-serif; --mono: ui-monospace, 'Cascadia Mono', 'SF Mono', Consolas, 'Microsoft YaHei UI', monospace; - --fs-display: clamp(2.1rem, 5vw, 3.6rem); + /* 首屏标题:比站内其它标题大一档(ClassIsland 官网也是这个量级)。 + 两个数都是量出来的: + 上限 4rem —— 第一行 11 个汉字 × 1em ≈ 704px,得留在 .hero-intro 的列宽(46em)里; + 下限 1.8rem —— 375px 屏上可用宽度只有 327px,再大会把「它知道现在是第几节课,」 + 折成「它知道现在是第几」+ 三个字的孤行。 */ + --fs-display: clamp(1.8rem, 5.7vw, 4rem); --fs-h2: clamp(1.7rem, 3.4vw, 2.5rem); --fs-h3: clamp(1.05rem, 1.5vw, 1.2rem); --fs-lead: clamp(1rem, 1.5vw, 1.12rem); @@ -417,10 +422,10 @@ table { font-family: var(--display); font-weight: 400; font-size: var(--fs-display); - line-height: 1.32; + line-height: 1.22; letter-spacing: 0.01em; max-width: 20em; - margin-bottom: 30px; + margin-bottom: 26px; } /* 标题逐字上浮 —— beUI TextReveal。 @@ -649,9 +654,41 @@ strong { } /* 首屏文字收成左列(ClassIsland 的 .introduction-col 也是这么窄的一条)。 - 47em ≈ 750px:再窄一点,「它知道现在是第几节课,」的逗号就会掉到第二行去。 */ + 46em ≈ 736px:这一列要能装下放大的「它知道现在是第几节课,」(11 个汉字 × 4rem ), + 同时右边还要留得下三句事实。两边的数是一起量的,改一个就得回头看另一个。 */ .hero-intro { - max-width: 47em; + max-width: 46em; +} + +/* 整行渐变填色 —— ClassIsland 官网的 .gradient-base 那个写法。 + 这一行**不拆字**(main.js 的 setupHeadlineReveal 会跳过它):拆成 per-char 的 + inline-block 之后每个字各自建一层合成,祖先的 `background-clip: text` 就穿不过去, + 渐变会整行失效。所以它按「整行」入场:透明度 + 轻微上浮,比逐字稍晚一点起。 */ +.display .reveal-grad { + background-image: linear-gradient(135deg, var(--chalk) 6%, var(--blue) 78%); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + -webkit-text-fill-color: transparent; + opacity: 0; + transition: opacity 0.85s var(--ease-out) 0.24s; +} + +html:not(.reveal-pending) .display .reveal-grad { + opacity: 1; +} + +@media (prefers-reduced-motion: no-preference) { + .display .reveal-grad { + transform: translateY(14%); + transition: + opacity 0.85s var(--ease-out) 0.24s, + transform 0.85s var(--ease-out) 0.24s; + } + + html:not(.reveal-pending) .display .reveal-grad { + transform: none; + } } /* 宽屏上把三句事实挪到文字右边,填住那条空 —— ClassIsland 的首屏也是左文右块。 @@ -659,7 +696,7 @@ strong { @media (min-width: 1080px) { .hero .wrap { display: grid; - grid-template-columns: minmax(0, 47em) 1fr; + grid-template-columns: minmax(0, 46em) 1fr; grid-template-areas: 'intro facts' 'screen screen' @@ -691,15 +728,47 @@ strong { .hero-sub { font-family: var(--display); - font-size: clamp(1.35rem, 2.6vw, 1.9rem); - line-height: 1.5; + font-size: clamp(1.4rem, 2.9vw, 2.1rem); + line-height: 1.45; color: var(--chalk); - margin-bottom: 14px; + margin-bottom: 16px; } .hero-intro .lead { max-width: 32em; - margin-bottom: 26px; + margin-bottom: 28px; +} + +/* 首屏按钮比站内其它按钮大一档 —— 它是这一页的主行动点 */ +.hero-intro .actions .btn { + padding: 15px 26px; + font-size: 0.95rem; +} + +/* 平台 / 支持情况那一行小标签(对应 ClassIsland 首屏底部那句「支持在 Windows…」)。 + 做成 mono 的芯片而不是一句话:这行是要被扫一眼的,不是要被读的。 */ +.hero-badges { + list-style: none; + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 22px; +} + +.hero-badges li { + font-family: var(--mono); + font-size: var(--fs-xs); + line-height: 1.6; + padding: 4px 11px; + border: 1px solid var(--board-line); + border-radius: 999px; + color: var(--chalk-2); +} + +/* 第一个是版本号:当前这一版是什么,比平台支持更需要一眼看到 */ +.hero-badges li:first-child { + border-color: var(--blue); + color: var(--chalk); } /* ── 首屏入场:照着 ClassIsland 的 .intro-seq 写(逐项 0.72s EASE_OUT + 错峰延迟)。 diff --git a/website/index.html b/website/index.html index 44effb3..16cc31a 100644 --- a/website/index.html +++ b/website/index.html @@ -54,6 +54,7 @@ 实机 部署 验收 + 文档站 取安装包 @@ -68,7 +69,7 @@

    它知道现在是第几节课, - 所以不会在课上打扰学生。 + 不会在课上打扰学生

    课表、作业、通知、成绩、叫人

    @@ -79,9 +80,24 @@

    + +
      +
    • v1.1.2
    • +
    • Windows 10/11
    • +
    • Linux · Docker
    • +
    • 触摸屏可用
    • +
    diff --git a/website/serve.mjs b/website/serve.mjs index bfbe9d7..47f8404 100644 --- a/website/serve.mjs +++ b/website/serve.mjs @@ -1,9 +1,13 @@ /** * 官网本地预览用的最小静态服务器 —— 只是为了 `node serve.mjs` 能直接看页面。 - * 正式发布时把 website/ 整个目录丢给任意静态托管 / Nginx 即可,不需要这个文件。 + * 正式发布时把 website/ 与 docs/_site/ 一起交给静态托管即可,不需要这个文件。 * * node website/serve.mjs → http://127.0.0.1:5180 * PORT=8080 node website/serve.mjs + * + * 线上两者是同一个 GitHub Pages 站点:官网在根,文档站在 /docs/ 下(见 .github/workflows/pages.yml)。 + * 这里把 docs/_site 挂到 /docs/ 就是为了让本地与线上同形 —— 否则页脚那几个「文档站」链接 + * 在本地全是 404。文档站没构建过时 /docs/ 会 404,先跑一次 `pnpm docs:build`。 */ import { createServer } from 'node:http'; @@ -12,27 +16,36 @@ import { extname, join, normalize, resolve, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; const ROOT = resolve(fileURLToPath(new URL('.', import.meta.url))); +const DOCS_ROOT = resolve(ROOT, '..', 'docs', '_site'); const PORT = Number(process.env.PORT ?? 5180); const MIME = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', '.png': 'image/png', '.jpg': 'image/jpeg', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.webmanifest': 'application/manifest+json', + '.woff': 'font/woff', '.woff2': 'font/woff2', + '.map': 'application/json', }; const server = createServer(async (request, response) => { const url = new URL(request.url ?? '/', 'http://localhost'); const requested = decodeURIComponent(url.pathname); - const target = normalize(join(ROOT, requested === '/' ? 'index.html' : requested)); - // 目录穿越防护:解析结果必须仍在 ROOT 里 - if (target !== ROOT && !target.startsWith(ROOT + sep)) { + // 文档站挂在 /docs/ 下(线上也是这样) + const underDocs = requested === '/docs' || requested.startsWith('/docs/'); + const base = underDocs ? DOCS_ROOT : ROOT; + const relative = underDocs ? requested.replace(/^\/docs\/?/, '/') : requested; + const target = normalize(join(base, relative === '/' ? 'index.html' : relative)); + + // 目录穿越防护:解析结果必须仍在它所属的根里 + if (target !== base && !target.startsWith(base + sep)) { response.writeHead(403).end('Forbidden'); return; } @@ -47,10 +60,18 @@ const server = createServer(async (request, response) => { }); response.end(body); } catch { + if (underDocs) { + response + .writeHead(404, { 'content-type': 'text/html; charset=utf-8' }) + .end( + '

    文档站还没构建。先跑一次 pnpm docs:build。

    ', + ); + return; + } response.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }).end('404'); } }); server.listen(PORT, '127.0.0.1', () => { - console.log(`官网预览:http://127.0.0.1:${PORT}`); + console.log(`官网预览:http://127.0.0.1:${PORT}(文档站在 /docs/)`); });