- 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
311 lines
12 KiB
JavaScript
311 lines
12 KiB
JavaScript
import { createRule } from '../utils/index.js';
|
|
import { getStringIfConstant, isHTMLElementLike, needParentheses } from '../utils/ast-utils.js';
|
|
export default createRule('prefer-class-directive', {
|
|
meta: {
|
|
docs: {
|
|
description: 'require class directives instead of ternary expressions',
|
|
category: 'Stylistic Issues',
|
|
recommended: false,
|
|
conflictWithPrettier: false
|
|
},
|
|
fixable: 'code',
|
|
schema: [
|
|
{
|
|
type: 'object',
|
|
properties: {
|
|
prefer: { enum: ['always', 'empty'] }
|
|
},
|
|
additionalProperties: false
|
|
}
|
|
],
|
|
messages: {
|
|
unexpected: 'Unexpected class using the ternary operator.'
|
|
},
|
|
type: 'suggestion'
|
|
},
|
|
create(context) {
|
|
const sourceCode = context.sourceCode;
|
|
const preferEmpty = context.options[0]?.prefer !== 'always';
|
|
/**
|
|
* Returns a map of expressions and strings from ConditionalExpression.
|
|
* Returns null if it has an unknown string.
|
|
*/
|
|
function parseConditionalExpression(node) {
|
|
const result = new Map();
|
|
if (!processItems({
|
|
node: node.test
|
|
}, node.consequent)) {
|
|
return null;
|
|
}
|
|
if (!processItems({
|
|
not: true,
|
|
node: node.test
|
|
}, node.alternate)) {
|
|
return null;
|
|
}
|
|
return result;
|
|
/** Process items */
|
|
function processItems(key, e) {
|
|
if (e.type === 'ConditionalExpression') {
|
|
const sub = parseConditionalExpression(e);
|
|
if (sub == null) {
|
|
return false;
|
|
}
|
|
for (const [expr, str] of sub) {
|
|
result.set({
|
|
...key,
|
|
chains: expr
|
|
}, str);
|
|
}
|
|
}
|
|
else {
|
|
const str = getStringIfConstant(e);
|
|
if (str == null) {
|
|
return false;
|
|
}
|
|
result.set(key, str);
|
|
}
|
|
return true;
|
|
}
|
|
}
|
|
/**
|
|
* Expr to string
|
|
*/
|
|
function exprToString({ node, not }) {
|
|
let text = sourceCode.text.slice(...node.range);
|
|
// *Currently not supported.
|
|
// if (chains) {
|
|
// if (needParentheses(node, "logical")) {
|
|
// text = `(${text})`
|
|
// }
|
|
// let chainsText = exprToString(chains)
|
|
// const needParenForChains =
|
|
// !/^[!(]/u.test(chainsText) && needParentheses(chains.node, "logical")
|
|
// if (needParenForChains) {
|
|
// chainsText = `(${chainsText})`
|
|
// }
|
|
// text = `${text} && ${chainsText}`
|
|
// if (not) {
|
|
// text = `!(${text})`
|
|
// }
|
|
// return text
|
|
// }
|
|
if (not) {
|
|
if (node.type === 'BinaryExpression') {
|
|
if (node.operator === '===' ||
|
|
node.operator === '==' ||
|
|
node.operator === '!==' ||
|
|
node.operator === '!=') {
|
|
const left = sourceCode.text.slice(...node.left.range);
|
|
const op = sourceCode.text.slice(node.left.range[1], node.right.range[0]);
|
|
const right = sourceCode.text.slice(...node.right.range);
|
|
return `${left}${node.operator === '===' || node.operator === '=='
|
|
? op.replace(/[=](={1,2})/g, '!$1')
|
|
: op.replace(/!(={1,2})/g, '=$1')}${right}`;
|
|
}
|
|
}
|
|
else if (node.type === 'UnaryExpression') {
|
|
if (node.operator === '!' && node.prefix) {
|
|
return sourceCode.text.slice(...node.argument.range);
|
|
}
|
|
}
|
|
if (needParentheses(node, 'not')) {
|
|
text = `(${text})`;
|
|
}
|
|
text = `!${text}`;
|
|
}
|
|
return text;
|
|
}
|
|
/**
|
|
* Returns all possible strings.
|
|
*/
|
|
function getStrings(node) {
|
|
if (node.type === 'SvelteLiteral') {
|
|
return [node.value];
|
|
}
|
|
if (node.expression.type === 'ConditionalExpression') {
|
|
const values = parseConditionalExpression(node.expression);
|
|
if (values == null) {
|
|
// unknown
|
|
return null;
|
|
}
|
|
return [...values.values()];
|
|
}
|
|
const str = getStringIfConstant(node.expression);
|
|
if (str == null) {
|
|
// unknown
|
|
return null;
|
|
}
|
|
return [str];
|
|
}
|
|
/**
|
|
* Checks if the last character is a non word.
|
|
*/
|
|
function endsWithNonWord(node, index) {
|
|
for (let i = index; i >= 0; i--) {
|
|
const valueNode = node.value[i];
|
|
const strings = getStrings(valueNode);
|
|
if (strings == null) {
|
|
// unknown
|
|
return false;
|
|
}
|
|
for (const str of strings) {
|
|
if (str) {
|
|
return !str[str.length - 1].trim();
|
|
}
|
|
}
|
|
// If the string is empty, check the previous string.
|
|
}
|
|
return true;
|
|
}
|
|
/**
|
|
* Checks if the first character is a non word.
|
|
*/
|
|
function startsWithNonWord(node, index) {
|
|
for (let i = index; i < node.value.length; i++) {
|
|
const valueNode = node.value[i];
|
|
const strings = getStrings(valueNode);
|
|
if (strings == null) {
|
|
// unknown
|
|
return false;
|
|
}
|
|
for (const str of strings) {
|
|
if (str) {
|
|
return !str[0].trim();
|
|
}
|
|
}
|
|
// If the string is empty, check the previous string.
|
|
}
|
|
return true;
|
|
}
|
|
/** Report */
|
|
function report(node, map, attr) {
|
|
context.report({
|
|
node,
|
|
messageId: 'unexpected',
|
|
*fix(fixer) {
|
|
const classDirectives = [];
|
|
let space = ' ';
|
|
for (const [expr, className] of map) {
|
|
const trimmedClassName = className.trim();
|
|
if (trimmedClassName) {
|
|
classDirectives.push(`class:${trimmedClassName}={${exprToString(expr)}}`);
|
|
}
|
|
else {
|
|
space = className;
|
|
}
|
|
}
|
|
const fixesBuffer = [];
|
|
const index = attr.value.indexOf(node);
|
|
const beforeAttrValues = attr.value.slice(0, index);
|
|
const afterAttrValues = attr.value.slice(index + 1);
|
|
let valueNode;
|
|
while ((valueNode = beforeAttrValues[beforeAttrValues.length - 1])) {
|
|
if (valueNode.type === 'SvelteLiteral') {
|
|
if (!valueNode.value.trim()) {
|
|
// Before spaces
|
|
beforeAttrValues.pop();
|
|
fixesBuffer.push(fixer.remove(valueNode));
|
|
continue;
|
|
}
|
|
if (valueNode.value.trimEnd() !== valueNode.value) {
|
|
// Before spaces
|
|
fixesBuffer.push(fixer.replaceText(valueNode, valueNode.value.trimEnd()));
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
while ((valueNode = afterAttrValues[0])) {
|
|
if (valueNode.type === 'SvelteLiteral') {
|
|
if (!valueNode.value.trim()) {
|
|
// After spaces
|
|
afterAttrValues.shift();
|
|
fixesBuffer.push(fixer.remove(valueNode));
|
|
continue;
|
|
}
|
|
if (valueNode.value.trimStart() !== valueNode.value) {
|
|
// After spaces
|
|
fixesBuffer.push(fixer.replaceText(valueNode, valueNode.value.trimStart()));
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
if (!beforeAttrValues.length && !afterAttrValues.length) {
|
|
yield fixer.replaceText(attr, classDirectives.join(' '));
|
|
}
|
|
else {
|
|
yield* fixesBuffer;
|
|
if (beforeAttrValues.length && afterAttrValues.length) {
|
|
yield fixer.replaceText(node, space || ' ');
|
|
}
|
|
else {
|
|
yield fixer.remove(node);
|
|
}
|
|
yield fixer.insertTextAfterRange([attr.range[1], attr.range[1]], ` ${classDirectives.join(' ')}`);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
/** verify */
|
|
function verify(node, index, attr) {
|
|
if (node.expression.type !== 'ConditionalExpression') {
|
|
return;
|
|
}
|
|
const map = parseConditionalExpression(node.expression);
|
|
if (map == null) {
|
|
// has unknown
|
|
return;
|
|
}
|
|
if (map.size > 2) {
|
|
// It's too complicated.
|
|
return;
|
|
}
|
|
if (preferEmpty && [...map.values()].every((x) => x.trim())) {
|
|
// We prefer directives when there's an empty string, but they're all not empty
|
|
return;
|
|
}
|
|
const prevIsWord = !startsWithNonWord(attr, index + 1);
|
|
const nextIsWord = !endsWithNonWord(attr, index - 1);
|
|
let canTransform = true;
|
|
for (const className of map.values()) {
|
|
if (className) {
|
|
if (!/^[\w-]*$/u.test(className.trim())) {
|
|
// Cannot be transformed to an attribute.
|
|
canTransform = false;
|
|
break;
|
|
}
|
|
if ((className[0].trim() && prevIsWord) ||
|
|
(className[className.length - 1].trim() && nextIsWord)) {
|
|
// The previous or next may be connected to this element.
|
|
canTransform = false;
|
|
break;
|
|
}
|
|
}
|
|
else if (prevIsWord && nextIsWord) {
|
|
// The previous and next may be connected.
|
|
canTransform = false;
|
|
break;
|
|
}
|
|
}
|
|
if (!canTransform) {
|
|
return;
|
|
}
|
|
report(node, map, attr);
|
|
}
|
|
return {
|
|
'SvelteStartTag > SvelteAttribute'(node) {
|
|
if (!isHTMLElementLike(node.parent.parent) || node.key.name !== 'class') {
|
|
return;
|
|
}
|
|
for (let index = 0; index < node.value.length; index++) {
|
|
const valueElement = node.value[index];
|
|
if (valueElement.type !== 'SvelteMustacheTag') {
|
|
continue;
|
|
}
|
|
verify(valueElement, index, node);
|
|
}
|
|
}
|
|
};
|
|
}
|
|
});
|