UNPKG

eslint-plugin-svelte

Version:
316 lines (315 loc) 12.9 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 using 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'); } } }, SvelteShorthandAttribute(node) { if (ignoreLinks || node.parent.parent.type !== 'SvelteElement' || node.parent.parent.kind !== 'html' || node.parent.parent.name.type !== 'SvelteName' || node.parent.parent.name.name !== 'a' || node.key.name !== 'href' || node.value.type !== 'Identifier') { return; } if (!expressionIsAbsolute(new FindVariableContext(context), node.value) && !expressionIsFragment(new FindVariableContext(context), node.value) && !isResolveCall(new FindVariableContext(context), node.value, resolveReferences)) { context.report({ loc: node.loc, messageId: 'linkWithoutResolve' }); } }, SvelteAttribute(node) { if (ignoreLinks || node.parent.parent.type !== 'SvelteElement' || node.parent.parent.kind !== 'html' || node.parent.parent.name.type !== 'SvelteName' || node.parent.parent.name.name !== 'a' || node.key.name !== 'href') { return; } if ((node.value[0].type === 'SvelteLiteral' && !expressionIsNullish(new FindVariableContext(context), node.value[0]) && !expressionIsAbsolute(new FindVariableContext(context), node.value[0]) && !expressionIsFragment(new FindVariableContext(context), node.value[0])) || (node.value[0].type === 'SvelteMustacheTag' && !expressionIsNullish(new FindVariableContext(context), node.value[0].expression) && !expressionIsAbsolute(new FindVariableContext(context), node.value[0].expression) && !expressionIsFragment(new FindVariableContext(context), node.value[0].expression) && !isResolveCall(new FindVariableContext(context), node.value[0].expression, resolveReferences))) { context.report({ loc: node.value[0].loc, messageId: 'linkWithoutResolve' }); } } }; } }); // 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 < 1) { return; } const url = call.arguments[0]; if (!isResolveCall(new FindVariableContext(context), url, resolveReferences)) { context.report({ loc: url.loc, messageId: 'gotoWithoutResolve' }); } } function checkShallowNavigationCall(context, call, resolveReferences, messageId) { if (call.arguments.length < 1) { return; } const url = call.arguments[0]; if (!expressionIsEmpty(url) && !isResolveCall(new FindVariableContext(context), url, resolveReferences)) { context.report({ loc: url.loc, messageId }); } } // Helper functions function isResolveCall(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 isResolveCall(ctx, variable.identifiers[0].parent.init, resolveReferences); } function expressionIsEmpty(url) { return ((url.type === 'Literal' && url.value === '') || (url.type === 'TemplateLiteral' && url.expressions.length === 0 && url.quasis.length === 1 && url.quasis[0].value.raw === '')); } function expressionIsNullish(ctx, url) { switch (url.type) { case 'Identifier': return identifierIsNullish(ctx, url); case 'Literal': return url.value === null; // Undefined is an Identifier in ESTree, null is a Literal default: return false; } } function identifierIsNullish(ctx, url) { if (url.name === 'undefined') { return true; } const variable = ctx.findVariable(url); if (variable === null || variable.identifiers.length === 0 || variable.identifiers[0].parent.type !== 'VariableDeclarator' || variable.identifiers[0].parent.init === null) { return false; } return expressionIsNullish(ctx, variable.identifiers[0].parent.init); } function expressionIsAbsolute(ctx, url) { switch (url.type) { case 'BinaryExpression': return binaryExpressionIsAbsolute(ctx, url); case 'Identifier': return identifierIsAbsolute(ctx, url); case 'Literal': return typeof url.value === 'string' && urlValueIsAbsolute(url.value); case 'SvelteLiteral': return urlValueIsAbsolute(url.value); case 'TemplateLiteral': return templateLiteralIsAbsolute(ctx, url); default: return false; } } function binaryExpressionIsAbsolute(ctx, url) { return ((url.left.type !== 'PrivateIdentifier' && expressionIsAbsolute(ctx, url.left)) || expressionIsAbsolute(ctx, url.right)); } function identifierIsAbsolute(ctx, url) { const variable = ctx.findVariable(url); if (variable === null || variable.identifiers.length === 0 || variable.identifiers[0].parent.type !== 'VariableDeclarator' || variable.identifiers[0].parent.init === null) { return false; } return expressionIsAbsolute(ctx, variable.identifiers[0].parent.init); } function templateLiteralIsAbsolute(ctx, url) { return (url.expressions.some((expression) => expressionIsAbsolute(ctx, expression)) || url.quasis.some((quasi) => urlValueIsAbsolute(quasi.value.raw))); } function urlValueIsAbsolute(url) { return /^[+a-z]*:/i.test(url); } function expressionIsFragment(ctx, url) { switch (url.type) { case 'BinaryExpression': return binaryExpressionIsFragment(ctx, url); case 'Identifier': return identifierIsFragment(ctx, url); case 'Literal': return typeof url.value === 'string' && urlValueIsFragment(url.value); case 'SvelteLiteral': return urlValueIsFragment(url.value); case 'TemplateLiteral': return templateLiteralIsFragment(ctx, url); default: return false; } } function binaryExpressionIsFragment(ctx, url) { return url.left.type !== 'PrivateIdentifier' && expressionIsFragment(ctx, url.left); } function identifierIsFragment(ctx, url) { const variable = ctx.findVariable(url); if (variable === null || variable.identifiers.length === 0 || variable.identifiers[0].parent.type !== 'VariableDeclarator' || variable.identifiers[0].parent.init === null) { return false; } return expressionIsFragment(ctx, variable.identifiers[0].parent.init); } function templateLiteralIsFragment(ctx, url) { return ((url.expressions.length >= 1 && expressionIsFragment(ctx, url.expressions[0])) || (url.quasis.length >= 1 && urlValueIsFragment(url.quasis[0].value.raw))); } function urlValueIsFragment(url) { return url.startsWith('#'); }