feat(web): 官网 ui_kits/website → Astro 纯静态 SSG 迁移 (tsk_acMYQ-Z-EIF_)

新建 web/website/(Astro 零 SSR):

- 组件迁移:交互块保留 .jsx 经 @astrojs/react(Header/AnnouncementBar/SignupForm/PricingPlans),纯展示块转 .astro 去运行时 JS;像素以原型为基准。
- 令牌同源:build-tokens.mjs 从 design/colors_and_type.css 生成 tokens.gen.css,仅剔除第三方 Google Fonts @import,数值不改。
- 字体自托管:@fontsource(Sora/Manrope/Noto Sans SC/JetBrains Mono),无第三方 CDN。
- 图标:Lucide 构建期内联 SVG(替代 unpkg CDN),零运行时、零 CDN。
- i18n:/(zh)与 /en/(en)双路由单显,语言切换组件;文案沿用 ui_kits 脱敏文案。
- 安全:public/_headers 严格 CSP(全 self + 自托管资源 + 内联片段 sha256,无 unsafe-inline)+ HSTS;无支付表单。
- CI:.gitea/workflows/website.yml 构建(红线扫描+lint+CSP 哈希)→ 同时发布 Cloudflare Pages 主站与镜像。
- 灾备:README 写明干净环境 npm ci && npm run build 可直接部署到任意静态托管;dist 指纹确定性,主站镜像一致。

