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(//.test(html), "页面语言必须是 zh-CN"); report(/]/g) ?? []).length === 1, "页面必须且只能包含一个 h1"); report(/class="skip-link"\s+href="#main-content"/.test(html), "缺少跳到主要内容链接"); report(//.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(//g, ""); report( !/\bv?\d+\.\d+\.\d+\b/.test(htmlWithoutSvg), "官网正文不得写入需要随发布更新的具体版本号", ); const releaseLinks = [ ...html.matchAll(/]*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(/]*target="_blank")[^>]*>/g), ].map((match) => match[0]); for (const link of externalBlankLinks) { report(/rel="[^"]*noreferrer[^"]*"/.test(link), `新窗口链接缺少 noreferrer:${link}`); } report( !/]*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} 个本地资源引用。`, ); }