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 8 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'][];
fileTypes?: SvelteContext['fileType'][];
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 isNotSatisfies<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 (
isNotSatisfies(svelteContext.svelteVersion, condition.svelteVersions) ||
isNotSatisfies(svelteContext.fileType, condition.fileTypes) ||
isNotSatisfies(svelteContext.runes, condition.runes) ||
isNotSatisfies(svelteContext.svelteKitVersion, condition.svelteKitVersions) ||
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

runes are a boolean, isNotSatisfies doesn't work for that...

Copy link
Member Author

@baseballyama baseballyama Jan 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I already changed it to array. How can I make "runes" even more plural?😅

isNotSatisfies(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);
}
};
}
201 changes: 201 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,201 @@
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';
fileType: '.svelte' | '.svelte.[js|ts]';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I don't like the design of having separate fileType and svelteKitFileType. I see 2 ways to do it better:

  1. Merge them
  2. Don't provide these, but go conceptually one level further - why would we want to know the file type? Maybe what we actually want to know is things like
    1. Are we in a SFC file?
    2. Are we in the server context?
    3. etc.

I think the second solution is more work (and requires quite careful design), but ultimately serves us better, because rules are fundamentally going to be enabled based on the properties of the current context/file, not based on its type...

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At here, it’s better to keep things simple and store only basic information. This is because the definition of "context" can change depending on the rules. For example, we might want to create a rule that prevents placing files like xxx.svelte.js in the same directory as +page.svelte.
Another example is that xxx.svelte.js might be used for implementing composables, and we might want to enforce a rule that their names must start with useX.

In short, since the definition of "context" depends on the rules, it’s better to just manage file types here.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm still not convinced. I think we should instead add something like a fileContext field where we add some information about the file and we can possibly extend it in the future. I am not sure your first example is possible in eslint, as it operates on files separately. But anyway, we would probably need a separate flag for .svelte.js/ts files. But I don't think it is useful to have a way to distinguish +page.js, +page.server.js, +layout.js and +layout.server.js - what you would actually want in most situations is 2 flags - "Is this a layout file?" and "Is this a server-only file?"

Does that make sense to you or am I being too convoluted?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is one of specific example why "Is this a layout file?" and "Is this a server-only file" is not enough.
#438

This rule should check only +layout.(js|ts). (+layout.svelte should not check.)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, maybe I'm wrong (I haven't used SvelteKit server-side yet), but #438 should also check +page.(js|ts), no? Then it's exactly what I've been getting at :D The differentiator isn't really a list of filename suffixes, but a role the file plays in SvelteKit...

Anyway, if my change is not accepted, then I would like to think a little bit more about how fileType and svelteKitFileType interact...

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And yes, the list of "file role flags" would probably be longer and would need to expand over time and some rules would check multiple flags at once...

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And yes, the list of "file role flags" would probably be longer and would need to expand over time and some rules would check multiple flags at once...

Yes. In that case, we can add helper functions for common validation patterns.
I think it’s best to start with basic functionality and then add more convenient features later if they prove to be valuable.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, maybe I'm wrong (I haven't used SvelteKit server-side yet), but #438 should also check +page.(js|ts),

Yes. We need to check +page.(js|ts) also, but what I wanted to say is that "Is this a layout file?" and "Is this a server-only file" is not enough.

Copy link
Contributor

@marekdedic marekdedic Jan 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. In that case, we can add helper functions for common validation patterns.

Hmm, yeah, that's probably a good enough alternative.

I was thinking about the fileType a little bit more as well. I get that in svelteKitFileType, the list of file suffixes makes sense, but I think it gets confusing with fileType pretty quickly - if I understand it correctly, a +page.svelte would havea file type .svelte and +page.js would have a file type of .svelte.js - which is a little counterintuitive. Maybe fileType could have values "component"|"plain" or something similar? Then it wouldn't give the illusion of being a filename extension, which it isn't in case of SvelteKit files...

Copy link
Member Author

@baseballyama baseballyama Jan 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rename to svelteFileType.

bbfae0e

if I understand it correctly, a +page.svelte would havea file type .svelte and +page.js would have a file type of .svelte.js

No. .svelte.[js|ts] will be used for xxx.svelte.js or xxx.svelte.ts. But not +page.js,

.svelte and .svelte.[js|ts] is taken from the docs. (I intended svelteFileType to be determined by the file extension.)
https://svelte.dev/docs/svelte/svelte-js-files

image

svelteFileType should be null for +page.js, so I fixed this bug.

8a02534

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 getFileType(filePath: string): SvelteContext['fileType'] | 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);

const runes = svelteParseContext.runes === true;
const fileType = getFileType(filePath);
if (fileType === null) {
return null;
}

return {
svelteVersion: getSvelteVersion(compilerVersion),
runes,
fileType,
svelteKitVersion: svelteKitContext.svelteKitVersion,
svelteKitFileType: svelteKitContext.svelteKitFileType
};
}
Loading
Loading