Skip to content

Commit 98fc7d9

Browse files
JubayeromiCopilot
andcommitted
Add heading order check to site lint
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent a18e972 commit 98fc7d9

3 files changed

Lines changed: 166 additions & 1 deletion

File tree

‎.github/workflows/site_lint.yml‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
name: Site Lint / Heading ID check
1+
name: Site Lint / Heading checks
22

33
on:
44
push:
@@ -17,6 +17,8 @@ jobs:
1717

1818
steps:
1919
- uses: actions/checkout@v4
20+
with:
21+
fetch-depth: 0
2022
- name: Use Node.js 20.x
2123
uses: actions/setup-node@v4
2224
with:
@@ -35,3 +37,8 @@ jobs:
3537

3638
- name: Lint codebase
3739
run: yarn ci-check
40+
41+
- name: Check heading order
42+
env:
43+
HEADING_ORDER_BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
44+
run: yarn lint-heading-order "$HEADING_ORDER_BASE_SHA"

‎package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"prettier": "yarn format:source",
1616
"prettier:diff": "yarn nit:source",
1717
"lint-heading-ids": "node scripts/headingIdLinter.js",
18+
"lint-heading-order": "node scripts/headingOrderLinter.js",
1819
"fix-headings": "node scripts/headingIdLinter.js --fix",
1920
"ci-check": "npm-run-all prettier:diff --parallel lint test:eslint-local-rules tsc lint-heading-ids rss deadlinks",
2021
"tsc": "tsc --noEmit",

‎scripts/headingOrderLinter.js‎

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
const fs = require('fs');
9+
const path = require('path');
10+
const {execFileSync} = require('child_process');
11+
12+
const contentDirectory = 'src/content';
13+
const baseSha = process.argv[2];
14+
15+
if (!baseSha) {
16+
console.error(
17+
'Pass the base commit SHA to compare heading-order violations.'
18+
);
19+
process.exit(1);
20+
}
21+
22+
function walk(directory) {
23+
return fs.readdirSync(directory, {withFileTypes: true}).flatMap((entry) => {
24+
const entryPath = path.join(directory, entry.name);
25+
if (entry.isDirectory()) {
26+
return walk(entryPath);
27+
}
28+
return entry.name.endsWith('.md') || entry.name.endsWith('.mdx')
29+
? [entryPath]
30+
: [];
31+
});
32+
}
33+
34+
function findHeadingOrderViolations(content, file) {
35+
const violations = [];
36+
const lines = content.split(/\r?\n/);
37+
let previousLevel = 1;
38+
let codeFence = null;
39+
40+
lines.forEach((line, index) => {
41+
const fence = /^\s{0,3}(`{3,}|~{3,})/.exec(line);
42+
if (fence) {
43+
const marker = fence[1][0];
44+
if (!codeFence) {
45+
codeFence = {marker, length: fence[1].length};
46+
} else if (
47+
marker === codeFence.marker &&
48+
fence[1].length >= codeFence.length
49+
) {
50+
codeFence = null;
51+
}
52+
return;
53+
}
54+
if (codeFence) {
55+
return;
56+
}
57+
58+
const heading = /^(#{1,6})[ \t]+(.+?)\s*$/.exec(line);
59+
if (!heading) {
60+
return;
61+
}
62+
63+
const level = heading[1].length;
64+
if (level > previousLevel + 1) {
65+
const text = heading[2].replace(/\s+\{\/\*.*?\*\/}\s*$/, '').trim();
66+
violations.push({
67+
file,
68+
line: index + 1,
69+
previousLevel,
70+
level,
71+
text,
72+
});
73+
}
74+
previousLevel = level;
75+
});
76+
77+
return violations;
78+
}
79+
80+
function violationKey(violation) {
81+
return JSON.stringify([
82+
violation.file,
83+
violation.previousLevel,
84+
violation.level,
85+
violation.text,
86+
]);
87+
}
88+
89+
function readBaseContent(revision, file) {
90+
try {
91+
return execFileSync('git', ['show', `${revision}:${file}`], {
92+
encoding: 'utf8',
93+
stdio: ['ignore', 'pipe', 'ignore'],
94+
});
95+
} catch (error) {
96+
if (error.status === 128) {
97+
return null;
98+
}
99+
throw error;
100+
}
101+
}
102+
103+
try {
104+
execFileSync('git', ['rev-parse', '--verify', `${baseSha}^{commit}`], {
105+
stdio: 'ignore',
106+
});
107+
108+
const currentViolations = walk(contentDirectory).flatMap((filePath) => {
109+
const file = path
110+
.relative(process.cwd(), filePath)
111+
.split(path.sep)
112+
.join('/');
113+
return findHeadingOrderViolations(fs.readFileSync(filePath, 'utf8'), file);
114+
});
115+
const filesWithViolations = [
116+
...new Set(currentViolations.map((violation) => violation.file)),
117+
];
118+
const baselineCounts = new Map();
119+
120+
filesWithViolations.forEach((file) => {
121+
const content = readBaseContent(baseSha, file);
122+
const violations = content ? findHeadingOrderViolations(content, file) : [];
123+
violations.forEach((violation) => {
124+
const key = violationKey(violation);
125+
baselineCounts.set(key, (baselineCounts.get(key) || 0) + 1);
126+
});
127+
});
128+
129+
const newViolations = [];
130+
currentViolations.forEach((violation) => {
131+
const key = violationKey(violation);
132+
const count = baselineCounts.get(key) || 0;
133+
if (count > 0) {
134+
baselineCounts.set(key, count - 1);
135+
} else {
136+
newViolations.push(violation);
137+
}
138+
});
139+
140+
if (newViolations.length > 0) {
141+
console.error('New heading-order skips found:');
142+
newViolations.forEach(({file, line, previousLevel, level, text}) => {
143+
console.error(
144+
` ${file}:${line}: h${previousLevel} to h${level}: ${text}`
145+
);
146+
});
147+
process.exitCode = 1;
148+
} else {
149+
console.log('No new heading-order skips found.');
150+
}
151+
} catch (error) {
152+
console.error(
153+
'Unable to check heading order against the base commit:',
154+
error.message
155+
);
156+
process.exitCode = 1;
157+
}

0 commit comments

Comments
 (0)