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
+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} 个内联哈希。`
);