Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enable ESLint JSDoc checks #2913

Merged
merged 10 commits into from
Oct 19, 2022
41 changes: 41 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,47 @@ module.exports = {
'src/govuk/vendor/polyfills/**/*'
],
overrides: [
{
extends: 'plugin:jsdoc/recommended',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: Why do we need to re-extend the 'plugin:jsdoc/recommended'? It's already declared in the root extends option.

Copy link
Contributor Author

@colinrotherham colinrotherham Oct 12, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well spotted! I thought I'd moved it, as it felt better in an override versus the root

Here I've made sure I'm only loading the JSDoc plugin for '**/*.{cjs,js,mjs}'

Gives us more flexibility when adding new plugins for other files. For example, if we added *.ts files in future their own override section would add the TSDoc plugin eslint-plugin-tsdoc instead

files: ['**/*.{cjs,js,mjs}'],
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: Will that not catch all JavaScript files? Or is that necessary because we need to create an override section.

suggestion: We may want to exclude test files (in case we need file-specific helper functions sometimes)

Suggested change
files: ['**/*.{cjs,js,mjs}'],
files: ['**/*.{cjs,js,mjs}'],
excludedFiles: ['*.test.js'],

Copy link
Contributor Author

@colinrotherham colinrotherham Oct 12, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I'm happy with JSDoc checking all JavaScript files 😮

plugins: [
'jsdoc'
],
rules: {
// JSDoc blocks are optional
'jsdoc/require-jsdoc': 'off',
'jsdoc/require-param-description': 'off',
'jsdoc/require-param': 'off',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Presumably by disabling this rule we're making it possible to accidentally omit params from otherwise valid JSDoc blocks?

Is there any way to lint that if a JSDoc block exists then all params should be covered by that JSDoc?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@36degrees Yeah if we solely have 'jsdoc/require-jsdoc' it'll do that

ESLint is clever enough to make @returns optional if nothing is returned, but we also needed @description and @param optional for things like this:

/**
 * Umbrella scripts tasks (for watch)
 * Runs JavaScript code quality checks and compilation
 */
gulp.task('scripts', gulp.series(
  npmScriptTask('lint:js', ['--silent']),
  'js:compile'
))

☝️ We quite frequently use JSDoc style comments for scripts and tests

We have a few other options on the table:

  1. Keep these rules as-is with JSDoc block @param and @description optional
  2. Remove these rules and lint for JSDoc blocks without @param and @description
  3. Remove these rules BUT exclude ./src


// Require hyphens before param description
// Aligns with TSDoc style: https://tsdoc.org/pages/tags/param/
'jsdoc/require-hyphen-before-param-description': 'warn',
Copy link
Contributor Author

@colinrotherham colinrotherham Oct 12, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We sometimes hyphenate, sometimes don't, so I've added this rule

It's recommended by JSDoc (and mandatory for TSDoc)

If you provide a description, you can make the JSDoc comment more readable by inserting a hyphen before the description. Be sure to include a space before and after the hyphen.


// Check for valid formatting
'jsdoc/check-line-alignment': 'warn',
'jsdoc/check-syntax': 'error',

// Add unknown @jest-environment tag name
'jsdoc/check-tag-names': [
'warn', {
definedTags: ['jest-environment']
}
],

// Add missing .querySelectorAll() type
'jsdoc/no-undefined-types': [
'error', {
definedTypes: ['NodeListOf']
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: Does that mean that a syntax like NodeList<Element> wouldn't be valid to highlight that our NodeList only contains elements?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@romaricpascal Yeah exactly

Without this change then NodeList<Element> NodeListOf<Element> would be invalid

One of the JSDoc plugin dependencies must not be aware of it as a known DOM type (possibly because the TypeScript flavour is NodeListOf not NodeList. Haven't managed to track down exactly where to log this yet.

In Visual Studio Code if you mouse over .querySelectorAll() you'll see NodeListOf<Element>

Screenshot of querySelectorAll being typed as NodeListOf

}
]
},
settings: {
jsdoc: {
// Allows us to use type declarations that exist in our dependencies
mode: 'typescript'
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why 'typescript' mode?

Rather than spend time documenting every complex object (or instance) we can use the declarations already published by the package author:

/**
 * @param {import('puppeteer').Page} page
 * @param {import('puppeteer').ElementHandle} element
 * @param {import('puppeteer').WaitForOptions} options
 */

TypeScript declarations are widely available unlike local-only @typedef tags:

Puppeteer on npmjs org showing TypeScript declarations are available

36degrees marked this conversation as resolved.
Show resolved Hide resolved
}
}
},
{
files: ['**/*.test.{cjs,js,mjs}'],
env: {
Expand Down
33 changes: 28 additions & 5 deletions lib/file-helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const configPaths = require('../config/paths.js')
* Directory listing for path
*
* @param {string} directoryPath
* @returns {Promise<Map<basename, { path: string; stats: import('fs').Stats }>>} Directory listing
* @returns {Promise<DirectoryListing>} Directory listing
*/
const getListing = async (directoryPath) => {
const basenames = await readdir(directoryPath)
Expand All @@ -32,7 +32,7 @@ const getListing = async (directoryPath) => {
* Directory listing (directories only)
*
* @param {string} directoryPath
* @returns {Promise<Map<dirname, { path: string; stats: import('fs').Stats }>>} Directory entries
* @returns {Promise<DirectoryListing>} Directory entries
*/
const getDirectories = async (directoryPath) => {
const listing = await getListing(directoryPath)
Expand All @@ -48,7 +48,7 @@ const getDirectories = async (directoryPath) => {
* Directory listing (files only)
*
* @param {string} directoryPath
* @returns {Promise<Map<filename, { path: string; stats: import('fs').Stats }>>} File entries
* @returns {Promise<DirectoryListing>} File entries
*/
const getFiles = async (directoryPath) => {
const listing = await getListing(directoryPath)
Expand All @@ -64,7 +64,7 @@ const getFiles = async (directoryPath) => {
* Directory listing (files, grouped by directory)
*
* @param {string} directoryPath
* @returns {Promise<Map<dirname, Map<filename, { path: string; stats: import('fs').Stats }>>>} File entries by directory
* @returns {Promise<Map<string, DirectoryListing>>} File entries by directory
*/
const getFilesByDirectory = async (directoryPath) => {
const directories = await getDirectories(directoryPath)
Expand Down Expand Up @@ -110,7 +110,7 @@ const getComponentsData = async () => {
/**
* Load all full page examples' front matter
*
* @returns {Promise<{ name: string; scenario?: string; notes?: string }[]>} Full page examples
* @returns {Promise<FullPageExample[]>} Full page examples
*/
const getFullPageExamples = async () => {
const directories = await getDirectories(configPaths.fullPageExamples)
Expand Down Expand Up @@ -145,6 +145,20 @@ module.exports = {
getListing
}

/**
* Directory listing
*
* @typedef {Map<string, { path: string; stats: import('fs').Stats }>} DirectoryListing
*/

/**
* Directory listing entry
*
* @typedef {object} DirectoryListingEntry
* @property {string} path - Relative path to file or directory
* @property {import('fs').Stats} stats - Information about a file or directory
*/

/**
* Component data from YAML
*
Expand All @@ -155,3 +169,12 @@ module.exports = {
* @property {string} [previewLayout] - Nunjucks layout for component preview
* @property {string} [accessibilityCriteria] - Accessibility criteria
*/

/**
* Full page example from front matter
*
* @typedef {object} FullPageExample
* @property {string} name - Example name
* @property {string} [scenario] - Description explaining the example
* @property {string} [notes] - Additional notes about the example
*/
16 changes: 8 additions & 8 deletions lib/helper-functions.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
/**
* Convert a kebab-cased string to a PascalCased one
*
* @param {String} value -
* @returns {String}
* @param {string} value - Input kebab-cased string
* @returns {string} Output PascalCased string
*/
function kebabCaseToPascalCase (value) {
return value
Expand All @@ -19,8 +19,8 @@ function kebabCaseToPascalCase (value) {
* Component names are kebab-cased (button, date-input), whilst macro names have
* a `govuk` prefix and are camel cased (govukButton, govukDateInput).
*
* @param {String} componentName - A kebab-cased component name
* @returns {String} The name of its corresponding Nunjucks macro
* @param {string} componentName - A kebab-cased component name
* @returns {string} The name of its corresponding Nunjucks macro
*/
function componentNameToMacroName (componentName) {
return `govuk${kebabCaseToPascalCase(componentName)}`
Expand All @@ -29,8 +29,8 @@ function componentNameToMacroName (componentName) {
/**
* Convert component name to its JavaScript class name
*
* @param {String} componentName - A kebab-cased component name
* @returns {String} The name of its corresponding JavaScript class
* @param {string} componentName - A kebab-cased component name
* @returns {string} The name of its corresponding JavaScript class
*/
function componentNameToJavaScriptClassName (componentName) {
return kebabCaseToPascalCase(componentName)
Expand All @@ -45,8 +45,8 @@ function componentNameToJavaScriptClassName (componentName) {
* names have a `GOVUKFrontend.` prefix and are PascalCased
* (GOVUKFrontend.Button, GOVUKFrontend.CharacterCount).
*
* @param {String} componentName - A kebab-cased component name
* @returns {String} The name of its corresponding module
* @param {string} componentName - A kebab-cased component name
* @returns {string} The name of its corresponding module
*/
function componentNameToJavaScriptModuleName (componentName) {
return `GOVUKFrontend.${componentNameToJavaScriptClassName(componentName)}`
Expand Down
29 changes: 15 additions & 14 deletions lib/jest-helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,11 @@ const nunjucksEnv = nunjucks.configure([configPaths.src, configPaths.components]
/**
* Render the raw HTML for a component
*
* @param {String} componentName
* @param {Object} options options to pass to the component macro
* @param {String} [callBlock] if provided, the macro is called using the
* @param {string} componentName
* @param {object} options - options to pass to the component macro
* @param {string} [callBlock] - if provided, the macro is called using the
* Nunjucks call tag, with the callBlock passed as the contents of the block
* @returns {String} HTML rendered by the macro
* @returns {string} HTML rendered by the macro
*/
function renderHtml (componentName, options, callBlock = false) {
if (typeof options === 'undefined') {
Expand All @@ -46,7 +46,7 @@ function renderHtml (componentName, options, callBlock = false) {
* @param {string} macroPath - The path to the file containing the macro *from the root of the project*
* @param {Array} params - The parameters that will be passed to the macro. They'll be `JSON.stringify`ed and joined with a `,`
* @param {string} [callBlock] - Content for an optional callBlock, if necessary for the macro to receive one
* @returns string - The result of calling the macro
* @returns {string} The result of calling the macro
*/
function callMacro (macroName, macroPath, params = [], callBlock = false) {
const macroParams = params.map(param => JSON.stringify(param, null, 2)).join(',')
Expand All @@ -69,12 +69,11 @@ function callMacro (macroName, macroPath, params = [], callBlock = false) {
*
* Allows us to interrogate the output of the macro using a jQuery-like syntax
*
* @param {String} componentName
* @param {Object} options options to pass to the component macro
* @param {String} [callBlock] if provided, the macro is called using the
* @param {string} componentName
* @param {object} options - options to pass to the component macro
* @param {string} [callBlock] - if provided, the macro is called using the
* Nunjucks call tag, with the callBlock passed as the contents of the block
*
* @returns {CheerioAPI} a jQuery-like representation of the macro output
* @returns {import('cheerio')} a jQuery-like representation of the macro output
*/
function render (componentName, options, callBlock = false) {
return cheerio.load(
Expand All @@ -99,6 +98,7 @@ function renderTemplate (params = {}, blocks = {}) {

/**
* Get examples from a component's metadata file
*
* @param {string} componentName
* @returns {object} returns object that includes all examples at once
*/
Expand All @@ -117,8 +117,8 @@ async function getExamples (componentName) {
/**
* Render Sass
*
* @param {object} options Options to pass to sass.render
* @return {promise} Result of calling sass.render
* @param {import('node-sass').Options} options - Options to pass to sass.render
* @returns {Promise<import('node-sass').Result>} Result of calling sass.render
*/
function renderSass (options) {
return sassRender({
Expand All @@ -132,9 +132,10 @@ function renderSass (options) {
* child elements that do not match the component.
* Relies on B.E.M naming ensuring that child components relating to a component
* are namespaced.
* @param {function} $ requires an instance of cheerio (jQuery) that includes the
*
* @param {import('cheerio')} $ - requires an instance of cheerio (jQuery) that includes the
* rendered component.
* @param {string} className the top level class 'Block' in B.E.M terminology
* @param {string} className - the top level class 'Block' in B.E.M terminology
* @returns {string} returns HTML
*/
function htmlWithClassName ($, className) {
Expand Down
12 changes: 6 additions & 6 deletions lib/puppeteer-helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@ const PORT = configPaths.ports.test
* or run arbitrary code)
*
* @param {import('puppeteer').Page} page - Puppeteer page object
* @param {String} componentName - The kebab-cased name of the component
* @param {Object} options
* @param {Object} options.nunjucksParams - Params passed to the Nunjucks macro
* @param {Object} [options.javascriptConfig] - The configuration hash passed to
* @param {string} componentName - The kebab-cased name of the component
* @param {object} options
* @param {object} options.nunjucksParams - Params passed to the Nunjucks macro
* @param {object} [options.javascriptConfig] - The configuration hash passed to
* the component's class for initialisation
* @param {Function} [options.initialiser] - A function that'll run in the
* browser to execute arbitrary initialisation. Receives an object with the
Expand Down Expand Up @@ -75,7 +75,7 @@ async function goTo (page, path) {
*
* @param {import('puppeteer').Page} page - Puppeteer page object
* @param {string} exampleName - Example name
* @param {import('puppeteer').WaitForOptions} [options] Navigation options (optional)
* @param {import('puppeteer').WaitForOptions} [options] - Navigation options (optional)
* @returns {Promise<import('puppeteer').Page>} Puppeteer page object
*/
function goToExample (page, exampleName, options) {
Expand Down Expand Up @@ -126,7 +126,7 @@ function getAttribute ($element, attributeName) {
* Check if element is visible
*
* @param {import('puppeteer').ElementHandle} $element - Puppeteer element handle
* @returns {Promise<Boolean>} Element visibility
* @returns {Promise<boolean>} Element visibility
*/
async function isVisible ($element) {
return !!await $element.boundingBox()
Expand Down
Loading