docbrown/app/javascript/utilities/markdown/markdownLintCustomRules.js
2021-10-13 16:37:34 +01:00

146 lines
5.2 KiB
JavaScript

/**
* Helper function for the image markdown lint rules.
*
* It takes a full line of text which includes an image with empty or default alt text (i.e. format "![]()") and returns the image portion only.
* This allows us to point users towards the exact image markdown text that triggered the rule.
*
* @param {string} contentLine The full line of content as provided by markdownlint
* @returns {string} a substring containing only the image text - e.g. "![alt text]()"
*/
const getImageTextString = (contentLine) => {
let indexOfImageStart = contentLine.indexOf('!');
while (contentLine.charAt(indexOfImageStart + 1) !== '[') {
// It's possible for an image to be inserted on a line with text preceding it,
// this check helps ensure that the '!' is actually the image start
indexOfImageStart = contentLine.indexOf('!', indexOfImageStart + 1);
if (indexOfImageStart === -1) {
return;
}
}
// Find the next closing bracket from the image start
// We don't need to worry about brackets inside the alt text as this check is only run on images with default or no alt text
const indexOfImageEnd = contentLine.indexOf(')', indexOfImageStart);
return contentLine.substring(indexOfImageStart, indexOfImageEnd + 1);
};
/**
* Custom markdown lint rule that detects if a user has uploaded an image, but not changed the default alt text
*/
export const noDefaultAltTextRule = {
names: ['no-default-alt-text'],
description: 'Images should not have the default alt text',
tags: ['images'],
function: (params, onError) => {
params.tokens
.filter((token) => token.type === 'inline')
.forEach(({ children }) => {
children.forEach((contentChild) => {
if (
contentChild.type === 'image' &&
contentChild.line.toLowerCase().includes('![image description]')
) {
onError({
lineNumber: contentChild.lineNumber,
detail: '/p/editor_guide#alt-text-for-images',
context: `Consider replacing the 'Image description' in square brackets at ${getImageTextString(
contentChild.line,
)} with a description of the image`,
});
}
});
});
},
};
/**
* A custom rule that mirrors the default "no-alt-text" rule, but with a more helpful error message
*/
export const noEmptyAltTextRule = {
names: ['no-empty-alt-text'],
description: 'Images should not have empty alt text',
tags: ['images'],
function: (params, onError) => {
params.tokens
.filter((token) => token.type === 'inline')
.forEach((inlineToken) => {
inlineToken.children.forEach((contentChild) => {
if (
contentChild.type === 'image' &&
contentChild.line.toLowerCase().includes('![]')
) {
onError({
lineNumber: inlineToken.lineNumber,
detail: '/p/editor_guide#alt-text-for-images',
context: `Consider adding an image description in the square brackets at ${getImageTextString(
contentChild.line,
)}`,
});
}
});
});
},
};
/**
* Custom markdown lint rule that detects if a level one heading has been used in a post
*/
export const noLevelOneHeadingsRule = {
names: ['no-level-one-heading'],
description: 'Heading level one should not be used in posts',
tags: ['headings'],
function: (params, onError) => {
const levelOneHeadings = [];
params.tokens.filter((token, index) => {
const isHeadingOneStart =
token.type === 'heading_open' && token.tag === 'h1';
if (isHeadingOneStart) {
// The next token is the actual content of the heading
levelOneHeadings.push(params.tokens[index + 1]);
}
});
levelOneHeadings.forEach((heading) => {
onError({
lineNumber: heading.lineNumber,
context: `Consider changing "${heading.line}" to a level two heading by using "##"`,
detail: '/p/editor_guide#accessible-headings',
});
});
},
};
/**
* A custom rule that mirrors the default "heading-increment" rule, but with a more helpful error message
*/
export const headingIncrement = {
names: ['custom-heading-increment'],
description: 'Heading levels should only increment by one level at a time',
tags: ['headings', 'headers'],
function: (params, onError) => {
let prevLevel = 0;
const headings = params.tokens.filter(
(token) => token.type === 'heading_open',
);
headings.forEach((heading) => {
const level = Number.parseInt(heading.tag.slice(1), 10);
if (prevLevel && level > prevLevel) {
// Heading level has increased
const suggestedHeadingLevel = prevLevel + 1;
if (suggestedHeadingLevel !== level) {
const suggestedHeadingStart = Array(suggestedHeadingLevel)
.fill('#')
.join('');
onError({
detail: '/p/editor_guide#accessible-headings',
lineNumber: heading.lineNumber,
context: `Consider changing the heading "${heading.line}" to a level ${suggestedHeadingLevel} heading by using "${suggestedHeadingStart}"`,
});
}
}
prevLevel = level;
});
},
};