emmercm/metalsmith-plugins

View on GitHub
packages/metalsmith-html-linter/index.ts

Summary

Maintainability
A
1 hr
Test Coverage
import { codeFrameColumns } from '@babel/code-frame';
import linthtml, { LegacyLinterConfig, LinterConfig } from '@linthtml/linthtml';
import async from 'async';
import * as cheerio from 'cheerio';
import deepmerge from 'deepmerge';
import Metalsmith from 'metalsmith';
import os from 'os';
 
export interface Options {
html?: string;
parallelism?: number;
ignoreTags?: string[];
htmllint?: LegacyLinterConfig;
linthtml?: LinterConfig;
}
 
Function `upgradeHtmllintConfig` has 28 lines of code (exceeds 25 allowed). Consider refactoring.
const upgradeHtmllintConfig = (htmllint: LegacyLinterConfig): LinterConfig => {
const config: { [key: string]: unknown } = {};
 
// https://github.com/linthtml/linthtml/blob/0.3.0/docs/migrations.md
const rules = Object.keys(htmllint).reduce(
(acc, rule) => {
const ruleConfig = htmllint[rule];
if (typeof ruleConfig === 'boolean') {
acc[rule] = ruleConfig;
} else {
acc[rule] = [true, ruleConfig];
}
return acc;
},
{} as { [key: string]: unknown },
);
[
'maxerr',
'text-ignore-regex',
'raw-ignore-regex',
'attr-name-ignore-regex',
'id-class-ignore-regex',
'line-max-len-ignore-regex',
].forEach((rule) => {
if (rules[rule] !== undefined) {
config[rule] = rules[rule];
delete rules[rule];
}
});
config.rules = rules;
 
return config;
};
 
// Get the linthtml (written in htmllint config) and upgrade it
const linthtmlDefault = upgradeHtmllintConfig(linthtml.default.presets.default);
 
export default (options: Options = {}): Metalsmith.Plugin => {
// Upgrade any old htmllint config
if (options.htmllint !== undefined) {
options.linthtml = upgradeHtmllintConfig(options.htmllint);
delete options.htmllint;
}
 
const defaultedOptions: Options = deepmerge.all(
[
{
linthtml: {
...linthtmlDefault,
},
},
{
html: '**/*.html',
linthtml: {
'text-ignore-regex': /&[a-zA-Z0-9]+=/, // https://github.com/htmllint/htmllint/issues/267
rules: {
'attr-bans': [
// https://www.w3.org/TR/html5-diff/#obsolete-attributes
// https://web.dev/optimize-cls/#images-without-dimensions (Google Lighthouse)
true,
[
'align',
'alink',
'background',
'bgcolor',
'border',
'cellpadding',
'cellspacing',
'char',
'charoff',
'clear',
'compact',
'frame',
'frameborder',
'hspace',
'link',
'marginheight',
'marginwidth',
'noshade',
'nowrap',
'rules',
'scrolling',
'size',
'text',
'valign',
'vlink',
'vspace',
],
],
'attr-req-value': false, // https://dev.w3.org/html5/spec-LC/syntax.html#attributes-0
'doctype-first': true, // https://dev.w3.org/html5/spec-LC/syntax.html#the-doctype
'id-class-style': false,
'indent-style': false,
'indent-width': false,
'line-end-style': false,
'line-no-trailing-whitespace': false,
'tag-bans': [
// https://www.w3.org/TR/html5-diff/#obsolete-elements
true,
[
'acronym',
'applet',
'basefont',
'big',
'center',
'dir',
'font',
'frame',
'frameset',
'isindex',
'noframes',
'strike',
'tt',
],
],
'tag-name-lowercase': false, // https://dev.w3.org/html5/spec-LC/syntax.html#elements-0,
'title-max-len': false, // https://dev.w3.org/html5/spec-LC/semantics.html#the-title-element
},
},
ignoreTags: [
// https://github.com/htmllint/htmllint/issues/194
'code',
'pre',
'svg',
'textarea',
],
parallelism: os.cpus().length,
},
options || {},
],
{ arrayMerge: (destinationArray, sourceArray) => sourceArray },
);
 
return (files, metalsmith, done) => {
const debug = metalsmith.debug('metalsmith-html-linter');
debug('running with options: %O', defaultedOptions);
 
const htmlFiles = metalsmith.match(defaultedOptions.html ?? '**/*', Object.keys(files));
 
const failures: string[] = [];
 
async.eachLimit(
htmlFiles,
defaultedOptions.parallelism ?? 1,
(filename, complete) => {
debug('processing file: %s', filename);
 
const file = files[filename];
const $ = cheerio.load(file.contents, {
// @ts-expect-error: _useHtmlParser2 is necessary but isn't exposed in CheerioOptions
_useHtmlParser2: true, // https://github.com/cheeriojs/cheerio/issues/1198
decodeEntities: false,
});
 
// Remove ignored tags
if (defaultedOptions.ignoreTags) {
$(defaultedOptions.ignoreTags.join(', ')).remove();
}
 
const contents = $.html();
 
linthtml
.default(contents, defaultedOptions.linthtml ?? {})
.then((results) => {
if (results.length) {
const codeFrames = results
.map((result) => {
// Use @babel/code-frame to get a more human-readable error message
const frame = codeFrameColumns(contents, {
start: result.position.start,
});
let data = '';
if (Object.keys(result.data as object).length) {
data = ` ${JSON.stringify(result.data)}:`;
}
return `${result.rule} (${result.code}):${data}\n\n${frame.replace(/^/gm, ' ')}`;
})
.join('\n\n');
 
failures.push(`${filename}:\n\n${codeFrames.replace(/^/gm, ' ')}`);
}
 
complete();
})
.catch((err: unknown) => {
debug.error('linthtml error: %s', err);
failures.push(`${filename}:\n\n${err}`);
complete();
});
},
(err) => {
if (err) {
done(err);
return;
}
 
if (failures.length) {
done(new Error(failures.join('\n\n')));
return;
}
 
done();
},
);
};
};