Files
novalon-website/scripts/utils/check-heading-hierarchy.ts
T
张翔 10404dbb36 chore: sync marketing pages, CMS extensions, tests and project docs
同步工作区剩余变更,主要包括:
- 营销页面组件与布局持续优化(about/news/services/solutions/team 等)
- 详情页四层叙事组件、布局组件、UI 组件调整
- CMS 数据模型、API 路由、权限、工作流、站内通知、媒体管理扩展
- 新增/补充单元测试与 E2E 测试(cms-workflow.spec.ts 等)
- ESLint 9 迁移、jest/tsconfig 配置更新、依赖调整
- 新增 ADR、CMS 评估文档、Release Review / Acceptance 报告
- 移除水墨装饰组件与大体积未使用字体文件
2026-07-25 08:04:01 +08:00

204 lines
6.0 KiB
TypeScript

#!/usr/bin/env tsx
/**
* 标题层级检查脚本
*
* 对关键页面进行标题(h1-h6)层级扫描,确保:
* 1. 每个页面有且仅有一个 h1
* 2. 标题层级不跳跃(例如 h1 后直接出现 h3)
* 3. 标题不为空
*/
import { chromium } from 'playwright';
import { spawn } from 'child_process';
import path from 'path';
const BASE_URL = process.env.E2E_BASE_URL || 'http://localhost:3000';
const PAGES = [
{ path: '/', name: '首页' },
{ path: '/about', name: '关于我们' },
{ path: '/products', name: '产品中心' },
{ path: '/products/erp', name: '产品详情-ERP' },
{ path: '/solutions', name: '解决方案' },
{ path: '/solutions/manufacturing', name: '解决方案详情-制造' },
{ path: '/services', name: '服务列表' },
{ path: '/services/software', name: '服务详情-软件开发' },
{ path: '/news', name: '新闻列表' },
{ path: '/contact', name: '联系我们' },
];
interface HeadingIssue {
type: 'missing_h1' | 'multiple_h1' | 'empty_heading' | 'skipped_level';
message: string;
detail?: string;
}
interface PageResult {
name: string;
url: string;
passed: boolean;
headings: Array<{ level: number; text: string }>;
issues: HeadingIssue[];
}
function startServer(): Promise<ReturnType<typeof spawn>> {
return new Promise((resolve, reject) => {
const server = spawn('npm', ['run', 'preview'], {
cwd: path.resolve(__dirname, '../..'),
stdio: 'pipe',
shell: true,
});
server.stdout?.on('data', (data: Buffer) => {
const output = data.toString();
if (output.includes('Ready') || output.includes('3000')) {
resolve(server);
}
});
server.stderr?.on('data', (data: Buffer) => {
const output = data.toString();
if (output.includes('EADDRINUSE') || output.includes('Failed')) {
reject(new Error(output));
}
});
setTimeout(() => resolve(server), 5000);
});
}
async function waitForServer(url: string, retries = 30): Promise<void> {
for (let i = 0; i < retries; i++) {
try {
const res = await fetch(url, { method: 'HEAD' });
if (res.ok || res.status === 404) return;
} catch {
await new Promise((r) => setTimeout(r, 1000));
}
}
throw new Error(`Server at ${url} did not become ready in time`);
}
function checkHeadings(headings: Array<{ level: number; text: string }>): HeadingIssue[] {
const issues: HeadingIssue[] = [];
const h1Headings = headings.filter((h) => h.level === 1);
if (h1Headings.length === 0) {
issues.push({ type: 'missing_h1', message: '页面缺少 h1 标题' });
} else if (h1Headings.length > 1) {
issues.push({
type: 'multiple_h1',
message: `页面存在 ${h1Headings.length} 个 h1 标题`,
detail: h1Headings.map((h) => h.text).join(' | '),
});
}
for (let i = 0; i < headings.length; i++) {
const current = headings[i]!;
if (!current.text.trim()) {
issues.push({
type: 'empty_heading',
message: `h${current.level} 标题内容为空`,
});
continue;
}
if (i === 0) continue;
const previous = headings[i - 1]!;
if (current.level > previous.level + 1) {
issues.push({
type: 'skipped_level',
message: `标题层级跳跃: h${previous.level} → h${current.level}`,
detail: `"${previous.text}" → "${current.text}"`,
});
}
}
return issues;
}
async function runCheck() {
let server;
try {
console.log('🚀 启动预览服务...');
server = await startServer();
await waitForServer(BASE_URL);
console.log('🔍 检查页面标题层级...\n');
const browser = await chromium.launch();
const context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
const page = await context.newPage();
const results: PageResult[] = [];
let totalIssues = 0;
for (const { path: pagePath, name } of PAGES) {
const url = `${BASE_URL}${pagePath}`;
try {
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(1500);
const headings = await page.evaluate(() => {
const elements = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6'));
return elements.map((el) => ({
level: parseInt(el.tagName[1]!, 10),
text: el.textContent?.trim() ?? '',
}));
});
const issues = checkHeadings(headings);
totalIssues += issues.length;
const result: PageResult = {
name,
url,
passed: issues.length === 0,
headings,
issues,
};
results.push(result);
const status = issues.length === 0 ? '✅' : '❌';
console.log(`${status} ${name} (${issues.length} 个问题)`);
for (const issue of issues) {
console.log(`${issue.message}${issue.detail ? ` (${issue.detail})` : ''}`);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`${name} 检查失败:`, message);
results.push({
name,
url,
passed: false,
headings: [],
issues: [{ type: 'missing_h1', message: `页面加载失败: ${message}` }],
});
}
}
await browser.close();
console.log('\n📊 检查摘要');
console.log(` 扫描页面: ${results.length}`);
console.log(` 通过页面: ${results.filter((r) => r.passed).length}`);
console.log(` 失败页面: ${results.filter((r) => !r.passed).length}`);
console.log(` 问题总数: ${totalIssues}`);
const reportPath = path.resolve(__dirname, '../../heading-hierarchy-report.json');
require('fs').writeFileSync(reportPath, JSON.stringify(results, null, 2));
console.log(`\n📝 详细报告已保存: ${reportPath}`);
process.exit(totalIssues > 0 ? 1 : 0);
} catch (error) {
console.error('检查执行失败:', error);
process.exit(1);
} finally {
if (server) {
server.kill('SIGTERM');
}
}
}
runCheck();