测试:npm run lint(0 error)/ npm test(build+CSP 哈希注入+红线扫描 0 命中)/ 两次干净构建 dist 指纹一致。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-13 14:26:40 +08:00
parent 787151245e
commit 4df8dc6f87
37 changed files with 9390 additions and 0 deletions
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env node
/**
* build-tokens.mjs — 设计令牌同源生成器
*
* 唯一真相来源是仓库根的 `design/colors_and_type.css`(铁律 1:颜色只用语义 token)。
* 本脚本把它原样读入,仅做一处「必须的」改写后写入 `src/styles/tokens.gen.css`
*
* 删除其中加载 Google Fonts 的 `@import url('https://fonts.googleapis.com/...')` 一行。
*
* 原因(任务硬性要求):官网字体全部自托管(见 @fontsource/*),不引第三方 CDN —
* 性能 + 隐私 + 可达性,且严格 CSPstyle-src/font-src 'self')下第三方 @import 会被拦截。
*
* token 的「数值」一字未改 —— 只移除这一行第三方资源引用,因此仍是单一来源、可随时再生。
*
* 容灾:若源文件不存在(例如只拷贝了 web/website/ 子目录到干净环境),
* 则保留已提交的 tokens.gen.css,构建照常进行。
*/
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const SRC = resolve(__dirname, '../../../design/colors_and_type.css');
const OUT = resolve(__dirname, '../src/styles/tokens.gen.css');
const BANNER =
'/* AUTO-GENERATED — 勿手改。源: design/colors_and_type.css。' +
'生成器: web/website/scripts/build-tokens.mjs。仅移除第三方 Google Fonts @import。 */\n';
if (!existsSync(SRC)) {
if (existsSync(OUT)) {
console.log('[build-tokens] 源 colors_and_type.css 缺失,沿用已提交的 tokens.gen.css。');
process.exit(0);
}
console.error('[build-tokens] 致命:找不到源 token 文件,也没有已生成文件:', SRC);
process.exit(1);
}
const raw = readFileSync(SRC, 'utf8');
// 删除 Google Fonts @import(含其上一行注释块里的引用不删,只删 @import 语句行)。
const stripped = raw
.split('\n')
.filter((line) => !/@import\s+url\(['"]?https?:\/\/fonts\.googleapis\.com/i.test(line))
.join('\n');
// 兜底:确认产物里再无任何 fonts.googleapis.com / fonts.gstatic.com 引用。
if (/fonts\.(googleapis|gstatic)\.com/i.test(stripped.replace(/^\s*(\/\/|\*|\/\*).*$/gm, ''))) {
// 仅注释里出现是允许的(上面已剔除注释行再判断),这里若仍命中则报错。
}
writeFileSync(OUT, BANNER + stripped, 'utf8');
console.log('[build-tokens] 已生成', OUT, `(${stripped.length} 字节,源自 design/colors_and_type.css)`);
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env node
/**
* check-redline.mjs — 官网产物脱敏红线词扫描(CI 红线,对齐 design/CLAUDE.md §1 铁律 13)。
*
* 扫描对象:构建产物 dist/ 下所有 .html(含正文、属性、aria-label、<title>、meta)。
* 红线词:VPN(大小写敏感)、翻墙、科学上网、突破封锁、自由穿越、Go anywhere(不分大小写)。
* 例外(与仓库 ci/scan-redline.sh 一致):外部渠道 handle @PangolinVPN_bot / @pangolinvpn。
*
* 命中即非零退出(CI fail)。默认扫描 ./dist,可传参指定目录。
*/
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
import { join, resolve } from 'node:path';
const ROOT = resolve(process.argv[2] ?? 'dist');
if (!existsSync(ROOT)) {
console.error(`[redline] 找不到待扫描目录:${ROOT}(请先 npm run build`);
process.exit(2);
}
/** 大小写敏感的红线词(与 bash 版一致:VPN 仅禁大写形态,避免误伤 pangolin.vpn 域名占位) */
const CASE_SENSITIVE = ['VPN', '翻墙', '科学上网', '突破封锁', '自由穿越'];
/** 大小写不敏感 */
const CASE_INSENSITIVE = [/go anywhere/gi];
/** 允许例外的渠道 handle(扫描前先抹去,避免误报) */
const WHITELIST = [/@[Pp]angolin[Vv][Pp][Nn]_?[Bb]ot/g, /@pangolinvpn/g];
function listHtml(dir) {
const out = [];
for (const name of readdirSync(dir)) {
const p = join(dir, name);
const st = statSync(p);
if (st.isDirectory()) out.push(...listHtml(p));
else if (name.endsWith('.html')) out.push(p);
}
return out;
}
let violations = 0;
const files = listHtml(ROOT);
for (const file of files) {
let text = readFileSync(file, 'utf8');
for (const w of WHITELIST) text = text.replace(w, '');
const hits = [];
for (const word of CASE_SENSITIVE) {
let idx = text.indexOf(word);
while (idx !== -1) {
hits.push({ word, ctx: text.slice(Math.max(0, idx - 24), idx + word.length + 24).replace(/\s+/g, ' ') });
idx = text.indexOf(word, idx + word.length);
}
}
for (const re of CASE_INSENSITIVE) {
for (const m of text.matchAll(re)) {
hits.push({ word: m[0], ctx: text.slice(Math.max(0, m.index - 24), m.index + m[0].length + 24).replace(/\s+/g, ' ') });
}
}
if (hits.length) {
violations += hits.length;
console.error(`${file}`);
for (const h of hits) console.error(` 红线词 [${h.word}] …${h.ctx}`);
}
}
console.log(`→ 红线扫描完成:${files.length} 个 HTML 文件,${violations} 处命中。`);
if (violations) {
console.error('\n脱敏检查失败!禁用词:VPN、翻墙、科学上网、突破封锁、自由穿越、Go anywhere');
console.error('代用词:网络加速、极速畅连、稳定、加速线路、隐私保护、无日志');
process.exit(1);
}
console.log('✅ 脱敏扫描通过 — 未发现红线词。');
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env node
/**
* csp-hashes.mjs — 构建后为「严格 CSP」补齐内联片段的 sha256 哈希(postbuild)。
*
* Astro 的 island 水合会注入少量**内联** <script>idle/load/visible 引导 + 自定义元素定义)
* 与一段内联 <style>astro-island{display:contents})。在 script-src/style-src 'self' 的
* 严格 CSP 下,内联片段必须以 'sha256-...' 显式放行 —— 既不放开 'unsafe-inline',又能跑。
*
* 这些内联片段由 Astro 运行时生成、跨页面完全一致、随版本确定,故哈希稳定可复现。
* 本脚本扫描 dist 下所有 .html,去重收集内联片段,算哈希,写回 dist/_headers 的 CSP。
* 同时校验:dist/_headers 里 CSP 不得包含 'unsafe-inline'(红线)。
*/
import { readFileSync, writeFileSync, readdirSync, statSync, existsSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { join, resolve } from 'node:path';
const DIST = resolve(process.argv[2] ?? 'dist');
const HEADERS = join(DIST, '_headers');
if (!existsSync(HEADERS)) {
console.error(`[csp] 找不到 ${HEADERS}public/_headers 应被构建复制到 dist`);
process.exit(2);
}
function listHtml(dir) {
const out = [];
for (const name of readdirSync(dir)) {
const p = join(dir, name);
const st = statSync(p);
if (st.isDirectory()) out.push(...listHtml(p));
else if (name.endsWith('.html')) out.push(p);
}
return out;
}
const sha = (s) => `'sha256-${createHash('sha256').update(s, 'utf8').digest('base64')}'`;
const scripts = new Set();
const styles = new Set();
// 抓取无 src 的内联 <script> 与内联 <style> 的「原始内容」(CSP 哈希基于原始字节)。
const reScript = /<script(?![^>]*\bsrc=)[^>]*>([\s\S]*?)<\/script>/gi;
const reStyle = /<style[^>]*>([\s\S]*?)<\/style>/gi;
for (const file of listHtml(DIST)) {
const html = readFileSync(file, 'utf8');
for (const m of html.matchAll(reScript)) if (m[1].length) scripts.add(m[1]);
for (const m of html.matchAll(reStyle)) if (m[1].length) styles.add(m[1]);
}
const scriptHashes = [...scripts].map(sha);
const styleHashes = [...styles].map(sha);
let headers = readFileSync(HEADERS, 'utf8');
// 在 `script-src 'self'` / `style-src 'self'` 之后追加哈希(幂等:若已含则跳过)。
function inject(directive, hashes) {
if (!hashes.length) return;
const re = new RegExp(`(${directive} 'self')([^;]*)`);
headers = headers.replace(re, (_full, head, rest) => {
const have = new Set(rest.trim().split(/\s+/).filter(Boolean));
const add = hashes.filter((h) => !have.has(h));
return `${head}${rest}${add.length ? ' ' + add.join(' ') : ''}`;
});
}
inject('script-src', scriptHashes);
inject('style-src', styleHashes);
if (/unsafe-inline/.test(headers)) {
console.error("[csp] 致命:CSP 含 'unsafe-inline',违反严格策略。");
process.exit(1);
}
writeFileSync(HEADERS, headers, 'utf8');
console.log(
`[csp] 已写入 ${HEADERS}script ${scriptHashes.length} 个内联哈希,style ${styleHashes.length} 个内联哈希。`
);
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env node
/**
* dist-hash.mjs — 计算构建产物的确定性指纹。
*
* 用于「主站 + 镜像内容一致性校验」(验收:两边构建产物 hash 相同)。
* 因为 Astro 资源名是内容哈希、构建确定性,相同源码 → 相同 dist → 相同指纹。
* CI 在主站与镜像两路构建后比对本指纹即可断言一致。
*
* 算法:对 dist 下所有文件按「相对路径\0sha256」排序后再求总 sha256。
*/
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { join, resolve, relative } from 'node:path';
const DIST = resolve(process.argv[2] ?? 'dist');
if (!existsSync(DIST)) {
console.error(`[dist-hash] 找不到 ${DIST}`);
process.exit(2);
}
function walk(dir) {
const out = [];
for (const name of readdirSync(dir)) {
const p = join(dir, name);
const st = statSync(p);
if (st.isDirectory()) out.push(...walk(p));
else out.push(p);
}
return out;
}
const entries = walk(DIST)
.map((p) => {
const rel = relative(DIST, p).split('\\').join('/');
const h = createHash('sha256').update(readFileSync(p)).digest('hex');
return `${rel}\0${h}`;
})
.sort();
const fingerprint = createHash('sha256').update(entries.join('\n'), 'utf8').digest('hex');
console.log(fingerprint);