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:
@@ -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 —
|
||||
* 性能 + 隐私 + 可达性,且严格 CSP(style-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)`);
|
||||
@@ -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('✅ 脱敏扫描通过 — 未发现红线词。');
|
||||
@@ -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} 个内联哈希。`
|
||||
);
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user