eslint-plugin-svelte
Version:
ESLint plugin for Svelte using AST
250 lines (249 loc) • 10 kB
JavaScript
import { createRule } from '../utils/index.js';
import { ReferenceTracker } from '@eslint-community/eslint-utils';
import { getSourceCode } from '../utils/compat.js';
import { findVariable } from '../utils/ast-utils.js';
export default createRule('no-navigation-without-base', {
meta: {
docs: {
description: 'disallow using navigation (links, goto, pushState, replaceState) without the base path',
category: 'SvelteKit',
recommended: false
},
schema: [
{
type: 'object',
properties: {
ignoreGoto: {
type: 'boolean'
},
ignoreLinks: {
type: 'boolean'
},
ignorePushState: {
type: 'boolean'
},
ignoreReplaceState: {
type: 'boolean'
}
},
additionalProperties: false
}
],
messages: {
gotoNotPrefixed: "Found a goto() call with a url that isn't prefixed with the base path.",
linkNotPrefixed: "Found a link with a url that isn't prefixed with the base path.",
pushStateNotPrefixed: "Found a pushState() call with a url that isn't prefixed with the base path.",
replaceStateNotPrefixed: "Found a replaceState() call with a url that isn't prefixed with the base path."
},
type: 'suggestion'
},
create(context) {
let basePathNames = new Set();
return {
Program() {
const referenceTracker = new ReferenceTracker(getSourceCode(context).scopeManager.globalScope);
basePathNames = extractBasePathReferences(referenceTracker, context);
const { goto: gotoCalls, pushState: pushStateCalls, replaceState: replaceStateCalls } = extractFunctionCallReferences(referenceTracker);
if (context.options[0]?.ignoreGoto !== true) {
for (const gotoCall of gotoCalls) {
checkGotoCall(context, gotoCall, basePathNames);
}
}
if (context.options[0]?.ignorePushState !== true) {
for (const pushStateCall of pushStateCalls) {
checkShallowNavigationCall(context, pushStateCall, basePathNames, 'pushStateNotPrefixed');
}
}
if (context.options[0]?.ignoreReplaceState !== true) {
for (const replaceStateCall of replaceStateCalls) {
checkShallowNavigationCall(context, replaceStateCall, basePathNames, 'replaceStateNotPrefixed');
}
}
},
SvelteAttribute(node) {
if (context.options[0]?.ignoreLinks === true ||
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;
}
const hrefValue = node.value[0];
if (hrefValue.type === 'SvelteLiteral') {
if (!expressionIsAbsolute(hrefValue)) {
context.report({ loc: hrefValue.loc, messageId: 'linkNotPrefixed' });
}
return;
}
if (!expressionStartsWithBase(context, hrefValue.expression, basePathNames) &&
!expressionIsAbsolute(hrefValue.expression)) {
context.report({ loc: hrefValue.loc, messageId: 'linkNotPrefixed' });
}
}
};
}
});
// Extract all imports of the base path
function extractBasePathReferences(referenceTracker, context) {
const set = new Set();
for (const { node } of referenceTracker.iterateEsmReferences({
'$app/paths': {
[ReferenceTracker.ESM]: true,
base: {
[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 === 'base') {
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, basePathNames) {
if (call.arguments.length < 1) {
return;
}
const url = call.arguments[0];
if (url.type === 'SpreadElement' || !expressionStartsWithBase(context, url, basePathNames)) {
context.report({ loc: url.loc, messageId: 'gotoNotPrefixed' });
}
}
function checkShallowNavigationCall(context, call, basePathNames, messageId) {
if (call.arguments.length < 1) {
return;
}
const url = call.arguments[0];
if (url.type === 'SpreadElement' ||
(!expressionIsEmpty(url) && !expressionStartsWithBase(context, url, basePathNames))) {
context.report({ loc: url.loc, messageId });
}
}
// Helper functions
function expressionStartsWithBase(context, url, basePathNames) {
switch (url.type) {
case 'BinaryExpression':
return binaryExpressionStartsWithBase(context, url, basePathNames);
case 'Identifier':
return variableStartsWithBase(context, url, basePathNames);
case 'MemberExpression':
return memberExpressionStartsWithBase(url, basePathNames);
case 'TemplateLiteral':
return templateLiteralStartsWithBase(context, url, basePathNames);
default:
return false;
}
}
function binaryExpressionStartsWithBase(context, url, basePathNames) {
return (url.left.type !== 'PrivateIdentifier' &&
expressionStartsWithBase(context, url.left, basePathNames));
}
function memberExpressionStartsWithBase(url, basePathNames) {
return url.property.type === 'Identifier' && basePathNames.has(url.property);
}
function variableStartsWithBase(context, url, basePathNames) {
if (basePathNames.has(url)) {
return true;
}
const variable = findVariable(context, url);
if (variable === null ||
variable.identifiers.length !== 1 ||
variable.identifiers[0].parent.type !== 'VariableDeclarator' ||
variable.identifiers[0].parent.init === null) {
return false;
}
return expressionStartsWithBase(context, variable.identifiers[0].parent.init, basePathNames);
}
function templateLiteralStartsWithBase(context, url, basePathNames) {
const startingIdentifier = extractLiteralStartingExpression(url);
return (startingIdentifier !== undefined &&
expressionStartsWithBase(context, startingIdentifier, basePathNames));
}
function extractLiteralStartingExpression(templateLiteral) {
const literalParts = [...templateLiteral.expressions, ...templateLiteral.quasis].sort((a, b) => a.range[0] < b.range[0] ? -1 : 1);
for (const part of literalParts) {
if (part.type === 'TemplateElement' && part.value.raw === '') {
// Skip empty quasi in the begining
continue;
}
if (part.type !== 'TemplateElement') {
return part;
}
return undefined;
}
return undefined;
}
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 expressionIsAbsolute(url) {
switch (url.type) {
case 'BinaryExpression':
return binaryExpressionIsAbsolute(url);
case 'Literal':
return typeof url.value === 'string' && urlValueIsAbsolute(url.value);
case 'SvelteLiteral':
return urlValueIsAbsolute(url.value);
case 'TemplateLiteral':
return templateLiteralIsAbsolute(url);
default:
return false;
}
}
function binaryExpressionIsAbsolute(url) {
return ((url.left.type !== 'PrivateIdentifier' && expressionIsAbsolute(url.left)) ||
expressionIsAbsolute(url.right));
}
function templateLiteralIsAbsolute(url) {
return (url.expressions.some(expressionIsAbsolute) ||
url.quasis.some((quasi) => urlValueIsAbsolute(quasi.value.raw)));
}
function urlValueIsAbsolute(url) {
return url.includes('://');
}