feat(website,docs,build): 首屏改成大标题 + 整行渐变填色,官网与文档站合并发布到 GitHub Pages

官网(`website/`)

- 首屏标题放大一档(`--fs-display` 上限 3.6rem → 4rem),第二行改成**整行渐变填色**,
  照 ClassIsland 官网那种「大标题 + 中间一段渐变 + 一行副标 + 一排徽章 + 两个按钮」的写法;
- 标题下补一排 mono 芯片(版本 / Windows / Linux·Docker / 触摸屏),主按钮换成
  「立即下载」+「GitHub 仓库」;
- 顶部导航与页脚补「文档站」入口;页脚原本指向 GitHub blob 的那三个链接改成指向文档站
  —— 那几个文件已经移到 `docs/management/` 下,原链接是 404。

> ⚠ 渐变那一行**不参与逐字揭示**:`main.js` 的 `setupHeadlineReveal` 会跳过 `.reveal-grad`。
> 拆成 per-char 的 `inline-block` 之后每个字各自建一层合成,祖先的 `background-clip: text`
> 就穿不过去,渐变会整行失效 —— 所以它按整行入场(透明度 + 轻微上浮)。

站点发布(`.github/workflows/pages.yml`,取代 `docs.yml`)

- **一个仓库在 GitHub Pages 上只有一个站点**,所以官网与文档站拼成一份产物:
  官网在根、文档站在 `/docs/` 下,组装逻辑在 `scripts/build-pages.mjs`;
- 站点根 404 由 `website/404.html` 提供(含 `{{BASE}}` 占位符,由脚本与 workflow 按仓库名注入);
- 官网目录里的 `serve.mjs` 与 `tools/` 是开发用的,不发布;
- `website/serve.mjs` 把 `../docs/_site` 挂在 `/docs/` 下,让本地预览与线上同形 ——
  否则页脚那几个「文档站」链接在本地全是 404;
- 文档站模板顶栏补一个「官网」入口(`{{_rel}}../`)。

版本迭代

- `scripts/version.mjs` 补上**官网上手写的三处版本号**(顶栏徽标 / 首屏芯片 / 页脚)。
  官网是纯静态站、没有构建步骤,没有哪段代码会去读 `package.json`,漏改的后果是
  首页一直挂着旧版本号 —— 而那是别人看到的第一眼。落点从 10 处变成 13 处,
  `AGENTS.md` §4.4 与 `docs/dev/release.md` 同步。
This commit is contained in:
LoveChengke committed 2026-10-06 16:44:23 +08:00
1 parent 19c2aa07b2
commit ded10118f5
15 files changed
+501 -83

No files matched your search

+91
View File
@@ -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,
* 而浏览器是按**被请求的那个路径**解析页面里的相对链接的 —— 不钉 `<base>`,
* 访问 `/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);
}
+35 -8
View File
@@ -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: /(<span class="brand-ver">)(\d+\.\d+\.\d+)(<\/span>)/,
},
{ file: 'website/index.html', label: '(首屏芯片)', pattern: /(<li>v)(\d+\.\d+\.\d+)(<\/li>)/ },
{
file: 'website/index.html',
label: '(页脚)',
pattern: /(<p class="foot-ver">版本 )(\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`,