Skip to content

Commit 0b21acb

Browse files
author
Strek
authored
Added hook to validate if headings are present or not (#4143)
* Added hook to validate if headings are present or not * Remove un wanted default param * Add validate Ids to ci check too * Revamp heading id generation and validation workflow * Update validateHeadingIDs.js
1 parent 5a3576a commit 0b21acb

File tree

8 files changed

+223
-113
lines changed

8 files changed

+223
-113
lines changed

.github/workflows/beta_site_lint.yml

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

33
on:
44
pull_request:

beta/.husky/pre-commit

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
. "$(dirname "$0")/_/husky.sh"
33

44
cd beta
5-
# yarn generate-ids
6-
# git add -u src/pages/**/*.md
5+
yarn lint-heading-ids
76
yarn prettier
87
yarn lint:fix

beta/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@
1313
"nit:source": "prettier --config .prettierrc --list-different \"{plugins,src}/**/*.{js,ts,jsx,tsx}\"",
1414
"prettier": "yarn format:source",
1515
"prettier:diff": "yarn nit:source",
16-
"generate-ids": "node scripts/generateHeadingIDs.js src/pages/",
17-
"ci-check": "npm-run-all prettier:diff --parallel lint tsc",
16+
"lint-heading-ids":"node scripts/headingIdLinter.js",
17+
"fix-headings": "node scripts/headingIdLinter.js --fix",
18+
"ci-check": "npm-run-all prettier:diff --parallel lint tsc lint-heading-ids",
1819
"tsc": "tsc --noEmit",
1920
"start": "next start",
2021
"postinstall": "is-ci || (cd .. && husky install beta/.husky)",

beta/scripts/generateHeadingIDs.js

Lines changed: 0 additions & 108 deletions
This file was deleted.
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
/**
2+
* Copyright (c) Facebook, Inc. and its affiliates.
3+
*/
4+
5+
// To do: Make this ESM.
6+
// To do: properly check heading numbers (headings with the same text get
7+
// numbered, this script doesn’t check that).
8+
9+
const assert = require('assert');
10+
const fs = require('fs');
11+
const GithubSlugger = require('github-slugger');
12+
const walk = require('./walk');
13+
14+
let modules;
15+
16+
function stripLinks(line) {
17+
return line.replace(/\[([^\]]+)\]\([^)]+\)/, (match, p1) => p1);
18+
}
19+
20+
function addHeaderID(line, slugger) {
21+
// check if we're a header at all
22+
if (!line.startsWith('#')) {
23+
return line;
24+
}
25+
26+
const match =
27+
/^(#+\s+)(.+?)(\s*\{(?:\/\*|#)([^\}\*\/]+)(?:\*\/)?\}\s*)?$/.exec(line);
28+
const before = match[1] + match[2];
29+
const proc = modules
30+
.unified()
31+
.use(modules.remarkParse)
32+
.use(modules.remarkSlug);
33+
const tree = proc.runSync(proc.parse(before));
34+
const head = tree.children[0];
35+
assert(
36+
head && head.type === 'heading',
37+
'expected `' +
38+
before +
39+
'` to be a heading, is it using a normal space after `#`?'
40+
);
41+
const autoId = head.data.id;
42+
const existingId = match[4];
43+
const id = existingId || autoId;
44+
// Ignore numbers:
45+
const cleanExisting = existingId
46+
? existingId.replace(/-\d+$/, '')
47+
: undefined;
48+
const cleanAuto = autoId.replace(/-\d+$/, '');
49+
50+
if (cleanExisting && cleanExisting !== cleanAuto) {
51+
console.log(
52+
'Note: heading `%s` has a different ID (`%s`) than what GH generates for it: `%s`:',
53+
before,
54+
existingId,
55+
autoId
56+
);
57+
}
58+
59+
return match[1] + match[2] + ' {/*' + id + '*/}';
60+
}
61+
62+
function addHeaderIDs(lines) {
63+
// Sluggers should be per file
64+
const slugger = new GithubSlugger();
65+
let inCode = false;
66+
const results = [];
67+
lines.forEach((line) => {
68+
// Ignore code blocks
69+
if (line.startsWith('```')) {
70+
inCode = !inCode;
71+
results.push(line);
72+
return;
73+
}
74+
if (inCode) {
75+
results.push(line);
76+
return;
77+
}
78+
79+
results.push(addHeaderID(line, slugger));
80+
});
81+
return results;
82+
}
83+
84+
async function main(paths) {
85+
paths = paths.length === 0 ? ['src/pages'] : paths;
86+
87+
const [unifiedMod, remarkParseMod, remarkSlugMod] = await Promise.all([
88+
import('unified'),
89+
import('remark-parse'),
90+
import('remark-slug'),
91+
]);
92+
const unified = unifiedMod.default;
93+
const remarkParse = remarkParseMod.default;
94+
const remarkSlug = remarkSlugMod.default;
95+
modules = {unified, remarkParse, remarkSlug};
96+
const files = paths.map((path) => [...walk(path)]).flat();
97+
98+
files.forEach((file) => {
99+
if (!(file.endsWith('.md') || file.endsWith('.mdx'))) {
100+
return;
101+
}
102+
103+
const content = fs.readFileSync(file, 'utf8');
104+
const lines = content.split('\n');
105+
const updatedLines = addHeaderIDs(lines);
106+
fs.writeFileSync(file, updatedLines.join('\n'));
107+
});
108+
}
109+
110+
module.exports = main;
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/**
2+
* Copyright (c) Facebook, Inc. and its affiliates.
3+
*/
4+
const fs = require('fs');
5+
const walk = require('./walk');
6+
7+
/**
8+
* Validate if there is a custom heading id and exit if there isn't a heading
9+
* @param {string} line
10+
* @returns
11+
*/
12+
function validateHeaderId(line) {
13+
if (!line.startsWith('#')) {
14+
return;
15+
}
16+
17+
const match = /\{\/\*(.*?)\*\/}/.exec(line);
18+
const id = match;
19+
if (!id) {
20+
console.error(
21+
'Run yarn fix-headings to generate headings.'
22+
);
23+
process.exit(1);
24+
}
25+
}
26+
27+
/**
28+
* Loops through the lines to skip code blocks
29+
* @param {Array<string>} lines
30+
*/
31+
function validateHeaderIds(lines) {
32+
let inCode = false;
33+
const results = [];
34+
lines.forEach((line) => {
35+
// Ignore code blocks
36+
if (line.startsWith('```')) {
37+
inCode = !inCode;
38+
39+
results.push(line);
40+
return;
41+
}
42+
if (inCode) {
43+
results.push(line);
44+
return;
45+
}
46+
validateHeaderId(line);
47+
});
48+
}
49+
/**
50+
* paths are basically array of path for which we have to validate heading IDs
51+
* @param {Array<string>} paths
52+
*/
53+
async function main(paths) {
54+
paths = paths.length === 0 ? ['src/pages'] : paths;
55+
const files = paths.map((path) => [...walk(path)]).flat();
56+
57+
files.forEach((file) => {
58+
if (!(file.endsWith('.md') || file.endsWith('.mdx'))) {
59+
return;
60+
}
61+
62+
const content = fs.readFileSync(file, 'utf8');
63+
const lines = content.split('\n');
64+
validateHeaderIds(lines);
65+
});
66+
}
67+
68+
module.exports = main;

beta/scripts/headingIDHelpers/walk.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
const fs = require('fs');
2+
3+
module.exports = function walk(dir) {
4+
let results = [];
5+
/**
6+
* If the param is a directory we can return the file
7+
*/
8+
if(dir.includes('md')){
9+
return [dir];
10+
}
11+
const list = fs.readdirSync(dir);
12+
list.forEach(function (file) {
13+
file = dir + '/' + file;
14+
const stat = fs.statSync(file);
15+
if (stat && stat.isDirectory()) {
16+
/* Recurse into a subdirectory */
17+
results = results.concat(walk(file));
18+
} else {
19+
/* Is a file */
20+
results.push(file);
21+
}
22+
});
23+
return results;
24+
};

beta/scripts/headingIdLinter.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
const validateHeaderIds = require('./headingIDHelpers/validateHeadingIDs');
2+
const generateHeadingIds = require('./headingIDHelpers/generateHeadingIDs');
3+
4+
/**
5+
* yarn lint-heading-ids --> Checks all files and causes an error if heading ID is missing
6+
* yarn lint-heading-ids --fix --> Fixes all markdown file's heading IDs
7+
* yarn lint-heading-ids path/to/markdown.md --> Checks that particular file for missing heading ID (path can denote a directory or particular file)
8+
* yarn lint-heading-ids --fix path/to/markdown.md --> Fixes that particular file's markdown IDs (path can denote a directory or particular file)
9+
*/
10+
11+
const markdownPaths = process.argv.slice(2);
12+
if (markdownPaths.includes('--fix')) {
13+
generateHeadingIds(markdownPaths.filter((path) => path !== '--fix'));
14+
} else {
15+
validateHeaderIds(markdownPaths);
16+
}

0 commit comments

Comments
 (0)