Files
ClassHelper/scripts/icons/render.cjs
T
LoveChengke 19c2aa07b2 feat(docs,scripts,build): 用 docfx 搭文档站并发布到 GitHub Pages,版本号收口成脚本迭代
文档站(`docs/` 就是 docfx 项目,发布在 https://lovechengke.github.io/ClassHelper/):

- 四章 + 常见问题 + 完整参考,共 44 页:快速上手(8 页)/ 应用帮助(10 页)/
  管理运维(6 页)/ 开发文档(11 页)。信息架构照 ClassIsland 文档站来:
  顶栏分章、左侧章节导航、右侧本页目录、上一页 / 下一页、站内搜索、深色主题跟随系统;
- 自定义模板覆盖(`templates/classhelper/`):把 docfx modern 模板那套英文 UI 文案
  (In this article / Improve this doc / Next…)换成中文,补 GitHub 入口与版本徽标,
  并加一层只调排版与配色的 `public/main.css`(不动 DOM 结构);
- 站点顶栏的版本徽标取自根 `package.json`(`docs/build.mjs` 用 `--metadata` 注入),
  不在文档里写第二份 —— 否则一定漂;
- 404 页单独生成:GitHub Pages 的项目页挂在 `/<仓库名>/` 子路径下,而浏览器是按**被请求的路径**
  解析页面里的相对链接的,所以 `<base>` 必须钉到站点根,由 `build.mjs`(本地 `./`)与
  workflow(CI 传 `/<仓库名>/`)分别注入;
- 构建前清空 `_site`:docfx 只覆盖不清理,删掉或改名的页面会以旧文件留在目录里,
  而 Pages 上传的是整个目录。

版本迭代(**每一次 releases,不是每一次提交**):

- 新增 `scripts/version.mjs`。这个仓库里同一个版本号散在 **10 个文件**里(5 份 `package.json`、
  `deploy/package.runtime.json`、两个 compose 的镜像 tag、插件 `manifest.yml`、以及插件 C# 里的
  `PluginVersion` 常量),手工改必然漏一处,而漏掉的那处**不会报错**,只在某个不常走的分支上
  表现为"版本号对不上"。现在收口成两条命令:`pnpm version:check`(逐处比对,不一致 exit 1)
  与 `pnpm version:bump patch|minor|major`(一次改写全部 10 处,任何一处匹配不到或匹配到多处
  就直接失败退出,不做尽力而为的部分改写);
- `AGENTS.md` 新增 §4.4「版本迭代(每一次 releases,不是每一次提交)」:写清版本号落在哪 10 处、
  完整发布命令序列(体检 → 验收 → `dist:all` → 哈希清单 → 推 master → `gh release create` →
  Linux 包由 CI 合并哈希行),以及"已发布的版本号不要复用"的理由。

其它:

- `.github/workflows/docs.yml`:推 master(`docs/**` / `package.json` / workflow 自身有变)即构建并
  部署到 Pages;构建带 `--check`,把 docfx 的警告当错误 —— 最常见的警告是"某个 `.md` 没被任何
  `toc.yml` 引用",那种页面有内容但没有侧边栏,属于必须修掉的疏漏;
- docs 目录重组后的引用同步:`production` / `mysql` / `linux-deploy` / `winisland-design-tokens`
  移到新位置后,AGENTS.md、`deploy/`、`scripts/`、客户端源码注释里的路径引用一并更新;
- 文档站只发布 `docs/` 这一棵树,页面里指向树外的相对链接(`../AGENTS.md` 等)改成 GitHub 绝对 URL,
  否则上线全是 404;
- 图标派生清单补上文档站模板里的 `logo.png` 与 `favicon.png`(`pnpm icons` 一并生成,
  仍然只有一个图形源 `build/classhelper.{png,ico}`);
- `docs/_site` 进 `.gitignore` / `.prettierignore` 与 ESLint ignores;
- 根脚本补 `version:check` / `version:bump` / `docs:build` / `docs:serve` / `docs:check`。
2026-10-06 16:27:44 +08:00

251 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 应用图标导出器(`pnpm icons`)—— **全项目所有图标都从这里出**。
*
* ## 图形源(唯一)
*
* 仓库根 `build/` 下那两份是**手工放入的设计导出**,不要用 `pnpm icons` 去覆盖它们:
*
* build/classhelper.png 345×339 PNG(调色板 + tRNS,带透明)—— 所有 PNG 尺寸由它重采样;
* build/classhelper.ico 9 个尺寸(16/24/32/48/64/72/96/128/256,BMP 载荷)—— 由设计工具
* **逐尺寸栅格化**,比把位图缩下去干净,因此**原样透传**,不重新生成。
*
* 历史上这里是用 Electron 渲染一段内联 SVG("蓝色圆角块 + 白色『班』字")生成全套图标;
* 换成设计导出的位图之后,那套 SVG 已删除,本脚本只做重采样与分发。
*
* ## 产物
*
* packages/web-admin/public/icon-192.png PWA 图标
* packages/web-admin/public/icon-512.png PWA 图标(manifest 里也当 maskable 用)
* packages/web-admin/public/apple-touch-icon.png iOS 主屏图标(180)
* packages/web-admin/public/favicon.png 标签页图标(64)
* packages/web-admin/public/favicon.svg 标签页图标(内嵌 128 位图的 SVG 壳)
* packages/web-admin/public/logo.png **界面内**的品牌标(侧栏 / 登录页,128)
* packages/classisland-plugin/icon.png ClassIsland 插件图标(256)
* packages/desktop-client/build/classhelper.{png,ico} 图标源在客户端的镜像
* packages/desktop-client/build/icon.{png,ico} electron-builder 的**兜底约定名**
* (`win.icon` 显式指向 classhelper.ico,
* 这两个是与它同步的副本,别再删)
* packages/desktop-client/public/tray.png 系统托盘图标(32)
* packages/desktop-client/public/logo.png **界面内**的品牌标(侧栏 / 登录页,128)
* website/assets/icon.png 官网(favicon / apple-touch / 页面内 brand)
* docs/templates/classhelper/logo.png 文档站顶栏的品牌标(128)
* docs/templates/classhelper/favicon.png 文档站标签页图标(64)
*
* ## 为什么还是 Electron
*
* 要把一张位图重采样到 8 个尺寸(其中 512 那档是**放大**),仓库里没有任何图形库;
* 而 Electron 自带 Chromium 的 canvas,重采样质量比 `nativeImage.resize()` 好得多
* (后者一步到位、没有逐级折半,345→16 会走样)。为这一个脚本引 sharp/jimp 进
* devDependencies 更不划算 —— 这条流水线从有图标那天起就是跑在 Electron 上的。
*
* ## 三条实现要点
*
* ① **非正方形源**:345×339 直接 `drawImage` 到正方形画布会拉变形,必须等比缩放后居中、
* 四周补透明(补边只有 1.7%,肉眼不可见);
* ② **逐级折半**:canvas 的降采样是一次双线性,345 → 16 一步到位会糊成一团,
* 先折到目标的两倍以内再出最后一步;
* ③ 图片以 `data:` URL 进页面:`data:` 图**不会污染 canvas**,`toDataURL()` 拿得到数据;
* 换成 `file://` 就会因跨源被拒(Chromium 里 file:// 页面是 opaque origin)。
*/
const fs = require('node:fs');
const path = require('node:path');
const { app, BrowserWindow } = require('electron');
const ROOT = path.resolve(__dirname, '..', '..');
const BUILD_DIR = path.join(ROOT, 'build');
const SOURCE_PNG = path.join(BUILD_DIR, 'classhelper.png');
const SOURCE_ICO = path.join(BUILD_DIR, 'classhelper.ico');
/** 需要从源 PNG 重采样出来的全部尺寸(去重后每个尺寸只渲染一次) */
const PNG_SIZES = [32, 64, 128, 180, 192, 256, 512];
/** 客户端产物目录(electron-builder 的 buildResources) */
const DESKTOP_BUILD = path.join(ROOT, 'packages', 'desktop-client', 'build');
function readSourceDataUrl() {
if (!fs.existsSync(SOURCE_PNG)) {
throw new Error(`缺少图形源 ${path.relative(ROOT, SOURCE_PNG)}(设计导出,需手工放入)`);
}
return `data:image/png;base64,${fs.readFileSync(SOURCE_PNG).toString('base64')}`;
}
/**
* 页面侧的重采样脚本。返回一张 `size×size`、内容等比居中、四周透明的 PNG(data URL)。
*
* 以字符串形式注入页面主世界执行(`executeJavaScript` 默认就在主世界,
* 不受 `contextIsolation` 影响),因此这里不能用反引号。
*/
function resampleScript(size, sourceUrl) {
return `(async () => {
const SIZE = ${size};
const img = new Image();
img.src = ${JSON.stringify(sourceUrl)};
await img.decode();
// 等比放进 SIZE×SIZE 的目标框
const fit = Math.min(SIZE / img.naturalWidth, SIZE / img.naturalHeight);
const boxW = Math.max(1, Math.round(img.naturalWidth * fit));
const boxH = Math.max(1, Math.round(img.naturalHeight * fit));
// 逐级折半:一步到位降采样会走样,先折到目标的两倍以内
let current = img;
while (Math.round(current.width / 2) >= boxW && Math.round(current.height / 2) >= boxH) {
const half = document.createElement('canvas');
half.width = Math.max(1, Math.round(current.width / 2));
half.height = Math.max(1, Math.round(current.height / 2));
const halfCtx = half.getContext('2d');
halfCtx.imageSmoothingEnabled = true;
halfCtx.imageSmoothingQuality = 'high';
halfCtx.drawImage(current, 0, 0, half.width, half.height);
current = half;
}
// 最后一次:按 current 的实际比例重算一次落位尺寸,避免折半取整带来的亚像素拉伸
const finalFit = Math.min(SIZE / current.width, SIZE / current.height);
const drawW = Math.max(1, Math.round(current.width * finalFit));
const drawH = Math.max(1, Math.round(current.height * finalFit));
const canvas = document.createElement('canvas');
canvas.width = SIZE;
canvas.height = SIZE;
const ctx = canvas.getContext('2d');
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(current, Math.floor((SIZE - drawW) / 2), Math.floor((SIZE - drawH) / 2), drawW, drawH);
return canvas.toDataURL('image/png');
})()`;
}
async function renderSizes(window, sourceUrl) {
const pngBySize = new Map();
for (const size of PNG_SIZES) {
const dataUrl = await window.webContents.executeJavaScript(resampleScript(size, sourceUrl));
if (typeof dataUrl !== 'string' || !dataUrl.startsWith('data:image/png;base64,')) {
throw new Error(`渲染 ${size}×${size} 失败:页面没有返回 PNG data URL`);
}
const buffer = Buffer.from(dataUrl.slice('data:image/png;base64,'.length), 'base64');
if (buffer.length === 0) throw new Error(`渲染 ${size}×${size} 失败:数据为空`);
console.log(`[icons] ${size}×${size} -> ${buffer.length} 字节`);
pngBySize.set(size, buffer);
}
return pngBySize;
}
/** ICO 容器头解析,只用来核对设计导出里到底带了哪几个尺寸 */
function readIcoSizes(buffer) {
if (buffer.length < 6 || buffer.readUInt16LE(0) !== 0 || buffer.readUInt16LE(2) !== 1) {
throw new Error(`${path.relative(ROOT, SOURCE_ICO)} 不是合法的 ICO 容器`);
}
const count = buffer.readUInt16LE(4);
const sizes = [];
for (let index = 0; index < count; index += 1) {
const base = 6 + index * 16;
sizes.push(`${buffer[base] || 256}×${buffer[base + 1] || 256}`);
}
return sizes;
}
/**
* 标签页图标的 SVG 壳。
*
* 设计只给了位图,没有矢量源 —— 所以这里不做"假矢量",而是把一张 128 的 PNG 原样嵌进去。
* 保留 `favicon.svg` 这个文件名是为了不动 `index.html` 里那条 `<link rel="icon" type="image/svg+xml">`:
* 现代浏览器优先取 SVG 那条,删掉它反而要改 HTML、改文档。
* 128 足够覆盖任何真实标签页尺寸(32px @4x = 128 物理像素)。
*/
function buildFaviconSvg(png128) {
const base64 = png128.toString('base64');
return `<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
<image href="data:image/png;base64,${base64}" width="128" height="128" />
</svg>
`;
}
/** 内容一致就不写盘:反复跑 `pnpm icons` 不该在 git 里刷出无意义的改动 */
function writeIfChanged(file, data) {
if (!data || data.length === 0) {
throw new Error(`拒绝写入空内容:${path.relative(ROOT, file)}`);
}
fs.mkdirSync(path.dirname(file), { recursive: true });
const existing = fs.existsSync(file) ? fs.readFileSync(file) : null;
if (existing && existing.equals(data)) {
console.log(`[icons] 未变化 ${path.relative(ROOT, file)}`);
return;
}
fs.writeFileSync(file, data);
console.log(`[icons] 写入 ${path.relative(ROOT, file)} (${data.length} 字节)`);
}
app.whenReady().then(async () => {
try {
const sourceUrl = readSourceDataUrl();
const icoBytes = fs.readFileSync(SOURCE_ICO);
const icoSizes = readIcoSizes(icoBytes);
console.log(`[icons] 图形源 ${path.relative(ROOT, SOURCE_PNG)}`);
console.log(`[icons] ICO 尺寸 ${icoSizes.join(' / ')}`);
// 尺寸由页面里的 canvas 决定,窗口本身多大都行 —— 用 64×64 就够,且必须隐藏
const window = new BrowserWindow({
width: 64,
height: 64,
show: false,
webPreferences: { offscreen: false, backgroundThrottling: false },
});
await window.loadURL(
`data:text/html;charset=utf-8,${encodeURIComponent('<!doctype html><meta charset="utf-8"><title>icons</title>')}`,
);
const pngBySize = await renderSizes(window, sourceUrl);
window.destroy();
// ── Web 管理端(PWA + 标签页) ───────────────────────────────────────────
const webPublic = path.join(ROOT, 'packages', 'web-admin', 'public');
writeIfChanged(path.join(webPublic, 'icon-192.png'), pngBySize.get(192));
writeIfChanged(path.join(webPublic, 'icon-512.png'), pngBySize.get(512));
writeIfChanged(path.join(webPublic, 'apple-touch-icon.png'), pngBySize.get(180));
writeIfChanged(path.join(webPublic, 'favicon.png'), pngBySize.get(64));
writeIfChanged(path.join(webPublic, 'favicon.svg'), Buffer.from(buildFaviconSvg(pngBySize.get(128)), 'utf8'));
// 界面内的品牌标(侧栏 / 登录页)。与 favicon 分开一份的理由:favicon 是给浏览器标签页用的,
// 而这张是**渲染进页面**的(`<img src="/logo.png">`),尺寸与缓存策略都不该混在一起。
writeIfChanged(path.join(webPublic, 'logo.png'), pngBySize.get(128));
// ── ClassIsland 插件(清单同目录的 icon.png,ClassIsland 按约定读取) ──────
writeIfChanged(path.join(ROOT, 'packages', 'classisland-plugin', 'icon.png'), pngBySize.get(256));
// ── 桌面客户端 ──────────────────────────────────────────────────────────
// classhelper.* 是源的镜像;icon.* 是 electron-builder 的兜底约定名(默认找 build/icon.ico),
// 两套都留在客户端产物目录里,免得哪天显式配置被拿掉就悄悄退回旧图标。
writeIfChanged(path.join(DESKTOP_BUILD, 'classhelper.png'), fs.readFileSync(SOURCE_PNG));
writeIfChanged(path.join(DESKTOP_BUILD, 'classhelper.ico'), icoBytes);
writeIfChanged(path.join(DESKTOP_BUILD, 'icon.png'), pngBySize.get(512));
writeIfChanged(path.join(DESKTOP_BUILD, 'icon.ico'), icoBytes);
// 托盘:Windows 通知区域在 100% DPI 下就是 16×16,给 32×32 让它在
// 150% / 200% DPI 上也有原生像素可用(原先直接丢了一张 345×339 的应用图标进去,
// 非正方形会被系统拉伸)。
writeIfChanged(path.join(ROOT, 'packages', 'desktop-client', 'public', 'tray.png'), pngBySize.get(32));
// 界面内的品牌标(侧栏 / 登录页)。
// **客户端必须经 `BRAND_LOGO_URL`(src/renderer/config.ts)绑定给 `:src`**:
// 生产环境是 file:// 加载 dist/renderer/index.html,根绝对路径会解析到盘符根目录;
// 而字面量的相对 `src` 又会被 Vite 当成模块导入去解析、直接构建失败。
writeIfChanged(path.join(ROOT, 'packages', 'desktop-client', 'public', 'logo.png'), pngBySize.get(128));
// ── 官网(纯静态站,图标同样是页面里的 brand 标记) ──────────────────────
writeIfChanged(path.join(ROOT, 'website', 'assets', 'icon.png'), pngBySize.get(512));
// ── 文档站(docfx) ─────────────────────────────────────────────────────
// 必须落在**模板目录**里:docfx 只拷贝各模板 `{{!include}}` 声明的文件,
// 直接放 docs/ 根下不会进 _site(见 docs/templates/classhelper/layout/_master.tmpl 顶部注释)。
const docsTemplate = path.join(ROOT, 'docs', 'templates', 'classhelper');
writeIfChanged(path.join(docsTemplate, 'logo.png'), pngBySize.get(128));
writeIfChanged(path.join(docsTemplate, 'favicon.png'), pngBySize.get(64));
console.log('[icons] 全部图标生成完成');
app.exit(0);
} catch (error) {
console.error('[icons] 生成失败', error);
app.exit(1);
}
});