166 lines
5.2 KiB
JavaScript
166 lines
5.2 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",
|
||
"styles.css",
|
||
"app.js",
|
||
"site.config.js",
|
||
"assets/favicon.svg",
|
||
"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, css, appJs, configJs] = await Promise.all([
|
||
readSiteFile("index.html"),
|
||
readSiteFile("styles.css"),
|
||
readSiteFile("app.js"),
|
||
readSiteFile("site.config.js"),
|
||
]);
|
||
|
||
for (const [relativePath, content] of [
|
||
["index.html", html],
|
||
["styles.css", css],
|
||
["app.js", appJs],
|
||
["site.config.js", configJs],
|
||
]) {
|
||
report(!/[ \t]+$/m.test(content), `${relativePath} 包含行尾空白`);
|
||
report(!content.includes("\t"), `${relativePath} 包含 Tab 缩进`);
|
||
}
|
||
|
||
report(/<html\s+lang="zh-CN">/.test(html), "页面语言必须是 zh-CN");
|
||
report(/<meta\s+name="viewport"/.test(html), "缺少 viewport 元信息");
|
||
report((html.match(/<h1[\s>]/g) ?? []).length === 1, "页面必须且只能包含一个 h1");
|
||
report(/class="skip-link"\s+href="#main-content"/.test(html), "缺少跳到主要内容链接");
|
||
report(/<main\s+id="main-content">/.test(html), "缺少 main-content 主区域");
|
||
report(/aria-label="主导航"/.test(html), "主导航缺少可访问名称");
|
||
report(/data-theme-toggle/.test(html), "缺少主题切换控件");
|
||
report(/prefers-reduced-motion:\s*reduce/.test(css), "缺少减少动态效果规则");
|
||
report(/\[data-theme="dark"\]/.test(css), "缺少深色主题令牌");
|
||
|
||
for (const breakpoint of ["1199px", "959px", "719px"]) {
|
||
report(css.includes(`max-width: ${breakpoint}`), `缺少 ${breakpoint} 响应式断点`);
|
||
}
|
||
|
||
const requiredCopy = [
|
||
"Subagent 与智能路由",
|
||
"钉钉与企业微信以开发者预览提供",
|
||
"个人微信处于实验性边界",
|
||
"单次最多添加 8 个附件,支持同时传入 5 张图片",
|
||
"auto、low、medium、high",
|
||
"当前按单张结果呈现,不承诺批量多图生成",
|
||
"发布后开放",
|
||
"安全不是开关",
|
||
];
|
||
|
||
for (const copy of requiredCopy) {
|
||
report(html.includes(copy), `缺少准确文案:${copy}`);
|
||
}
|
||
|
||
report(
|
||
/version:\s*"0\.8\.0"/.test(configJs),
|
||
"site.config.js 必须集中配置 0.8.0 版本",
|
||
);
|
||
report(
|
||
/releasePublished:\s*true/.test(configJs),
|
||
"v0.8.0 Release 发布后 releasePublished 必须为 true",
|
||
);
|
||
report(
|
||
/releaseUrl:\s*"https:\/\/github\.com\/mesalogo\/goodbuddy\/releases\/tag\/v0\.8\.0"/.test(
|
||
configJs,
|
||
),
|
||
"v0.8.0 Release URL 配置不正确",
|
||
);
|
||
report(
|
||
appJs.includes("config?.releasePublished === true"),
|
||
"下载链接必须受 releasePublished 配置保护",
|
||
);
|
||
|
||
const ids = [...html.matchAll(/\sid="([^"]+)"/g)].map((match) => match[1]);
|
||
const duplicateIds = ids.filter((id, index) => ids.indexOf(id) !== index);
|
||
report(duplicateIds.length === 0, `存在重复 id:${[...new Set(duplicateIds)].join(", ")}`);
|
||
|
||
const attributes = [...html.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)), `页内链接目标不存在:${fragment}`);
|
||
}
|
||
|
||
const localAssets = attributes.filter(
|
||
(value) =>
|
||
!value.startsWith("#") &&
|
||
!value.startsWith("https://") &&
|
||
!value.startsWith("http://") &&
|
||
!value.startsWith("mailto:") &&
|
||
!value.startsWith("data:"),
|
||
);
|
||
|
||
for (const asset of localAssets) {
|
||
const cleanAsset = asset.split(/[?#]/, 1)[0].replace(/^\.\//, "");
|
||
try {
|
||
const assetStats = await stat(path.join(siteRoot, cleanAsset));
|
||
report(assetStats.isFile(), `本地资源不是文件:${asset}`);
|
||
} catch {
|
||
errors.push(`本地资源不存在:${asset}`);
|
||
}
|
||
}
|
||
|
||
const externalBlankLinks = [
|
||
...html.matchAll(/<a\b(?=[^>]*target="_blank")[^>]*>/g),
|
||
].map((match) => match[0]);
|
||
|
||
for (const link of externalBlankLinks) {
|
||
report(/rel="[^"]*noreferrer[^"]*"/.test(link), `新窗口链接缺少 noreferrer:${link}`);
|
||
}
|
||
|
||
report(
|
||
!/<a\b[^>]*href="[^"]+\.(?:exe|dmg|zip|AppImage|deb)(?:[?#][^"]*)?"/i.test(html),
|
||
"具体安装资产链接应由 Release 页面统一提供",
|
||
);
|
||
report(
|
||
!/(?:react|vue|angular|bootstrap|tailwind)(?:\.min)?\.(?:js|css)/i.test(html),
|
||
"静态官网不得引入额外框架资源",
|
||
);
|
||
|
||
if (errors.length > 0) {
|
||
console.error(`官网静态检查失败(${errors.length} 项):`);
|
||
for (const error of errors) {
|
||
console.error(`- ${error}`);
|
||
}
|
||
process.exitCode = 1;
|
||
} else {
|
||
console.log(
|
||
`官网静态检查通过:${requiredFiles.length} 个必需文件,${ids.length} 个唯一 id,${localAssets.length} 个本地资源引用。`,
|
||
);
|
||
}
|