diff --git a/eslint-rules/no-vp-error-outside-text.js b/eslint-rules/no-vp-error-outside-text.js new file mode 100644 index 00000000..3498f5ea --- /dev/null +++ b/eslint-rules/no-vp-error-outside-text.js @@ -0,0 +1,55 @@ +function containsVpErrorToken(node) { + if (!node || typeof node !== "object") { + return false; + } + + if (node.type === "Literal" && typeof node.value === "string") { + return node.value.split(/\s+/u).includes("vp-error"); + } + + if (node.type === "TemplateElement") { + return node.value.raw.split(/\s+/u).includes("vp-error"); + } + + return Object.entries(node).some(([key, value]) => { + if (key === "parent") { + return false; + } + if (Array.isArray(value)) { + return value.some(containsVpErrorToken); + } + return containsVpErrorToken(value); + }); +} + +const rule = { + meta: { + type: "problem", + docs: { + description: "Require the Text component for legacy vp-error styling", + }, + schema: [], + messages: { + useText: + 'Use the Text component for the vp-error class; pair the error message with variant="error" so feedback stays accessible and consistent.', + }, + }, + create(context) { + return { + JSXAttribute(node) { + if (node.name?.name !== "className" || !containsVpErrorToken(node.value)) { + return; + } + + const elementName = node.parent?.name; + if (elementName?.type === "JSXIdentifier" && elementName.name === "Text") { + return; + } + + context.report({ node, messageId: "useText" }); + }, + }; + }, +}; + +export default rule; diff --git a/eslint.config.js b/eslint.config.js index 55ef4f71..10c0aa46 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -6,10 +6,12 @@ import globals from "globals"; import tseslint from "typescript-eslint"; import noNonBooleanJsxAnd from "./eslint-rules/no-non-boolean-jsx-and.js"; +import noVpErrorOutsideText from "./eslint-rules/no-vp-error-outside-text.js"; const localRules = { rules: { "no-non-boolean-jsx-and": noNonBooleanJsxAnd, + "no-vp-error-outside-text": noVpErrorOutsideText, }, }; @@ -72,6 +74,7 @@ export default tseslint.config( "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], "@typescript-eslint/no-explicit-any": "warn", "seahaven/no-non-boolean-jsx-and": "error", + "seahaven/no-vp-error-outside-text": "error", "no-restricted-syntax": [ "error", { @@ -86,12 +89,6 @@ export default tseslint.config( message: "Use the shared Text component so typography semantics, family, tone, and feedback behavior stay consistent.", }, - { - selector: - "JSXOpeningElement:not([name.name='Text']) > JSXAttribute[name.name='className'][value.type='Literal'][value.value=/vp-error/]", - message: - 'Use the Text component for the vp-error class; pair the error message with variant="error" so feedback stays accessible and consistent.', - }, ], }, },