UNPKG

eslint-plugin-svelte

Version:
325 lines (324 loc) 13.5 kB
import { createRule } from '../utils/index.js'; import { ReferenceTracker } from '@eslint-community/eslint-utils'; import { FindVariableContext } from '../utils/ast-utils.js'; import { findVariable } from '../utils/ast-utils.js'; export default createRule('no-navigation-without-resolve', { meta: { docs: { description: 'disallow internal navigation (links, `goto()`, `pushState()`, `replaceState()`) without a `resolve()`', category: 'SvelteKit', recommended: true }, schema: [ { type: 'object', properties: { ignoreGoto: { type: 'boolean' }, ignoreLinks: { type: 'boolean' }, ignorePushState: { type: 'boolean' }, ignoreReplaceState: { type: 'boolean' } }, additionalProperties: false } ], messages: { gotoWithoutResolve: 'Unexpected goto() call without resolve().', linkWithoutResolve: 'Unexpected href link without resolve().', pushStateWithoutResolve: 'Unexpected pushState() call without resolve().', replaceStateWithoutResolve: 'Unexpected replaceState() call without resolve().' }, type: 'suggestion', conditions: [ { svelteKitVersions: ['1.0.0-next', '1', '2'] } ] }, create(context) { let resolveReferences = new Set(); const ignoreGoto = context.options[0]?.ignoreGoto ?? false; const ignorePushState = context.options[0]?.ignorePushState ?? false; const ignoreReplaceState = context.options[0]?.ignoreReplaceState ?? false; const ignoreLinks = context.options[0]?.ignoreLinks ?? false; return { Program() { const referenceTracker = new ReferenceTracker(context.sourceCode.scopeManager.globalScope); resolveReferences = extractResolveReferences(referenceTracker, context); const { goto: gotoCalls, pushState: pushStateCalls, replaceState: replaceStateCalls } = extractFunctionCallReferences(referenceTracker); if (!ignoreGoto) { for (const gotoCall of gotoCalls) { checkGotoCall(context, gotoCall, resolveReferences); } } if (!ignorePushState) { for (const pushStateCall of pushStateCalls) { checkShallowNavigationCall(context, pushStateCall, resolveReferences, 'pushStateWithoutResolve'); } } if (!ignoreReplaceState) { for (const replaceStateCall of replaceStateCalls) { checkShallowNavigationCall(context, replaceStateCall, resolveReferences, 'replaceStateWithoutResolve'); } } }, ...(!ignoreLinks && { SvelteShorthandAttribute(node) { checkLinkAttribute(context, node, node.value, resolveReferences); }, SvelteAttribute(node) { if (node.value.length > 0) { checkLinkAttribute(context, node, node.value[0].type === 'SvelteMustacheTag' ? node.value[0].expression : node.value[0], resolveReferences); } } }) }; } }); // Extract all imports of the resolve() function function extractResolveReferences(referenceTracker, context) { const set = new Set(); for (const { node } of referenceTracker.iterateEsmReferences({ '$app/paths': { [ReferenceTracker.ESM]: true, asset: { [ReferenceTracker.READ]: true }, resolve: { [ReferenceTracker.READ]: true } } })) { if (node.type === 'ImportSpecifier') { const variable = findVariable(context, node.local); if (variable === null) { continue; } for (const reference of variable.references) { if (reference.identifier.type === 'Identifier') set.add(reference.identifier); } } else if (node.type === 'MemberExpression' && node.property.type === 'Identifier' && node.property.name === 'resolve') { set.add(node.property); } } return set; } // Extract all references to goto, pushState and replaceState function extractFunctionCallReferences(referenceTracker) { const rawReferences = Array.from(referenceTracker.iterateEsmReferences({ '$app/navigation': { [ReferenceTracker.ESM]: true, goto: { [ReferenceTracker.CALL]: true }, pushState: { [ReferenceTracker.CALL]: true }, replaceState: { [ReferenceTracker.CALL]: true } } })); return { goto: rawReferences .filter(({ path }) => path[path.length - 1] === 'goto') .map(({ node }) => node), pushState: rawReferences .filter(({ path }) => path[path.length - 1] === 'pushState') .map(({ node }) => node), replaceState: rawReferences .filter(({ path }) => path[path.length - 1] === 'replaceState') .map(({ node }) => node) }; } // Actual function checking function checkGotoCall(context, call, resolveReferences) { if (call.arguments.length > 0 && !isValueAllowed(new FindVariableContext(context), call.arguments[0], resolveReferences, {})) { context.report({ loc: call.arguments[0].loc, messageId: 'gotoWithoutResolve' }); } } function checkShallowNavigationCall(context, call, resolveReferences, messageId) { if (call.arguments.length > 0 && !isValueAllowed(new FindVariableContext(context), call.arguments[0], resolveReferences, { allowEmpty: true })) { context.report({ loc: call.arguments[0].loc, messageId }); } } function checkLinkAttribute(context, attribute, value, resolveReferences) { if (attribute.parent.parent.type === 'SvelteElement' && attribute.parent.parent.kind === 'html' && attribute.parent.parent.name.type === 'SvelteName' && attribute.parent.parent.name.name === 'a' && attribute.key.name === 'href' && !hasRelExternal(new FindVariableContext(context), attribute.parent) && !isValueAllowed(new FindVariableContext(context), value, resolveReferences, { allowAbsolute: true, allowFragment: true, allowNullish: true })) { context.report({ loc: attribute.loc, messageId: 'linkWithoutResolve' }); } } function hasRelExternal(ctx, element) { function identifierIsExternal(identifier) { const variable = ctx.findVariable(identifier); return (variable !== null && variable.identifiers.length > 0 && variable.identifiers[0].parent.type === 'VariableDeclarator' && variable.identifiers[0].parent.init !== null && variable.identifiers[0].parent.init.type === 'Literal' && variable.identifiers[0].parent.init.value === 'external'); } for (const attr of element.attributes) { if ((attr.type === 'SvelteAttribute' && attr.key.name === 'rel' && ((attr.value[0].type === 'SvelteLiteral' && attr.value[0].value.split(/\s+/).includes('external')) || (attr.value[0].type === 'SvelteMustacheTag' && ((attr.value[0].expression.type === 'Literal' && attr.value[0].expression.value?.toString().split(/\s+/).includes('external')) || (attr.value[0].expression.type === 'Identifier' && identifierIsExternal(attr.value[0].expression)))))) || (attr.type === 'SvelteShorthandAttribute' && attr.key.name === 'rel' && attr.value.type === 'Identifier' && identifierIsExternal(attr.value))) { return true; } } return false; } function isValueAllowed(ctx, value, resolveReferences, config) { if (value.type === 'Identifier') { const variable = ctx.findVariable(value); if (variable !== null && variable.identifiers.length > 0 && variable.identifiers[0].parent.type === 'VariableDeclarator' && variable.identifiers[0].parent.init !== null) { return isValueAllowed(ctx, variable.identifiers[0].parent.init, resolveReferences, config); } } if (value.type === 'ConditionalExpression') { return (isValueAllowed(ctx, value.consequent, resolveReferences, config) && isValueAllowed(ctx, value.alternate, resolveReferences, config)); } if ((config.allowAbsolute && expressionIsAbsoluteUrl(ctx, value)) || (config.allowEmpty && expressionIsEmpty(value)) || (config.allowFragment && expressionStartsWith(ctx, value, '#')) || (config.allowNullish && expressionIsNullish(value)) || expressionIsResolveCall(ctx, value, resolveReferences)) { return true; } return false; } // Helper functions function expressionIsResolveCall(ctx, node, resolveReferences) { if (node.type === 'CallExpression' && ((node.callee.type === 'Identifier' && resolveReferences.has(node.callee)) || (node.callee.type === 'MemberExpression' && node.callee.property.type === 'Identifier' && resolveReferences.has(node.callee.property)))) { return true; } if (node.type !== 'Identifier') { return false; } const variable = ctx.findVariable(node); if (variable === null || variable.identifiers.length === 0 || variable.identifiers[0].parent.type !== 'VariableDeclarator' || variable.identifiers[0].parent.init === null) { return false; } return expressionIsResolveCall(ctx, variable.identifiers[0].parent.init, resolveReferences); } function expressionIsEmpty(node) { return ((node.type === 'Literal' && node.value === '') || (node.type === 'TemplateLiteral' && node.expressions.length === 0 && node.quasis.length === 1 && node.quasis[0].value.raw === '')); } function expressionIsNullish(node) { switch (node.type) { case 'Identifier': return node.name === 'undefined'; case 'Literal': return node.value === null; // Undefined is an Identifier in ESTree, null is a Literal default: return false; } } function expressionIsAbsoluteUrl(ctx, node) { switch (node.type) { case 'BinaryExpression': return binaryExpressionIsAbsoluteUrl(ctx, node); case 'Literal': return typeof node.value === 'string' && valueIsAbsoluteUrl(node.value); case 'SvelteLiteral': return valueIsAbsoluteUrl(node.value); case 'TemplateLiteral': return templateLiteralIsAbsoluteUrl(ctx, node); default: return false; } } function binaryExpressionIsAbsoluteUrl(ctx, node) { return (node.operator === '+' && (expressionIsAbsoluteUrl(ctx, node.left) || expressionIsAbsoluteUrl(ctx, node.right))); } function templateLiteralIsAbsoluteUrl(ctx, node) { return (node.expressions.some((expression) => expressionIsAbsoluteUrl(ctx, expression)) || node.quasis.some((quasi) => valueIsAbsoluteUrl(quasi.value.raw))); } function valueIsAbsoluteUrl(node) { return /^[+a-z]*:/i.test(node); } function expressionStartsWith(ctx, node, prefix) { switch (node.type) { case 'BinaryExpression': return binaryExpressionStartsWith(ctx, node, prefix); case 'Identifier': return identifierStartsWith(ctx, node, prefix); case 'Literal': return typeof node.value === 'string' && node.value.startsWith(prefix); case 'SvelteLiteral': return node.value.startsWith(prefix); case 'TemplateElement': return node.value.raw.startsWith(prefix); case 'TemplateLiteral': return templateLiteralStartsWith(ctx, node, prefix); default: return false; } } function binaryExpressionStartsWith(ctx, node, prefix) { return node.operator === '+' && expressionStartsWith(ctx, node.left, prefix); } function identifierStartsWith(ctx, node, prefix) { const variable = ctx.findVariable(node); if (variable === null || variable.identifiers.length === 0 || variable.identifiers[0].parent.type !== 'VariableDeclarator' || variable.identifiers[0].parent.init === null) { return false; } return expressionStartsWith(ctx, variable.identifiers[0].parent.init, prefix); } function templateLiteralStartsWith(ctx, node, prefix) { return ((node.expressions.length >= 1 && expressionStartsWith(ctx, node.expressions[0], prefix)) || (node.quasis.length >= 1 && expressionStartsWith(ctx, node.quasis[0], prefix))); }