- Delete old Vite+Svelte frontend - Initialize new SvelteKit project with TypeScript - Configure Tailwind CSS v4 + DaisyUI - Implement JWT authentication with auto-refresh - Create login page with form validation (Zod) - Add protected route guards - Update Docker configuration for single-stage build - Add E2E tests with Playwright (6/11 passing) - Fix Svelte 5 reactivity with $state() runes Known issues: - 5 E2E tests failing (timing/async issues) - Token refresh implementation needs debugging - Validation error display timing
205 lines
9.8 KiB
JavaScript
205 lines
9.8 KiB
JavaScript
import { getPropertyName } from '@eslint-community/eslint-utils';
|
|
import { keyword } from 'esutils';
|
|
import { createRule } from '../utils/index.js';
|
|
import { findAttribute, isExpressionIdentifier, findVariable } from '../utils/ast-utils.js';
|
|
import { getSvelteContext } from '../utils/svelte-context.js';
|
|
import { SVELTE_RUNES } from '../shared/runes.js';
|
|
export default createRule('prefer-destructured-store-props', {
|
|
meta: {
|
|
docs: {
|
|
description: 'destructure values from object stores for better change tracking & fewer redraws',
|
|
category: 'Best Practices',
|
|
recommended: false
|
|
},
|
|
hasSuggestions: true,
|
|
schema: [],
|
|
messages: {
|
|
useDestructuring: `Destructure {{property}} from {{store}} for better change tracking & fewer redraws`,
|
|
fixUseDestructuring: `Using destructuring like $: ({ {{property}} } = {{store}}); will run faster`,
|
|
fixUseVariable: `Using the predefined reactive variable {{variable}}`
|
|
},
|
|
type: 'suggestion'
|
|
},
|
|
create(context) {
|
|
let mainScript = null;
|
|
const svelteContext = getSvelteContext(context);
|
|
// Store off instances of probably-destructurable statements
|
|
const reports = [];
|
|
let inScriptElement = false;
|
|
const storeMemberAccessStack = [];
|
|
/** Find for defined reactive variables. */
|
|
function* findReactiveVariable(object, propName) {
|
|
const storeVar = findVariable(context, object);
|
|
if (!storeVar) {
|
|
return;
|
|
}
|
|
for (const reference of storeVar.references) {
|
|
const id = reference.identifier;
|
|
if (id.name !== object.name)
|
|
continue;
|
|
if (isReactiveVariableDefinitionWithMemberExpression(id)) {
|
|
// $: target = $store.prop
|
|
yield id.parent.parent.left;
|
|
}
|
|
else if (isReactiveVariableDefinitionWithDestructuring(id)) {
|
|
const prop = id.parent.left.properties.find((prop) => prop.type === 'Property' &&
|
|
prop.value.type === 'Identifier' &&
|
|
getPropertyName(prop) === propName);
|
|
if (prop) {
|
|
// $: ({prop: target} = $store)
|
|
yield prop.value;
|
|
}
|
|
}
|
|
}
|
|
/** Checks whether the given node is reactive variable definition with member expression. */
|
|
function isReactiveVariableDefinitionWithMemberExpression(node) {
|
|
return (node.type === 'Identifier' &&
|
|
node.parent?.type === 'MemberExpression' &&
|
|
node.parent.object === node &&
|
|
getPropertyName(node.parent) === propName &&
|
|
node.parent.parent?.type === 'AssignmentExpression' &&
|
|
node.parent.parent.right === node.parent &&
|
|
node.parent.parent.left.type === 'Identifier' &&
|
|
node.parent.parent.parent?.type === 'ExpressionStatement' &&
|
|
node.parent.parent.parent.parent?.type ===
|
|
'SvelteReactiveStatement');
|
|
}
|
|
/** Checks whether the given node is reactive variable definition with destructuring. */
|
|
function isReactiveVariableDefinitionWithDestructuring(node) {
|
|
return (node.type === 'Identifier' &&
|
|
node.parent?.type === 'AssignmentExpression' &&
|
|
node.parent.right === node &&
|
|
node.parent.left.type === 'ObjectPattern' &&
|
|
node.parent.parent?.type === 'ExpressionStatement' &&
|
|
node.parent.parent.parent?.type ===
|
|
'SvelteReactiveStatement');
|
|
}
|
|
}
|
|
/** Checks whether the given name is already defined as a variable. */
|
|
function hasTopLevelVariable(name) {
|
|
const scopeManager = context.sourceCode.scopeManager;
|
|
if (scopeManager.globalScope?.set.has(name)) {
|
|
return true;
|
|
}
|
|
const moduleScope = scopeManager.globalScope?.childScopes.find((s) => s.type === 'module');
|
|
return moduleScope?.set.has(name) || false;
|
|
}
|
|
return {
|
|
SvelteScriptElement(node) {
|
|
inScriptElement = true;
|
|
const scriptContext = findAttribute(node, 'context');
|
|
const contextValue = scriptContext?.value.length === 1 && scriptContext.value[0];
|
|
if (contextValue &&
|
|
contextValue.type === 'SvelteLiteral' &&
|
|
contextValue.value === 'module') {
|
|
// It is <script context="module">
|
|
return;
|
|
}
|
|
mainScript = node;
|
|
},
|
|
'SvelteScriptElement:exit'() {
|
|
inScriptElement = false;
|
|
},
|
|
// {$foo.bar}
|
|
// should be
|
|
// $: ({ bar } = $foo);
|
|
// {bar}
|
|
// Same with {$foo["bar"]}
|
|
"MemberExpression[object.type='Identifier'][object.name=/^\\$[^\\$]/]"(node) {
|
|
if (inScriptElement)
|
|
return; // Within a script tag
|
|
// Skip Svelte 5 runes (e.g., $derived.by, $state.raw, $effect.pre)
|
|
if (svelteContext?.runes === true && SVELTE_RUNES.has(node.object.name))
|
|
return;
|
|
storeMemberAccessStack.unshift({ node, identifiers: [] });
|
|
},
|
|
Identifier(node) {
|
|
storeMemberAccessStack[0]?.identifiers.push(node);
|
|
},
|
|
"MemberExpression[object.type='Identifier'][object.name=/^\\$[^\\$]/]:exit"(node) {
|
|
if (storeMemberAccessStack[0]?.node !== node)
|
|
return;
|
|
const { identifiers } = storeMemberAccessStack.shift();
|
|
for (const id of identifiers) {
|
|
if (!isExpressionIdentifier(id))
|
|
continue;
|
|
const variable = findVariable(context, id);
|
|
const isTopLevel = !variable || variable.scope.type === 'module' || variable.scope.type === 'global';
|
|
if (!isTopLevel) {
|
|
// Member expressions may use variables defined with {#each} etc.
|
|
return;
|
|
}
|
|
}
|
|
reports.push(node);
|
|
},
|
|
'Program:exit'() {
|
|
const scriptEndTag = mainScript && mainScript.endTag;
|
|
for (const node of reports) {
|
|
const store = node.object.name;
|
|
const suggest = [];
|
|
if (
|
|
// Avoid suggestions for:
|
|
// dynamic accesses like {$foo[bar]}
|
|
!node.computed) {
|
|
for (const variable of new Set(findReactiveVariable(node.object, node.property.name))) {
|
|
suggest.push({
|
|
messageId: 'fixUseVariable',
|
|
data: {
|
|
variable: variable.name
|
|
},
|
|
fix(fixer) {
|
|
return fixer.replaceText(node, variable.name);
|
|
}
|
|
});
|
|
}
|
|
if (
|
|
// Avoid suggestions for:
|
|
// no <script> tag
|
|
// no <script> ending
|
|
scriptEndTag) {
|
|
suggest.push({
|
|
messageId: 'fixUseDestructuring',
|
|
data: {
|
|
store,
|
|
property: node.property.name
|
|
},
|
|
fix(fixer) {
|
|
const propName = node.property.name;
|
|
let varName = propName;
|
|
if (varName.startsWith('$')) {
|
|
varName = varName.slice(1);
|
|
}
|
|
const baseName = varName;
|
|
let suffix = 0;
|
|
if (keyword.isReservedWordES6(varName, true) ||
|
|
keyword.isRestrictedWord(varName)) {
|
|
varName = `${baseName}${++suffix}`;
|
|
}
|
|
while (hasTopLevelVariable(varName)) {
|
|
varName = `${baseName}${++suffix}`;
|
|
}
|
|
return [
|
|
fixer.insertTextAfterRange([scriptEndTag.range[0], scriptEndTag.range[0]], `$: ({ ${propName}${propName !== varName ? `: ${varName}` : ''} } = ${store});\n`),
|
|
fixer.replaceText(node, varName)
|
|
];
|
|
}
|
|
});
|
|
}
|
|
}
|
|
context.report({
|
|
node,
|
|
messageId: 'useDestructuring',
|
|
data: {
|
|
store,
|
|
property: !node.computed
|
|
? node.property.name
|
|
: context.sourceCode.getText(node.property).replace(/\s+/g, ' ')
|
|
},
|
|
suggest
|
|
});
|
|
}
|
|
}
|
|
};
|
|
}
|
|
});
|