Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add control for executing rules based on Svelte/SvelteKit context #980

Merged
merged 19 commits into from
Jan 11, 2025
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/blue-swans-give.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'eslint-plugin-svelte': minor
---

feat: Implement util to conditionally run lint based on Svelte version and SvelteKit routes etc
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type { TSESTree } from '@typescript-eslint/types';
import { createRule } from '../utils/index.js';
import { isKitPageComponent } from '../utils/svelte-kit.js';

export default createRule('no-export-load-in-svelte-module-in-kit-pages', {
meta: {
Expand All @@ -8,7 +7,7 @@
description:
'disallow exporting load functions in `*.svelte` module in SvelteKit page components.',
category: 'Possible Errors',
// TODO Switch to recommended in the major version.

Check warning on line 10 in packages/eslint-plugin-svelte/src/rules/no-export-load-in-svelte-module-in-kit-pages.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected 'todo' comment: 'TODO Switch to recommended in the major...'
recommended: false
},
schema: [],
Expand All @@ -16,12 +15,14 @@
unexpected:
'disallow exporting load functions in `*.svelte` module in SvelteKit page components.'
},
type: 'problem'
type: 'problem',
conditions: [
{
svelteKitFileTypes: ['+page.svelte', '+error.svelte', '+layout.svelte']
}
]
},
create(context) {
if (!isKitPageComponent(context)) {
return {};
}
let isModule = false;
return {
// <script context="module">
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type { AST } from 'svelte-eslint-parser';
import type { TSESTree } from '@typescript-eslint/types';
import { createRule } from '../utils/index.js';
import { isKitPageComponent } from '../utils/svelte-kit.js';
import type { RuleContext } from '../types.js';

const EXPECTED_PROP_NAMES = ['data', 'errors', 'form', 'snapshot'];
Expand Down Expand Up @@ -35,10 +34,14 @@ export default createRule('valid-prop-names-in-kit-pages', {
messages: {
unexpected: 'disallow props other than data or errors in SvelteKit page components.'
},
type: 'problem'
type: 'problem',
conditions: [
{
svelteKitFileTypes: ['+page.svelte', '+error.svelte', '+layout.svelte']
}
]
},
create(context) {
if (!isKitPageComponent(context)) return {};
let isScript = false;
return {
// <script>
Expand Down
13 changes: 13 additions & 0 deletions packages/eslint-plugin-svelte/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { Root as SelectorRoot, Node as SelectorNode } from 'postcss-selecto
import type { ASTNode, ASTNodeWithParent, ASTNodeListener } from './types-for-node.js';
import type * as TS from 'typescript';
import type { SourceLocation } from 'svelte-eslint-parser/lib/ast/common.js';
import type { SvelteContext } from './utils/svelte-context.js';

export type { ASTNode, ASTNodeWithParent, ASTNodeListener };
export interface RuleListener extends ASTNodeListener {
Expand Down Expand Up @@ -108,6 +109,18 @@ export interface PartialRuleMetaData {
deprecated?: boolean;
replacedBy?: string[] | { note: string };
type: 'problem' | 'suggestion' | 'layout';
/**
* Conditions to determine whether this rule should be applied.
* Multiple conditions can be specified as array, and the rule will be applied if any one of them matches (logical OR).
* If not specified, the rule will be applied to all files.
*/
conditions?: {
svelteVersions?: SvelteContext['svelteVersion'][];
svelteFileTypes?: SvelteContext['svelteFileType'][];
runes?: SvelteContext['runes'][];
svelteKitVersions?: SvelteContext['svelteKitVersion'][];
svelteKitFileTypes?: SvelteContext['svelteKitFileType'][];
}[];
}

export type RuleContext = {
Expand Down
58 changes: 56 additions & 2 deletions packages/eslint-plugin-svelte/src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,50 @@
import type { RuleModule, PartialRuleModule } from '../types.js';
import type { RuleModule, PartialRuleModule, PartialRuleMetaData, RuleContext } from '../types.js';
import { getSvelteContext, type SvelteContext } from '../utils/svelte-context.js';

function doesNotSatisfy<T>(actual: T, expected?: T[]): boolean {
if (expected == null || expected.length === 0) {
return false;
}

return !expected.includes(actual);
}

function satisfiesCondition(
condition: NonNullable<PartialRuleMetaData['conditions']>[number],
svelteContext: SvelteContext
): boolean {
if (
doesNotSatisfy(svelteContext.svelteVersion, condition.svelteVersions) ||
doesNotSatisfy(svelteContext.svelteFileType, condition.svelteFileTypes) ||
doesNotSatisfy(svelteContext.runes, condition.runes) ||
doesNotSatisfy(svelteContext.svelteKitVersion, condition.svelteKitVersions) ||
doesNotSatisfy(svelteContext.svelteKitFileType, condition.svelteKitFileTypes)
) {
return false;
}

return true;
}

// export for testing
export function shouldRun(
svelteContext: SvelteContext | null,
conditions: PartialRuleMetaData['conditions']
): boolean {
// If svelteContext is null, it means the rule might be executed based on the analysis result of a different parser.
// In this case, always execute the rule.
if (svelteContext == null || conditions == null || conditions.length === 0) {
return true;
}

for (const condition of conditions) {
if (satisfiesCondition(condition, svelteContext)) {
return true;
}
}

return false;
}

/**
* Define the rule.
Expand All @@ -16,6 +62,14 @@ export function createRule(ruleName: string, rule: PartialRuleModule): RuleModul
ruleName
}
},
create: rule.create as never
create(context: RuleContext) {
const { conditions } = rule.meta;
const svelteContext = getSvelteContext(context);
if (!shouldRun(svelteContext, conditions)) {
return {};
}

return rule.create(context);
}
};
}
195 changes: 195 additions & 0 deletions packages/eslint-plugin-svelte/src/utils/svelte-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import type { RuleContext } from '../types.js';
import fs from 'fs';
import path from 'path';
import { getPackageJson } from './get-package-json.js';
import { getFilename, getSourceCode } from './compat.js';

const isRunInBrowser = !fs.readFileSync;

export type SvelteContext = {
svelteVersion: '3/4' | '5';
svelteFileType: '.svelte' | '.svelte.[js|ts]' | null;
runes: boolean;
svelteKitVersion: '1-next' | '1' | '2' | null;
svelteKitFileType:
| '+page.svelte'
| '+page.js'
| '+page.server.js'
| '+error.svelte'
| '+layout.svelte'
| '+layout.js'
| '+layout.server.js'
| '+server.js'
| null;
};

function getSvelteFileType(filePath: string): SvelteContext['svelteFileType'] | null {
if (filePath.endsWith('.svelte')) {
return '.svelte';
}

if (filePath.endsWith('.svelte.js') || filePath.endsWith('.svelte.ts')) {
return '.svelte.[js|ts]';
}

return null;
}

function getSvelteKitFileTypeFromFilePath(filePath: string): SvelteContext['svelteKitFileType'] {
const fileName = filePath.split('/').pop();
switch (fileName) {
case '+page.svelte': {
return '+page.svelte';
}
case '+page.js':
case '+page.ts': {
return '+page.js';
}
case '+page.server.js':
case '+page.server.ts': {
return '+page.server.js';
}
case '+error.svelte': {
return '+error.svelte';
}
case '+layout.svelte': {
return '+layout.svelte';
}
case '+layout.js':
case '+layout.ts': {
return '+layout.js';
}
case '+layout.server.js':
case '+layout.server.ts': {
return '+layout.server.js';
}
case '+server.js':
case '+server.ts': {
return '+server.js';
}
default: {
return null;
}
}
}

function getSvelteKitContext(
context: RuleContext
): Pick<SvelteContext, 'svelteKitFileType' | 'svelteKitVersion'> {
const filePath = getFilename(context);
const svelteKitVersion = getSvelteKitVersion(filePath);
if (svelteKitVersion == null) {
return {
svelteKitFileType: null,
svelteKitVersion: null
};
}
if (isRunInBrowser) {
return {
svelteKitVersion,
// Judge by only file path if it runs in browser.
svelteKitFileType: getSvelteKitFileTypeFromFilePath(filePath)
};
}

const routes =
(
context.settings?.svelte?.kit?.files?.routes ??
getSourceCode(context).parserServices.svelteParseContext?.svelteConfig?.kit?.files?.routes
)?.replace(/^\//, '') ?? 'src/routes';
const projectRootDir = getProjectRootDir(getFilename(context)) ?? '';

if (!filePath.startsWith(path.join(projectRootDir, routes))) {
return {
svelteKitVersion,
svelteKitFileType: null
};
}

return {
svelteKitVersion,
svelteKitFileType: getSvelteKitFileTypeFromFilePath(filePath)
};
}

/**
* Check givin file is under SvelteKit project.
*
* If it runs on browser, it always returns true.
*
* @param filePath A file path.
* @returns
*/
function getSvelteKitVersion(filePath: string): SvelteContext['svelteKitVersion'] {
// Hack: if it runs in browser, it regards as SvelteKit project.
if (isRunInBrowser) return '2';
try {
const packageJson = getPackageJson(filePath);
if (!packageJson) return null;
if (packageJson.name === 'eslint-plugin-svelte')
// Hack: CI removes `@sveltejs/kit` and it returns false and test failed.
// So always it returns true if it runs on the package.
return '2';

const version =
packageJson.dependencies?.['@sveltejs/kit'] ?? packageJson.devDependencies?.['@sveltejs/kit'];
if (typeof version !== 'string') {
return null;
}
if (version.startsWith('1.0.0-next.')) {
return '1-next';
} else if (version.startsWith('1.')) {
return '1';
} else if (version.startsWith('2.')) {
return '2';
}
// If unknown version, it recognize as v2.
return '2';
} catch {
return null;
}
}

function getSvelteVersion(compilerVersion: string): SvelteContext['svelteVersion'] {
const version = parseInt(compilerVersion.split('.')[0], 10);
if (version === 3 || version === 4) {
return '3/4';
}
return '5';
}

/**
* Gets a project root folder path.
* @param filePath A file path to lookup.
* @returns A found project root folder path or null.
*/
function getProjectRootDir(filePath: string): string | null {
if (isRunInBrowser) return null;
const packageJsonFilePath = getPackageJson(filePath)?.filePath;
if (!packageJsonFilePath) return null;
return path.dirname(path.resolve(packageJsonFilePath));
}

export function getSvelteContext(context: RuleContext): SvelteContext | null {
const { parserServices } = getSourceCode(context);
const { svelteParseContext } = parserServices;
if (svelteParseContext === undefined) {
return null;
}

const { compilerVersion } = svelteParseContext;
if (compilerVersion === undefined) {
return null;
}

const filePath = getFilename(context);
const svelteKitContext = getSvelteKitContext(context);

return {
svelteVersion: getSvelteVersion(compilerVersion),
runes: svelteParseContext.runes === true,
svelteFileType: getSvelteFileType(filePath),
svelteKitVersion: svelteKitContext.svelteKitVersion,
svelteKitFileType: svelteKitContext.svelteKitFileType
};
}
Loading
Loading