mirror of
https://github.com/reactjs/react.dev.git
synced 2026-02-23 20:23:08 +00:00
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
This commit is contained in:
2
.github/workflows/beta_site_lint.yml
vendored
2
.github/workflows/beta_site_lint.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Beta Site Lint
|
||||
name: Beta Site Lint / Heading ID check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
cd beta
|
||||
# yarn generate-ids
|
||||
# git add -u src/pages/**/*.md
|
||||
yarn lint-heading-ids
|
||||
yarn prettier
|
||||
yarn lint:fix
|
||||
@@ -13,8 +13,9 @@
|
||||
"nit:source": "prettier --config .prettierrc --list-different \"{plugins,src}/**/*.{js,ts,jsx,tsx}\"",
|
||||
"prettier": "yarn format:source",
|
||||
"prettier:diff": "yarn nit:source",
|
||||
"generate-ids": "node scripts/generateHeadingIDs.js src/pages/",
|
||||
"ci-check": "npm-run-all prettier:diff --parallel lint tsc",
|
||||
"lint-heading-ids":"node scripts/headingIdLinter.js",
|
||||
"fix-headings": "node scripts/headingIdLinter.js --fix",
|
||||
"ci-check": "npm-run-all prettier:diff --parallel lint tsc lint-heading-ids",
|
||||
"tsc": "tsc --noEmit",
|
||||
"start": "next start",
|
||||
"postinstall": "is-ci || (cd .. && husky install beta/.husky)",
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*/
|
||||
|
||||
// To do: Make this ESM.
|
||||
// To do: properly check heading numbers (headings with the same text get
|
||||
// numbered, this script doesn’t check that).
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const GithubSlugger = require('github-slugger');
|
||||
|
||||
let modules
|
||||
|
||||
function walk(dir) {
|
||||
let results = [];
|
||||
const list = fs.readdirSync(dir);
|
||||
list.forEach(function (file) {
|
||||
file = dir + '/' + file;
|
||||
const stat = fs.statSync(file);
|
||||
if (stat && stat.isDirectory()) {
|
||||
/* Recurse into a subdirectory */
|
||||
results = results.concat(walk(file));
|
||||
} else {
|
||||
/* Is a file */
|
||||
results.push(file);
|
||||
}
|
||||
});
|
||||
return results;
|
||||
}
|
||||
|
||||
function stripLinks(line) {
|
||||
return line.replace(/\[([^\]]+)\]\([^)]+\)/, (match, p1) => p1);
|
||||
}
|
||||
|
||||
function addHeaderID(line, slugger) {
|
||||
// check if we're a header at all
|
||||
if (!line.startsWith('#')) {
|
||||
return line;
|
||||
}
|
||||
|
||||
const match = /^(#+\s+)(.+?)(\s*\{(?:\/\*|#)([^\}\*\/]+)(?:\*\/)?\}\s*)?$/.exec(line);
|
||||
const before = match[1] + match[2]
|
||||
const proc = modules.unified().use(modules.remarkParse).use(modules.remarkSlug)
|
||||
const tree = proc.runSync(proc.parse(before))
|
||||
const head = tree.children[0]
|
||||
assert(head && head.type === 'heading', 'expected `' + before + '` to be a heading, is it using a normal space after `#`?')
|
||||
const autoId = head.data.id
|
||||
const existingId = match[4]
|
||||
const id = existingId || autoId
|
||||
// Ignore numbers:
|
||||
const cleanExisting = existingId ? existingId.replace(/-\d+$/, '') : undefined
|
||||
const cleanAuto = autoId.replace(/-\d+$/, '')
|
||||
|
||||
if (cleanExisting && cleanExisting !== cleanAuto) {
|
||||
console.log('Note: heading `%s` has a different ID (`%s`) than what GH generates for it: `%s`:', before, existingId, autoId)
|
||||
}
|
||||
|
||||
return match[1] + match[2] + ' {/*' + id + '*/}';
|
||||
}
|
||||
|
||||
function addHeaderIDs(lines) {
|
||||
// Sluggers should be per file
|
||||
const slugger = new GithubSlugger();
|
||||
let inCode = false;
|
||||
const results = [];
|
||||
lines.forEach((line) => {
|
||||
// Ignore code blocks
|
||||
if (line.startsWith('```')) {
|
||||
inCode = !inCode;
|
||||
results.push(line);
|
||||
return;
|
||||
}
|
||||
if (inCode) {
|
||||
results.push(line);
|
||||
return;
|
||||
}
|
||||
|
||||
results.push(addHeaderID(line, slugger));
|
||||
});
|
||||
return results;
|
||||
}
|
||||
|
||||
const [path] = process.argv.slice(2);
|
||||
|
||||
main()
|
||||
|
||||
async function main() {
|
||||
const [unifiedMod, remarkParseMod, remarkSlugMod] = await Promise.all([import('unified'), import('remark-parse'), import('remark-slug')])
|
||||
const unified = unifiedMod.default
|
||||
const remarkParse = remarkParseMod.default
|
||||
const remarkSlug = remarkSlugMod.default
|
||||
modules = {unified, remarkParse, remarkSlug}
|
||||
|
||||
const files = walk(path);
|
||||
|
||||
files.forEach((file) => {
|
||||
if (!(file.endsWith('.md') || file.endsWith('.mdx'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(file, 'utf8');
|
||||
const lines = content.split('\n');
|
||||
const updatedLines = addHeaderIDs(lines);
|
||||
fs.writeFileSync(file, updatedLines.join('\n'));
|
||||
});
|
||||
|
||||
}
|
||||
110
beta/scripts/headingIDHelpers/generateHeadingIDs.js
Normal file
110
beta/scripts/headingIDHelpers/generateHeadingIDs.js
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*/
|
||||
|
||||
// To do: Make this ESM.
|
||||
// To do: properly check heading numbers (headings with the same text get
|
||||
// numbered, this script doesn’t check that).
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const GithubSlugger = require('github-slugger');
|
||||
const walk = require('./walk');
|
||||
|
||||
let modules;
|
||||
|
||||
function stripLinks(line) {
|
||||
return line.replace(/\[([^\]]+)\]\([^)]+\)/, (match, p1) => p1);
|
||||
}
|
||||
|
||||
function addHeaderID(line, slugger) {
|
||||
// check if we're a header at all
|
||||
if (!line.startsWith('#')) {
|
||||
return line;
|
||||
}
|
||||
|
||||
const match =
|
||||
/^(#+\s+)(.+?)(\s*\{(?:\/\*|#)([^\}\*\/]+)(?:\*\/)?\}\s*)?$/.exec(line);
|
||||
const before = match[1] + match[2];
|
||||
const proc = modules
|
||||
.unified()
|
||||
.use(modules.remarkParse)
|
||||
.use(modules.remarkSlug);
|
||||
const tree = proc.runSync(proc.parse(before));
|
||||
const head = tree.children[0];
|
||||
assert(
|
||||
head && head.type === 'heading',
|
||||
'expected `' +
|
||||
before +
|
||||
'` to be a heading, is it using a normal space after `#`?'
|
||||
);
|
||||
const autoId = head.data.id;
|
||||
const existingId = match[4];
|
||||
const id = existingId || autoId;
|
||||
// Ignore numbers:
|
||||
const cleanExisting = existingId
|
||||
? existingId.replace(/-\d+$/, '')
|
||||
: undefined;
|
||||
const cleanAuto = autoId.replace(/-\d+$/, '');
|
||||
|
||||
if (cleanExisting && cleanExisting !== cleanAuto) {
|
||||
console.log(
|
||||
'Note: heading `%s` has a different ID (`%s`) than what GH generates for it: `%s`:',
|
||||
before,
|
||||
existingId,
|
||||
autoId
|
||||
);
|
||||
}
|
||||
|
||||
return match[1] + match[2] + ' {/*' + id + '*/}';
|
||||
}
|
||||
|
||||
function addHeaderIDs(lines) {
|
||||
// Sluggers should be per file
|
||||
const slugger = new GithubSlugger();
|
||||
let inCode = false;
|
||||
const results = [];
|
||||
lines.forEach((line) => {
|
||||
// Ignore code blocks
|
||||
if (line.startsWith('```')) {
|
||||
inCode = !inCode;
|
||||
results.push(line);
|
||||
return;
|
||||
}
|
||||
if (inCode) {
|
||||
results.push(line);
|
||||
return;
|
||||
}
|
||||
|
||||
results.push(addHeaderID(line, slugger));
|
||||
});
|
||||
return results;
|
||||
}
|
||||
|
||||
async function main(paths) {
|
||||
paths = paths.length === 0 ? ['src/pages'] : paths;
|
||||
|
||||
const [unifiedMod, remarkParseMod, remarkSlugMod] = await Promise.all([
|
||||
import('unified'),
|
||||
import('remark-parse'),
|
||||
import('remark-slug'),
|
||||
]);
|
||||
const unified = unifiedMod.default;
|
||||
const remarkParse = remarkParseMod.default;
|
||||
const remarkSlug = remarkSlugMod.default;
|
||||
modules = {unified, remarkParse, remarkSlug};
|
||||
const files = paths.map((path) => [...walk(path)]).flat();
|
||||
|
||||
files.forEach((file) => {
|
||||
if (!(file.endsWith('.md') || file.endsWith('.mdx'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(file, 'utf8');
|
||||
const lines = content.split('\n');
|
||||
const updatedLines = addHeaderIDs(lines);
|
||||
fs.writeFileSync(file, updatedLines.join('\n'));
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = main;
|
||||
68
beta/scripts/headingIDHelpers/validateHeadingIDs.js
Normal file
68
beta/scripts/headingIDHelpers/validateHeadingIDs.js
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const walk = require('./walk');
|
||||
|
||||
/**
|
||||
* Validate if there is a custom heading id and exit if there isn't a heading
|
||||
* @param {string} line
|
||||
* @returns
|
||||
*/
|
||||
function validateHeaderId(line) {
|
||||
if (!line.startsWith('#')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const match = /\{\/\*(.*?)\*\/}/.exec(line);
|
||||
const id = match;
|
||||
if (!id) {
|
||||
console.error(
|
||||
'Run yarn fix-headings to generate headings.'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loops through the lines to skip code blocks
|
||||
* @param {Array<string>} lines
|
||||
*/
|
||||
function validateHeaderIds(lines) {
|
||||
let inCode = false;
|
||||
const results = [];
|
||||
lines.forEach((line) => {
|
||||
// Ignore code blocks
|
||||
if (line.startsWith('```')) {
|
||||
inCode = !inCode;
|
||||
|
||||
results.push(line);
|
||||
return;
|
||||
}
|
||||
if (inCode) {
|
||||
results.push(line);
|
||||
return;
|
||||
}
|
||||
validateHeaderId(line);
|
||||
});
|
||||
}
|
||||
/**
|
||||
* paths are basically array of path for which we have to validate heading IDs
|
||||
* @param {Array<string>} paths
|
||||
*/
|
||||
async function main(paths) {
|
||||
paths = paths.length === 0 ? ['src/pages'] : paths;
|
||||
const files = paths.map((path) => [...walk(path)]).flat();
|
||||
|
||||
files.forEach((file) => {
|
||||
if (!(file.endsWith('.md') || file.endsWith('.mdx'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(file, 'utf8');
|
||||
const lines = content.split('\n');
|
||||
validateHeaderIds(lines);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = main;
|
||||
24
beta/scripts/headingIDHelpers/walk.js
Normal file
24
beta/scripts/headingIDHelpers/walk.js
Normal file
@@ -0,0 +1,24 @@
|
||||
const fs = require('fs');
|
||||
|
||||
module.exports = function walk(dir) {
|
||||
let results = [];
|
||||
/**
|
||||
* If the param is a directory we can return the file
|
||||
*/
|
||||
if(dir.includes('md')){
|
||||
return [dir];
|
||||
}
|
||||
const list = fs.readdirSync(dir);
|
||||
list.forEach(function (file) {
|
||||
file = dir + '/' + file;
|
||||
const stat = fs.statSync(file);
|
||||
if (stat && stat.isDirectory()) {
|
||||
/* Recurse into a subdirectory */
|
||||
results = results.concat(walk(file));
|
||||
} else {
|
||||
/* Is a file */
|
||||
results.push(file);
|
||||
}
|
||||
});
|
||||
return results;
|
||||
};
|
||||
16
beta/scripts/headingIdLinter.js
Normal file
16
beta/scripts/headingIdLinter.js
Normal file
@@ -0,0 +1,16 @@
|
||||
const validateHeaderIds = require('./headingIDHelpers/validateHeadingIDs');
|
||||
const generateHeadingIds = require('./headingIDHelpers/generateHeadingIDs');
|
||||
|
||||
/**
|
||||
* yarn lint-heading-ids --> Checks all files and causes an error if heading ID is missing
|
||||
* yarn lint-heading-ids --fix --> Fixes all markdown file's heading IDs
|
||||
* yarn lint-heading-ids path/to/markdown.md --> Checks that particular file for missing heading ID (path can denote a directory or particular file)
|
||||
* yarn lint-heading-ids --fix path/to/markdown.md --> Fixes that particular file's markdown IDs (path can denote a directory or particular file)
|
||||
*/
|
||||
|
||||
const markdownPaths = process.argv.slice(2);
|
||||
if (markdownPaths.includes('--fix')) {
|
||||
generateHeadingIds(markdownPaths.filter((path) => path !== '--fix'));
|
||||
} else {
|
||||
validateHeaderIds(markdownPaths);
|
||||
}
|
||||
Reference in New Issue
Block a user