4df8dc6f87
新建 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>
76 lines
2.8 KiB
JavaScript
76 lines
2.8 KiB
JavaScript
#!/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('✅ 脱敏扫描通过 — 未发现红线词。');
|