diff --git a/.github/workflows/beta_site_lint.yml b/.github/workflows/beta_site_lint.yml index 40d1c3f853c..8f894054a06 100644 --- a/.github/workflows/beta_site_lint.yml +++ b/.github/workflows/beta_site_lint.yml @@ -1,4 +1,4 @@ -name: Beta Site Lint +name: Beta Site Lint / Heading ID check on: pull_request: diff --git a/beta/.husky/pre-commit b/beta/.husky/pre-commit index c2af8489416..dfb0f806675 100755 --- a/beta/.husky/pre-commit +++ b/beta/.husky/pre-commit @@ -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 \ No newline at end of file diff --git a/beta/package.json b/beta/package.json index 4e2a050cf3a..d638bfd77a5 100644 --- a/beta/package.json +++ b/beta/package.json @@ -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)", diff --git a/beta/scripts/generateHeadingIDs.js b/beta/scripts/generateHeadingIDs.js deleted file mode 100644 index d8690172d96..00000000000 --- a/beta/scripts/generateHeadingIDs.js +++ /dev/null @@ -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')); - }); - -} diff --git a/beta/scripts/headingIDHelpers/generateHeadingIDs.js b/beta/scripts/headingIDHelpers/generateHeadingIDs.js new file mode 100644 index 00000000000..0b1d627e0ed --- /dev/null +++ b/beta/scripts/headingIDHelpers/generateHeadingIDs.js @@ -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; diff --git a/beta/scripts/headingIDHelpers/validateHeadingIDs.js b/beta/scripts/headingIDHelpers/validateHeadingIDs.js new file mode 100644 index 00000000000..535613e7fb4 --- /dev/null +++ b/beta/scripts/headingIDHelpers/validateHeadingIDs.js @@ -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} 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} 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; diff --git a/beta/scripts/headingIDHelpers/walk.js b/beta/scripts/headingIDHelpers/walk.js new file mode 100644 index 00000000000..721274e0962 --- /dev/null +++ b/beta/scripts/headingIDHelpers/walk.js @@ -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; +}; diff --git a/beta/scripts/headingIdLinter.js b/beta/scripts/headingIdLinter.js new file mode 100644 index 00000000000..037e4945f06 --- /dev/null +++ b/beta/scripts/headingIdLinter.js @@ -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); +}