UNPKG

eslint-plugin-unicorn

Version:
427 lines (367 loc) • 13 kB
import {ident} from '@eslint/css-tree'; import {decodeHTMLAttribute} from 'entities'; import {parse, preprocess, postprocess} from 'micromark'; import {parseFragment} from 'parse5'; import {isMethodCall, isNewExpression, isStringLiteral} from './ast/index.js'; import {getStaticValueIfNoSideEffects, isTypeScriptExpressionWrapper, toLocation} from './utils/index.js'; import getSrcsetCandidates from './shared/get-srcset-candidates.js'; const MESSAGE_ID_NEVER = 'never'; const MESSAGE_ID_ALWAYS = 'always'; const MESSAGE_ID_REMOVE = 'remove'; const messages = { [MESSAGE_ID_NEVER]: 'Remove the `./` prefix from the relative URL.', [MESSAGE_ID_ALWAYS]: 'Add a `./` prefix to the relative URL.', [MESSAGE_ID_REMOVE]: 'Remove leading `./`.', }; const DOT_SLASH = './'; const imageSetFunctions = new Set(['image-set', '-webkit-image-set']); const TEST_URL_BASES = [ 'https://example.com/a/b/', 'https://example.com/a/b.html', 'file:///a/b.html', ]; const isSafeToAddDotSlashToUrl = (url, base) => { try { const originalUrl = new URL(url, base); const urlWithDotSlash = new URL(DOT_SLASH + url, base); return originalUrl.href === urlWithDotSlash.href; } catch {} return false; }; const isSafeToAddDotSlash = (url, bases = TEST_URL_BASES) => bases.every(base => isSafeToAddDotSlashToUrl(url, base)); const isSafeToRemoveDotSlash = (url, bases = TEST_URL_BASES) => bases.every(base => isSafeToAddDotSlashToUrl(url.slice(DOT_SLASH.length), base)); /** Get the destination's offset relative to one complete Markdown image's source. @param {string} text @returns {number | undefined} */ function getMarkdownImageDestinationOffset(text) { const events = postprocess(parse().document().write(preprocess()(text, 'utf8', true))); const owners = []; for (const [event, token] of events) { if (token.type === 'image' || token.type === 'link') { if (event === 'enter') { owners.push(token); } else { owners.pop(); } } const owner = owners.at(-1); if ( event === 'enter' && token.type === 'resourceDestinationString' && owners.length === 1 && owner.type === 'image' && owner.start.offset === 0 && owner.end.offset === text.length ) { return token.start.offset; } } } /** @param {import('eslint').Rule.RuleContext} context */ const create = context => { const style = context.options[0]; const {sourceCode} = context; const templateDelimiters = Object.keys(context.languageOptions?.templateEngineSyntax ?? {}); const isCssResourceUrl = node => { const prelude = sourceCode.getAncestors(node).findLast(ancestor => ancestor.type === 'AtrulePrelude'); return !prelude || (ident.decode(sourceCode.getParent(prelude).name).toLowerCase() === 'import' && prelude.children.at(0) === node); }; const getUrlProblem = (node, url, start, bases) => { if (templateDelimiters.some(delimiter => url.includes(delimiter))) { return; } if (style === 'never') { if (!sourceCode.text.startsWith(DOT_SLASH, start) || !isSafeToRemoveDotSlash(url, bases)) { return; } } else if (/^\.\.?(?:[#/?\\]|$)/u.test(url) || url.startsWith('/') || !isSafeToAddDotSlash(url, bases)) { return; } return { node, messageId: style, fix: fixer => style === 'never' ? fixer.removeRange([start, start + DOT_SLASH.length]) : fixer.insertTextAfterRange([start, start], DOT_SLASH), }; }; context.on('Url', node => { if (!isCssResourceUrl(node)) { return; } // The CSS tokenizer only creates `Url` nodes from a literal `url(` (no escapes), so this always matches. const [prefix] = sourceCode.getText(node).match(/^url\([\t\n\f\r ]*["']?/i); return getUrlProblem(node, node.value, sourceCode.getRange(node)[0] + prefix.length); }); context.on('Atrule', node => { if (ident.decode(node.name).toLowerCase() !== 'import') { return; } const string = node.prelude?.children?.at(0); if (string?.type === 'String') { return getUrlProblem(string, string.value, sourceCode.getRange(string)[0] + 1); } }); context.on('String', node => { const parent = sourceCode.getParent(node); if (parent?.type !== 'Function' || !imageSetFunctions.has(ident.decode(parent.name).toLowerCase()) || !isCssResourceUrl(node)) { return; } return getUrlProblem(node, node.value, sourceCode.getRange(node)[0] + 1); }); const getHtmlAttributeProblems = (node, name, raw, start) => { if ( !['href', 'src', 'poster', 'srcset', 'imagesrcset', 'action', 'formaction', 'cite'].includes(name) || !raw || templateDelimiters.some(delimiter => raw.includes(delimiter)) ) { return; } const getAttributeProblem = (url, offset, length) => { const problem = getUrlProblem(node, url, start + offset); if (!problem) { return; } return { ...problem, loc: toLocation([start + offset, start + offset + length], context), }; }; if (name === 'srcset' || name === 'imagesrcset') { if (decodeHTMLAttribute(raw) !== raw) { return; } // A leading comma would become a candidate separator if the prefix were removed. return getSrcsetCandidates(raw) .filter(candidate => !candidate.value.startsWith('./,')) .map(candidate => getAttributeProblem(candidate.value, candidate.offsets[0], candidate.value.length)); } const whitespacePattern = /^[\t\n\f\r ]$/u; let valueStart = 0; let valueEnd = raw.length; while (valueStart < valueEnd && whitespacePattern.test(raw[valueStart])) { valueStart++; } while (valueEnd > valueStart && whitespacePattern.test(raw[valueEnd - 1])) { valueEnd--; } const value = raw.slice(valueStart, valueEnd); const url = decodeHTMLAttribute(value); if (url !== url.trim()) { return; } return [getAttributeProblem(url, valueStart, value.length)]; }; context.on('Attribute', node => { if (node.key.parts.length > 0 || !node.value || node.value.parts.length > 0) { return; } const [start] = sourceCode.getRange(node.value); // The HTML parser can stop an unquoted value at a slash. const raw = node.startWrapper ? sourceCode.getText(node.value) : sourceCode.text.slice(start).match(/^[^\t\n\f\r "'<>`]+/u)?.[0]; return getHtmlAttributeProblems(node, node.key.value.toLowerCase(), raw, start); }); context.on('JSXAttribute', node => { const tag = node.parent.name; if ( tag.type !== 'JSXIdentifier' || !/^[a-z][\dA-Za-z]*$/u.test(tag.name) || node.name.type !== 'JSXIdentifier' || !['href', 'src', 'poster', 'srcSet', 'imageSrcSet', 'action', 'formAction', 'cite'].includes(node.name.name) ) { return; } if (node.value?.type === 'JSXExpressionContainer') { const {expression} = node.value; if ( ['srcSet', 'imageSrcSet'].includes(node.name.name) || (!isStringLiteral(expression) && !(expression.type === 'TemplateLiteral' && expression.expressions.length === 0)) ) { return; } const url = expression.type === 'TemplateLiteral' ? expression.quasis[0].value.cooked : expression.value; return getUrlProblem(expression, url, sourceCode.getRange(expression)[0] + 1); } if (!isStringLiteral(node.value)) { return; } const raw = sourceCode.getText(node.value).slice(1, -1); // JSX parsers and HTML can decode character references differently. if (decodeHTMLAttribute(raw) !== node.value.value) { return; } return getHtmlAttributeProblems(node, node.name.name.toLowerCase(), raw, sourceCode.getRange(node.value)[0] + 1); }); const getMarkdownUrlProblem = (node, start) => { // Removing the prefix can turn a literal opening angle bracket into a destination delimiter. if (node.url.includes('<')) { return; } return getUrlProblem(node, node.url, start); }; context.on(['link', 'definition'], node => { // Complex definition labels are left unchanged when their destination cannot be located unambiguously. const [start, end] = sourceCode.getRange(node); const lastChild = node.children?.at(-1); const prefixStart = lastChild ? sourceCode.getRange(lastChild)[1] : start; const prefix = sourceCode.text.slice(prefixStart, end).match(lastChild ? /^\]\([\t\n\r ]*<?/u : /^\[(?:\\.|[^<[\\\]`])*\](?:\(|:)[\t\n\r ]*<?/u)?.[0]; if (!prefix) { return; } const destinationStart = prefixStart + prefix.length; // Blockquote markers in multiline destinations are not part of the URL. if (sourceCode.text[destinationStart] === '>' && /[\n\r]/u.test(prefix)) { return; } return getMarkdownUrlProblem(node, destinationStart); }); context.on('image', node => { const text = sourceCode.getText(node); // Markdown extensions can change image label boundaries. if (text.includes('[^') || (context.languageOptions.math === true && text.includes('$'))) { return; } const offset = getMarkdownImageDestinationOffset(text); if (offset !== undefined) { return getMarkdownUrlProblem(node, sourceCode.getRange(node)[0] + offset); } }); const markdownHtmlNodes = []; context.on('html', node => { markdownHtmlNodes.push(node); }); context.onExit('root', () => { // Container processing can change HTML content, so only use unchanged fragments. if (markdownHtmlNodes.length === 0 || markdownHtmlNodes.some(node => sourceCode.getText(node) !== node.value)) { return; } // Mask Markdown while preserving offsets and HTML context across separate fragments. const parts = []; let previousEnd = 0; for (const node of markdownHtmlNodes) { const [start, end] = sourceCode.getRange(node); parts.push(sourceCode.text.slice(previousEnd, start).replaceAll(/[^\n\r]/g, ' '), sourceCode.text.slice(start, end)); previousEnd = end; } parts.push(sourceCode.text.slice(previousEnd).replaceAll(/[^\n\r]/g, ' ')); const fragment = parseFragment(parts.join(''), {sourceCodeLocationInfo: true}); const problems = []; const collectProblems = element => { for (const attribute of element.attrs ?? []) { const location = element.sourceCodeLocation?.attrs?.[attribute.name]; if (attribute.prefix || !location) { continue; } const node = markdownHtmlNodes.find(node => { const [start, end] = sourceCode.getRange(node); return location.startOffset >= start && location.endOffset <= end; }); if (!node) { continue; } const text = sourceCode.text.slice(location.startOffset, location.endOffset); const prefix = text.match(/^[^=]*=[\t\n\f\r ]*(["']?)/u); if (!prefix) { continue; } const raw = text.slice(prefix[0].length, prefix[1] ? -1 : undefined); problems.push(...getHtmlAttributeProblems(node, attribute.name, raw, location.startOffset + prefix[0].length) ?? []); } for (const child of element.childNodes ?? []) { collectProblems(child); } if (element.content) { collectProblems(element.content); } }; collectProblems(fragment); return problems; }); context.on(['NewExpression', 'CallExpression'], node => { if ( !isNewExpression(node, {name: 'URL', argumentsLength: 2}) && !isMethodCall(node, { object: 'URL', methods: ['canParse', 'parse'], argumentsLength: 2, computed: false, optionalCall: false, optionalMember: false, }) ) { return; } let argument = node.arguments[0]; while (isTypeScriptExpressionWrapper(argument)) { if (argument.type === 'TSSatisfiesExpression') { return; } argument = argument.expression; } if (!isStringLiteral(argument) && argument.type !== 'TemplateLiteral') { return; } // Template literals are not always safe to remove `./` from, but report those starting with `./`. if (argument.type === 'TemplateLiteral' && argument.expressions.length > 0) { if (style !== 'never' || !argument.quasis[0].value.raw.startsWith(DOT_SLASH)) { return; } return { node: argument, messageId: style, suggest: [ { messageId: MESSAGE_ID_REMOVE, fix(fixer) { const start = sourceCode.getRange(argument)[0] + 1; // After quote return fixer.removeRange([start, start + DOT_SLASH.length]); }, }, ], }; } const url = argument.type === 'TemplateLiteral' ? argument.quasis[0].value.cooked : argument.value; const base = getStaticValueIfNoSideEffects(node.arguments[1], {sourceCode}); const bases = typeof base?.value === 'string' ? [base.value] : undefined; const start = sourceCode.getRange(argument)[0] + 1; // After quote return getUrlProblem(argument, url, start, bases); }); }; const schema = [ { enum: ['never', 'always'], description: 'Whether to never or always use a leading `./` for relative URLs.', }, ]; /** @type {import('eslint').Rule.RuleModule} */ const config = { create, meta: { type: 'suggestion', docs: { description: 'Enforce consistent relative URL style.', recommended: 'unopinionated', }, fixable: 'code', hasSuggestions: true, schema, defaultOptions: ['never'], messages, languages: [ 'js/js', 'css/css', 'html/html', 'markdown/commonmark', 'markdown/gfm', ], }, }; export default config;