Files
goodbuddy/sites/scripts/validate.mjs
T

163 lines
5.4 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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",
"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] = await Promise.all([
readSiteFile("index.html"),
readSiteFile("styles.css"),
readSiteFile("app.js"),
]);
for (const [relativePath, content] of [
["index.html", html],
["styles.css", css],
["app.js", appJs],
]) {
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 = [
"在本地管理笔记和待办",
"微信、企业微信和钉钉",
"单条消息最多 4 个附件",
"OpenCode 与 Continue",
"单次最多添加 8 个附件,支持同时传入 5 张图片",
"auto、low、medium、high",
"下载入口始终指向最新正式 Release",
"主要安全边界",
];
for (const copy of requiredCopy) {
report(html.includes(copy), `缺少准确文案:${copy}`);
}
const htmlWithoutSvg = html.replace(/<svg\b[\s\S]*?<\/svg>/g, "");
report(
!/\bv?\d+\.\d+\.\d+\b/.test(htmlWithoutSvg),
"官网正文不得写入需要随发布更新的具体版本号",
);
const releaseLinks = [
...html.matchAll(/<a\b(?=[^>]*data-release-link)[^>]*>/g),
].map((match) => match[0]);
report(releaseLinks.length >= 5, "缺少完整的官方下载入口");
for (const link of releaseLinks) {
report(
/href="https:\/\/github\.com\/mesalogo\/goodbuddy\/releases\/latest"/.test(link),
`下载入口必须指向官方最新 Release:${link}`,
);
report(/target="_blank"/.test(link), `下载入口必须在新窗口打开:${link}`);
report(/rel="[^"]*noreferrer[^"]*"/.test(link), `下载入口缺少 noreferrer${link}`);
}
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} 个本地资源引用。`,
);
}