Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
Deploy website to GitHub Pages / Deploy static website (push) Canceled after 0s
Highlight domestic platform support and built-in coding agents, replace placeholder demos with task workflows, and add bilingual setup FAQs. Keep downloads directly after the hero and improve mobile demo layout. Release note: Update the bilingual website with clearer product copy and demos, prominent downloads, and setup guidance. DeepSeek Harness and LoongArch retain their preview labels.
676 lines
23 KiB
JavaScript
676 lines
23 KiB
JavaScript
import { readFile, stat } from "node:fs/promises";
|
||
import path from "node:path";
|
||
import { fileURLToPath } from "node:url";
|
||
|
||
const siteRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||
const errors = [];
|
||
|
||
const requiredFiles = [
|
||
"index.html",
|
||
"en.html",
|
||
"styles.css",
|
||
"app.js",
|
||
"language.js",
|
||
"release-index.js",
|
||
"assets/goodbuddy-light.png",
|
||
"assets/goodbuddy-dark.png",
|
||
"assets/linux-plain.svg",
|
||
"assets/devicon-LICENSE",
|
||
"assets/fonts/inter-latin-variable.woff2",
|
||
"assets/fonts/inter-OFL.txt",
|
||
"scripts/app.test.mjs",
|
||
"scripts/release-index.test.mjs",
|
||
"README.md",
|
||
];
|
||
|
||
const report = (condition, message) => {
|
||
if (!condition) {
|
||
errors.push(message);
|
||
}
|
||
};
|
||
|
||
const readSiteFile = async (relativePath) => {
|
||
try {
|
||
return await readFile(path.join(siteRoot, relativePath), "utf8");
|
||
} catch {
|
||
errors.push(`缺少文件:${relativePath}`);
|
||
return "";
|
||
}
|
||
};
|
||
|
||
await Promise.all(
|
||
requiredFiles.map(async (relativePath) => {
|
||
try {
|
||
const fileStats = await stat(path.join(siteRoot, relativePath));
|
||
report(fileStats.isFile(), `不是普通文件:${relativePath}`);
|
||
} catch {
|
||
errors.push(`缺少文件:${relativePath}`);
|
||
}
|
||
}),
|
||
);
|
||
|
||
const [html, englishHtml, css, appJs, languageJs, releaseIndexJs, fontLicense] =
|
||
await Promise.all([
|
||
readSiteFile("index.html"),
|
||
readSiteFile("en.html"),
|
||
readSiteFile("styles.css"),
|
||
readSiteFile("app.js"),
|
||
readSiteFile("language.js"),
|
||
readSiteFile("release-index.js"),
|
||
readSiteFile("assets/fonts/inter-OFL.txt"),
|
||
]);
|
||
|
||
for (const [relativePath, content] of [
|
||
["index.html", html],
|
||
["en.html", englishHtml],
|
||
["styles.css", css],
|
||
["app.js", appJs],
|
||
["language.js", languageJs],
|
||
["release-index.js", releaseIndexJs],
|
||
]) {
|
||
report(!/[ \t]+$/m.test(content), `${relativePath} 包含行尾空白`);
|
||
report(!content.includes("\t"), `${relativePath} 包含 Tab 缩进`);
|
||
}
|
||
|
||
report(/<html\s+lang="zh-CN">/.test(html), "页面语言必须是 zh-CN");
|
||
report(/<html\s+lang="en">/.test(englishHtml), "英文页面语言必须是 en");
|
||
report(/<meta\s+name="viewport"/.test(html), "缺少 viewport 元信息");
|
||
report(/<meta\s+name="viewport"/.test(englishHtml), "英文页面缺少 viewport 元信息");
|
||
report(
|
||
/<link\s+rel="canonical"\s+href="https:\/\/mesalogo\.github\.io\/goodbuddy\/"\s*\/>/.test(
|
||
html,
|
||
),
|
||
"canonical 地址必须指向 GitHub Pages 正式站点",
|
||
);
|
||
report(
|
||
/<link\s+rel="canonical"\s+href="https:\/\/mesalogo\.github\.io\/goodbuddy\/en\.html"\s*\/>/.test(
|
||
englishHtml,
|
||
),
|
||
"英文 canonical 地址必须指向 GitHub Pages 英文站点",
|
||
);
|
||
for (const [relativePath, content] of [
|
||
["index.html", html],
|
||
["en.html", englishHtml],
|
||
]) {
|
||
report(
|
||
/hreflang="zh-CN"\s+href="https:\/\/mesalogo\.github\.io\/goodbuddy\/"/.test(content),
|
||
`${relativePath} 缺少中文 alternate 链接`,
|
||
);
|
||
report(
|
||
/hreflang="en"\s+href="https:\/\/mesalogo\.github\.io\/goodbuddy\/en\.html"/.test(
|
||
content,
|
||
),
|
||
`${relativePath} 缺少英文 alternate 链接`,
|
||
);
|
||
report(
|
||
/<script\s+src="\.\/language\.js"><\/script>/.test(content),
|
||
`${relativePath} 缺少语言选择脚本`,
|
||
);
|
||
}
|
||
report((html.match(/<h1[\s>]/g) ?? []).length === 1, "页面必须且只能包含一个 h1");
|
||
report(
|
||
(englishHtml.match(/<h1[\s>]/g) ?? []).length === 1,
|
||
"英文页面必须且只能包含一个 h1",
|
||
);
|
||
report(/class="skip-link"\s+href="#main-content"/.test(html), "缺少跳到主要内容链接");
|
||
report(
|
||
/class="skip-link"\s+href="#main-content"/.test(englishHtml),
|
||
"英文页面缺少跳到主要内容链接",
|
||
);
|
||
report(/<main\s+id="main-content">/.test(html), "缺少 main-content 主区域");
|
||
report(/<main\s+id="main-content">/.test(englishHtml), "英文页面缺少 main-content 主区域");
|
||
report(/aria-label="主导航"/.test(html), "主导航缺少可访问名称");
|
||
report(/aria-label="Main navigation"/.test(englishHtml), "英文主导航缺少可访问名称");
|
||
report(/data-theme-toggle/.test(html), "缺少主题切换控件");
|
||
report(/data-theme-toggle/.test(englishHtml), "英文页面缺少主题切换控件");
|
||
report(
|
||
/href="\.\/en\.html\?lang=en"/.test(html),
|
||
"中文页面缺少英文语言切换入口",
|
||
);
|
||
report(
|
||
/href="\.\/index\.html\?lang=zh"/.test(englishHtml),
|
||
"英文页面缺少中文语言切换入口",
|
||
);
|
||
report(
|
||
/data-language-link/.test(html) && /data-language-link/.test(englishHtml),
|
||
"中英文语言切换入口必须标记为保留片段的手动切换",
|
||
);
|
||
report(
|
||
(html.match(/src="\.\/assets\/goodbuddy-light\.png"/g) ?? []).length >= 5,
|
||
"品牌位置必须使用官方亮色图标",
|
||
);
|
||
report(
|
||
(html.match(/src="\.\/assets\/goodbuddy-dark\.png"/g) ?? []).length >= 5,
|
||
"品牌位置必须使用官方深色图标",
|
||
);
|
||
report(
|
||
(englishHtml.match(/src="\.\/assets\/goodbuddy-light\.png"/g) ?? []).length >= 5,
|
||
"英文品牌位置必须使用官方亮色图标",
|
||
);
|
||
report(
|
||
(englishHtml.match(/src="\.\/assets\/goodbuddy-dark\.png"/g) ?? []).length >= 5,
|
||
"英文品牌位置必须使用官方深色图标",
|
||
);
|
||
report(!/class="brand-mark"/.test(html), "官网不得使用自绘品牌标志");
|
||
report(!/class="brand-mark"/.test(englishHtml), "英文官网不得使用自绘品牌标志");
|
||
report(/data-tilt-stage/.test(html), "首屏产品界面缺少倾斜交互区域");
|
||
report(/data-tilt-stage/.test(englishHtml), "英文首屏产品界面缺少倾斜交互区域");
|
||
report(/data-tilt-card/.test(html), "首屏产品界面缺少倾斜卡片");
|
||
report(/data-tilt-card/.test(englishHtml), "英文首屏产品界面缺少倾斜卡片");
|
||
report(/prefers-reduced-motion:\s*reduce/.test(css), "缺少减少动态效果规则");
|
||
report(/\[data-theme="dark"\]/.test(css), "缺少深色主题令牌");
|
||
report(/--scene-tilt-x/.test(css), "缺少产品界面横向倾斜变量");
|
||
report(/--spotlight-x/.test(css), "缺少产品界面动态光效变量");
|
||
report(
|
||
/\.floating-card[\s\S]*rotateX\(var\(--scene-tilt-x\)\)/.test(css),
|
||
"浮动标签必须跟随产品界面倾斜",
|
||
);
|
||
report(/requestAnimationFrame/.test(appJs), "产品界面倾斜交互必须按帧更新");
|
||
report(
|
||
/@font-face[\s\S]*font-family:\s*"Inter Variable"[\s\S]*inter-latin-variable\.woff2/.test(
|
||
css,
|
||
),
|
||
"官网必须使用本地 Inter Variable Latin 字体",
|
||
);
|
||
report(
|
||
!/@import\s+url|fonts\.(?:googleapis|gstatic)\.com|https?:\/\/[^)"']+\.(?:woff2?|ttf)/iu.test(
|
||
css,
|
||
),
|
||
"官网字体不得通过远程请求加载",
|
||
);
|
||
report(
|
||
/SIL OPEN FONT LICENSE Version 1\.1/.test(fontLicense),
|
||
"Inter 字体必须附带 OFL 1.1 许可证",
|
||
);
|
||
const fontStats = await stat(
|
||
path.join(siteRoot, "assets/fonts/inter-latin-variable.woff2"),
|
||
).catch(() => null);
|
||
report(
|
||
fontStats?.isFile() && fontStats.size >= 20_000 && fontStats.size <= 100_000,
|
||
"Inter Latin 字体文件大小应保持在 20 KB 到 100 KB",
|
||
);
|
||
|
||
const hexToLuminance = (hex) => {
|
||
const channels = [1, 3, 5].map(
|
||
(offset) => Number.parseInt(hex.slice(offset, offset + 2), 16) / 255,
|
||
);
|
||
const linear = channels.map((channel) =>
|
||
channel <= 0.04045
|
||
? channel / 12.92
|
||
: ((channel + 0.055) / 1.055) ** 2.4,
|
||
);
|
||
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2];
|
||
};
|
||
|
||
const contrastRatio = (foreground, background) => {
|
||
const foregroundLuminance = hexToLuminance(foreground);
|
||
const backgroundLuminance = hexToLuminance(background);
|
||
return (
|
||
(Math.max(foregroundLuminance, backgroundLuminance) + 0.05) /
|
||
(Math.min(foregroundLuminance, backgroundLuminance) + 0.05)
|
||
);
|
||
};
|
||
|
||
const lightThemeBlock = css.match(/:root\s*\{([\s\S]*?)\n\}/)?.[1] ?? "";
|
||
const getLightToken = (name) =>
|
||
lightThemeBlock.match(new RegExp(`--${name}:\\s*(#[0-9a-fA-F]{6})`))?.[1];
|
||
const mutedColor = getLightToken("text-muted");
|
||
const controlBorder = getLightToken("border-control");
|
||
const raisedSurface = getLightToken("surface-raised");
|
||
const subtleSurface = getLightToken("surface-subtle");
|
||
const canvasSurface = getLightToken("surface-canvas");
|
||
for (const [label, foreground, background, minimum] of [
|
||
["浅色弱文本/画布", mutedColor, canvasSurface, 4.5],
|
||
["浅色弱文本/卡片", mutedColor, raisedSurface, 4.5],
|
||
["浅色弱文本/次级表面", mutedColor, subtleSurface, 4.5],
|
||
["浅色控件边框/卡片", controlBorder, raisedSurface, 3],
|
||
["浅色控件边框/次级表面", controlBorder, subtleSurface, 3],
|
||
]) {
|
||
report(
|
||
foreground &&
|
||
background &&
|
||
contrastRatio(foreground, background) >= minimum,
|
||
`${label} 对比度必须至少达到 ${minimum}:1`,
|
||
);
|
||
}
|
||
for (const selector of ["language-link", "icon-button", "button--quiet"]) {
|
||
report(
|
||
new RegExp(
|
||
`\\.${selector}\\s*\\{[^}]*border(?:-color)?:\\s*(?:1px solid )?var\\(--border-control\\)`,
|
||
).test(css),
|
||
`${selector} 必须使用达到 3:1 的控件边框`,
|
||
);
|
||
}
|
||
report(/@media\s*\(forced-colors:\s*active\)/.test(css), "缺少强制颜色模式适配");
|
||
|
||
for (const breakpoint of ["1199px", "959px", "719px"]) {
|
||
report(css.includes(`max-width: ${breakpoint}`), `缺少 ${breakpoint} 响应式断点`);
|
||
}
|
||
|
||
const requiredCopy = [
|
||
"无需注册 GoodBuddy 账号",
|
||
"面向国产化",
|
||
"与信创环境的",
|
||
"产品亮点",
|
||
"桌面 AI 工作空间",
|
||
"桌面版本覆盖国产 x64、ARM64 和龙芯 LoongArch",
|
||
"龙芯 LoongArch 独立编译预览版",
|
||
"龙芯 LoongArch(实验预览)",
|
||
"桌面助手",
|
||
"AI 编程",
|
||
"Windows、macOS、Linux",
|
||
"统信 UOS",
|
||
"银河麒麟",
|
||
"海光 · 兆芯(x64)",
|
||
"鲲鹏 · 飞腾(ARM64)",
|
||
"内置集成 OpenCode、Continue",
|
||
"DeepSeek Harness",
|
||
"魔法笔记",
|
||
"智能心跳",
|
||
"文件、截图、应用窗口、剪贴板和离线语音",
|
||
"微信、企业微信或钉钉",
|
||
];
|
||
|
||
for (const copy of requiredCopy) {
|
||
report(html.includes(copy), `缺少准确文案:${copy}`);
|
||
}
|
||
|
||
const requiredEnglishCopy = [
|
||
"No GoodBuddy account required",
|
||
"Product highlights",
|
||
"Windows, macOS, and Linux",
|
||
"LoongArch has a separate experimental preview",
|
||
"Direct models",
|
||
"OpenCode",
|
||
"Continue",
|
||
"DeepSeek Harness",
|
||
"Download from GitHub",
|
||
];
|
||
|
||
for (const copy of requiredEnglishCopy) {
|
||
report(englishHtml.includes(copy), `英文页面缺少准确文案:${copy}`);
|
||
}
|
||
|
||
for (const [relativePath, content] of [["index.html", html], ["en.html", englishHtml]]) {
|
||
for (const id of ["workflows", "faq"]) {
|
||
report(content.includes(`id="${id}"`), `${relativePath} 缺少 ${id} 区域`);
|
||
}
|
||
const mainContent = content.match(/<main\b[^>]*>([\s\S]*?)<\/main>/)?.[1] ?? "";
|
||
const mainSections = [...mainContent.matchAll(/<section\b[^>]*>/g)];
|
||
report(/\sid="download"/.test(mainSections[1]?.[0] ?? ""),
|
||
`${relativePath} 下载区必须是 main 中的第二个 section`);
|
||
report((content.match(/<summary>/g) ?? []).length >= 4,
|
||
`${relativePath} 常见问题必须使用原生可展开控件`);
|
||
report(!/approval-controlled|受控执行|继续经过审批/.test(content),
|
||
`${relativePath} 不得保留过时的执行审批文案`);
|
||
}
|
||
|
||
for (const forbiddenCopy of ["信创", "国产", "统信 UOS", "银河麒麟", "海光", "兆芯", "鲲鹏", "飞腾"]) {
|
||
report(!englishHtml.includes(forbiddenCopy), `英文页面不得包含中文信创文案:${forbiddenCopy}`);
|
||
}
|
||
|
||
for (const [relativePath, content] of [
|
||
["index.html", html],
|
||
["en.html", englishHtml],
|
||
]) {
|
||
const contentWithoutSvg = content.replace(/<svg\b[\s\S]*?<\/svg>/g, "");
|
||
report(
|
||
!/\bv?\d+\.\d+\.\d+\b/.test(contentWithoutSvg),
|
||
`${relativePath} 正文不得写入需要随发布更新的具体版本号`,
|
||
);
|
||
}
|
||
|
||
const releaseLinks = [
|
||
...html.matchAll(/<a\b(?=[^>]*data-release-link)[^>]*>/g),
|
||
].map((match) => match[0]);
|
||
report(releaseLinks.length >= 3, "缺少三个桌面系统的官方下载入口");
|
||
for (const link of releaseLinks) {
|
||
report(
|
||
!/\shref=/.test(link),
|
||
`中文下载入口加载期间不得预先指向 GitHub:${link}`,
|
||
);
|
||
report(/aria-disabled="true"/.test(link), `中文下载入口加载期间必须禁用:${link}`);
|
||
report(/tabindex="-1"/.test(link), `禁用的中文下载入口不得进入 Tab 顺序:${link}`);
|
||
report(/\bis-disabled\b/.test(link), `中文下载入口缺少禁用样式:${link}`);
|
||
}
|
||
report(
|
||
(html.match(/data-download-card="(?:windows|macos|linux)"/g) ?? []).length === 3,
|
||
"下载区必须包含 Windows、macOS 和 Linux 选择器",
|
||
);
|
||
report(
|
||
(html.match(/data-download-arch/g) ?? []).length === 3,
|
||
"每个平台必须提供处理器架构选择器",
|
||
);
|
||
report(
|
||
(html.match(/data-download-format/g) ?? []).length === 3,
|
||
"每个平台必须提供安装包类型选择器",
|
||
);
|
||
report(
|
||
/<option\s+value="loong64">龙芯 LoongArch(实验预览)<\/option>/.test(
|
||
html,
|
||
),
|
||
"Linux 处理器选项必须包含龙芯实验预览",
|
||
);
|
||
report(
|
||
/const setLoongArchFormat =[\s\S]*option\.value !== "deb"[\s\S]*formatSelect\.value = "deb"[\s\S]*formatSelect\.disabled = selected/.test(
|
||
appJs,
|
||
),
|
||
"龙芯处理器必须只允许选择 DEB",
|
||
);
|
||
report(
|
||
!/data-release-status|download-release-status/.test(`${html}\n${englishHtml}\n${css}\n${appJs}`),
|
||
"官网不得显示下载源状态提示",
|
||
);
|
||
const englishReleaseLinks = [
|
||
...englishHtml.matchAll(
|
||
/<a\b(?=[^>]*href="https:\/\/github\.com\/mesalogo\/goodbuddy\/releases\/latest")[^>]*>/g,
|
||
),
|
||
].map((match) => match[0]);
|
||
report(englishReleaseLinks.length === 3, "英文页面必须包含三个 GitHub Release 下载入口");
|
||
for (const link of englishReleaseLinks) {
|
||
report(/target="_blank"/.test(link), `英文下载入口必须在新窗口打开:${link}`);
|
||
report(/rel="[^"]*noreferrer[^"]*"/.test(link), `英文下载入口缺少 noreferrer:${link}`);
|
||
}
|
||
report(
|
||
!/data-download-card|data-download-meta|data-release-link/.test(englishHtml),
|
||
"英文下载入口必须保持为直接 GitHub Release 链接",
|
||
);
|
||
report(
|
||
/<script\s+src="\.\/release-index\.js"><\/script>\s*<script\s+src="\.\/app\.js"><\/script>/.test(
|
||
html,
|
||
),
|
||
"中文页面必须在交互脚本前加载发布索引校验器",
|
||
);
|
||
report(
|
||
!/release-index\.js/.test(englishHtml),
|
||
"英文页面不得加载动态发布索引校验器",
|
||
);
|
||
report(
|
||
appJs.includes(
|
||
"https://goodbuddy.oss-cn-beijing.aliyuncs.com/releases/latest.json",
|
||
),
|
||
"官网必须从 GoodBuddy OSS 加载最新发布索引",
|
||
);
|
||
report(
|
||
appJs.includes(
|
||
"https://goodbuddy.oss-cn-beijing.aliyuncs.com/releases/loongarch-preview/latest.json",
|
||
),
|
||
"官网必须从独立 OSS 索引加载龙芯预览包",
|
||
);
|
||
report(
|
||
/validateLoongArchPreviewIndex\(payload\)/.test(appJs),
|
||
"龙芯预览下载链接必须先通过独立索引校验",
|
||
);
|
||
report(
|
||
/catch\s*\{[\s\S]*setLoongArchPreviewUnavailable\(\)/.test(appJs),
|
||
"龙芯预览索引失败时必须保持下载入口禁用",
|
||
);
|
||
report(
|
||
releaseIndexJs.includes(
|
||
"https://goodbuddy.oss-cn-beijing.aliyuncs.com/releases/loongarch-preview/latest.json",
|
||
) &&
|
||
/const validateLoongArchPreviewIndex =/.test(releaseIndexJs),
|
||
"龙芯预览索引必须使用独立受信任前缀和校验器",
|
||
);
|
||
report(
|
||
appJs.includes("https://github.com/mesalogo/goodbuddy/releases/latest"),
|
||
"官网必须保留 GitHub Release 回退地址",
|
||
);
|
||
report(/credentials:\s*"omit"/.test(appJs), "OSS 发布索引请求不得携带凭据");
|
||
report(/redirect:\s*"error"/.test(appJs), "OSS 发布索引请求不得跟随重定向");
|
||
report(/referrerPolicy:\s*"no-referrer"/.test(appJs), "OSS 发布索引请求必须禁用来源信息");
|
||
report(/maximumIndexBytes/.test(appJs), "OSS 发布索引响应缺少大小上限");
|
||
report(/response\.body\.getReader\(\)/.test(appJs), "OSS 发布索引响应必须在读取时限制大小");
|
||
report(/AbortController/.test(appJs), "OSS 发布索引请求必须设置超时取消");
|
||
report(
|
||
/validateReleaseIndex\(payload\)/.test(appJs),
|
||
"动态下载链接必须先通过完整发布索引校验",
|
||
);
|
||
report(
|
||
/const setFallbackDownloads[\s\S]*catch\s*\{[\s\S]*setFallbackDownloads\(\)/.test(
|
||
appJs,
|
||
),
|
||
"发布索引任一错误必须让全部下载入口回退 GitHub",
|
||
);
|
||
report(
|
||
/replaceChildren\(document\.createTextNode\(visibleText\)\)[\s\S]*append\(newWindowNotice\)/.test(
|
||
appJs,
|
||
),
|
||
"动态更新下载链接时必须保留新窗口的屏幕阅读器提示",
|
||
);
|
||
report(/if\s*\(!isEnglish\)\s*\{\s*void loadRelease\(\)/.test(appJs), "英文页面不得请求 OSS 发布索引");
|
||
for (const listenerRule of [
|
||
'typeof query.addEventListener === "function"',
|
||
'typeof query.addListener === "function"',
|
||
"listenMediaQuery(systemTheme",
|
||
"listenMediaQuery(finePointer",
|
||
"listenMediaQuery(reducedMotion",
|
||
"listenMediaQuery(mobileMenu",
|
||
]) {
|
||
report(appJs.includes(listenerRule), `媒体查询监听缺少兼容规则:${listenerRule}`);
|
||
}
|
||
for (const menuRule of [
|
||
"isolatedMenuContent = new Map()",
|
||
"element === menuBackdrop",
|
||
"element.inert = true",
|
||
"element.inert = wasInert",
|
||
'navigation?.querySelector("a")?.focus()',
|
||
"closeMenu({ restoreFocus: false })",
|
||
'menuBackdrop?.addEventListener("click", () => closeMenu())',
|
||
]) {
|
||
report(appJs.includes(menuRule), `移动导航隔离或焦点管理缺少规则:${menuRule}`);
|
||
}
|
||
report(
|
||
/const semVerPattern[\s\S]*const sha256Pattern[\s\S]*const targetDefinitions/.test(
|
||
releaseIndexJs,
|
||
),
|
||
"发布索引校验器缺少 SemVer、SHA-256 或目标定义",
|
||
);
|
||
for (const rule of [
|
||
"windows-x64",
|
||
"windows-arm64",
|
||
"macos-x64",
|
||
"macos-arm64",
|
||
"linux-x64",
|
||
"linux-arm64",
|
||
"SHA256SUMS",
|
||
"encodeURIComponent(file.name)",
|
||
"!url.username",
|
||
"!url.password",
|
||
"!url.port",
|
||
"!url.search",
|
||
"!url.hash",
|
||
"canonicalFileName",
|
||
"GoodBuddy-${version}-windows-${arch}-setup.exe",
|
||
"GoodBuddy-${version}-windows-${arch}-portable.zip",
|
||
"GoodBuddy-${version}-mac-${arch}.${format}",
|
||
'"x86_64"',
|
||
'"amd64"',
|
||
]) {
|
||
report(releaseIndexJs.includes(rule), `发布索引校验器缺少规则:${rule}`);
|
||
}
|
||
report(
|
||
/navigator\.languages\?\.\[0\]/.test(languageJs),
|
||
"语言选择必须读取浏览器首选语言",
|
||
);
|
||
report(
|
||
/goodbuddy-site-language/.test(languageJs),
|
||
"语言选择必须记住用户的手动切换",
|
||
);
|
||
report(
|
||
/window\.location\.replace/.test(languageJs),
|
||
"语言选择缺少自动页面切换",
|
||
);
|
||
report(
|
||
/targetUrl\.hash\s*=\s*window\.location\.hash/.test(languageJs),
|
||
"手动切换语言必须保留当前页面片段",
|
||
);
|
||
|
||
for (const [relativePath, content] of [
|
||
["index.html", html],
|
||
["en.html", englishHtml],
|
||
]) {
|
||
const menuButton = content.match(
|
||
/<button\b(?=[^>]*data-menu-toggle)[^>]*>/,
|
||
)?.[0];
|
||
const controlsId = menuButton?.match(/aria-controls="([^"]+)"/)?.[1];
|
||
report(
|
||
Boolean(controlsId) && content.includes(`id="${controlsId}"`),
|
||
`${relativePath} 移动导航按钮必须关联现有导航区域`,
|
||
);
|
||
report(
|
||
menuButton?.includes('aria-expanded="false"'),
|
||
`${relativePath} 移动导航必须声明初始折叠状态`,
|
||
);
|
||
report(
|
||
/<div\s+class="menu-backdrop"\s+aria-hidden="true"\s+data-menu-backdrop><\/div>/.test(
|
||
content,
|
||
),
|
||
`${relativePath} 移动导航缺少页外点击关闭层`,
|
||
);
|
||
}
|
||
report(
|
||
/@media\s*\(max-width:\s*719px\)[\s\S]*\.menu-toggle\s*\{[\s\S]*display:\s*inline-grid/.test(
|
||
css,
|
||
) &&
|
||
/@media\s*\(max-width:\s*719px\)[\s\S]*\.site-header\.is-menu-open \.site-navigation\s*\{[\s\S]*display:\s*flex/.test(
|
||
css,
|
||
) &&
|
||
/@media\s*\(max-width:\s*719px\)[\s\S]*\.menu-backdrop\.is-active\s*\{[\s\S]*display:\s*block/.test(
|
||
css,
|
||
),
|
||
"移动断点必须显示菜单按钮、页外关闭层并支持展开导航",
|
||
);
|
||
|
||
const expectedDownloadOptions = {
|
||
windows: {
|
||
arches: ["x64", "arm64"],
|
||
formats: ["nsis", "portable"],
|
||
},
|
||
macos: {
|
||
arches: ["arm64", "x64"],
|
||
formats: ["dmg"],
|
||
},
|
||
linux: {
|
||
arches: ["x64", "arm64", "loong64"],
|
||
formats: ["AppImage", "deb", "rpm"],
|
||
},
|
||
};
|
||
for (const [platform, expected] of Object.entries(expectedDownloadOptions)) {
|
||
const card = html.match(
|
||
new RegExp(
|
||
`data-download-card="${platform}"([\\s\\S]*?)<\\/article>`,
|
||
),
|
||
)?.[1];
|
||
const archOptions = [
|
||
...(card ?? "").matchAll(/<option\s+value="([^"]+)"/g),
|
||
].map((match) => match[1]);
|
||
report(
|
||
expected.arches.every((arch, index) => archOptions[index] === arch) &&
|
||
expected.formats.every(
|
||
(format, index) => archOptions[index + expected.arches.length] === format,
|
||
) &&
|
||
archOptions.length === expected.arches.length + expected.formats.length,
|
||
`${platform} 下载控件的架构或格式选项无效`,
|
||
);
|
||
report(
|
||
(card?.match(/<select\b[^>]*aria-label="[^"]+"/g) ?? []).length === 2,
|
||
`${platform} 下载选择器必须有可访问名称`,
|
||
);
|
||
}
|
||
|
||
let totalIds = 0;
|
||
let totalLocalAssets = 0;
|
||
for (const [relativePath, content] of [
|
||
["index.html", html],
|
||
["en.html", englishHtml],
|
||
]) {
|
||
const ids = [...content.matchAll(/\sid="([^"]+)"/g)].map((match) => match[1]);
|
||
totalIds += ids.length;
|
||
const duplicateIds = ids.filter((id, index) => ids.indexOf(id) !== index);
|
||
report(
|
||
duplicateIds.length === 0,
|
||
`${relativePath} 存在重复 id:${[...new Set(duplicateIds)].join(", ")}`,
|
||
);
|
||
|
||
const attributes = [...content.matchAll(/\s(?:href|src)="([^"]+)"/g)].map(
|
||
(match) => match[1],
|
||
);
|
||
const fragmentLinks = attributes.filter(
|
||
(value) => value.startsWith("#") && value.length > 1,
|
||
);
|
||
for (const fragment of fragmentLinks) {
|
||
report(ids.includes(fragment.slice(1)), `${relativePath} 页内链接目标不存在:${fragment}`);
|
||
}
|
||
|
||
const localAssets = attributes.filter(
|
||
(value) =>
|
||
!value.startsWith("#") &&
|
||
!value.startsWith("https://") &&
|
||
!value.startsWith("http://") &&
|
||
!value.startsWith("mailto:") &&
|
||
!value.startsWith("data:"),
|
||
);
|
||
totalLocalAssets += localAssets.length;
|
||
for (const asset of localAssets) {
|
||
const cleanAsset = asset.split(/[?#]/, 1)[0].replace(/^\.\//, "");
|
||
try {
|
||
const assetStats = await stat(path.join(siteRoot, cleanAsset));
|
||
report(assetStats.isFile(), `${relativePath} 本地资源不是文件:${asset}`);
|
||
} catch {
|
||
errors.push(`${relativePath} 本地资源不存在:${asset}`);
|
||
}
|
||
}
|
||
|
||
const externalBlankLinks = [
|
||
...content.matchAll(/<a\b(?=[^>]*target="_blank")[^>]*>/g),
|
||
].map((match) => match[0]);
|
||
for (const link of externalBlankLinks) {
|
||
report(
|
||
/rel="[^"]*noreferrer[^"]*"/.test(link),
|
||
`${relativePath} 新窗口链接缺少 noreferrer:${link}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
const cssAssets = [...css.matchAll(/url\(["']?([^"')]+)["']?\)/g)].map(
|
||
(match) => match[1],
|
||
);
|
||
for (const asset of cssAssets) {
|
||
if (/^(?:data:|https?:)/u.test(asset)) {
|
||
continue;
|
||
}
|
||
const cleanAsset = asset.split(/[?#]/, 1)[0].replace(/^\.\//, "");
|
||
try {
|
||
const assetStats = await stat(path.join(siteRoot, cleanAsset));
|
||
report(assetStats.isFile(), `CSS 本地资源不是文件:${asset}`);
|
||
} catch {
|
||
errors.push(`CSS 本地资源不存在:${asset}`);
|
||
}
|
||
}
|
||
|
||
report(
|
||
!/<a\b[^>]*href="[^"]+\.(?:exe|dmg|zip|AppImage|deb|rpm)(?:[?#][^"]*)?"/i.test(
|
||
`${html}\n${englishHtml}`,
|
||
),
|
||
"具体安装资产链接应由 OSS 发布索引动态提供",
|
||
);
|
||
report(
|
||
!/(?:react|vue|angular|bootstrap|tailwind)(?:\.min)?\.(?:js|css)/i.test(
|
||
`${html}\n${englishHtml}`,
|
||
),
|
||
"静态官网不得引入额外框架资源",
|
||
);
|
||
|
||
if (errors.length > 0) {
|
||
console.error(`官网静态检查失败(${errors.length} 项):`);
|
||
for (const error of errors) {
|
||
console.error(`- ${error}`);
|
||
}
|
||
process.exitCode = 1;
|
||
} else {
|
||
console.log(
|
||
`官网静态检查通过:${requiredFiles.length} 个必需文件,${totalIds} 个唯一 id,${totalLocalAssets} 个本地资源引用。`,
|
||
);
|
||
}
|