// Rule: NO HARDCODED USER TEXT IN SVELTE TEMPLATES. Every user-visible // string must come from the dictionaries via t(); a literal silently ships // English to the RU locale. Warn-only report-only pilot. Exclusion list // (brand, units, registry strings, /kit): web/eslint-plugins/README.md. import { USER_TEXT_ATTRS } from "./lists.js"; // Formats, units and axis markers that look like words but are not copy. const TECH_WORDS = new Set([ "png", "webp", "jpg", "jpeg", "bmp", "gif", "svg", "zip", "hct", "oklch", "rgb", "rgba", "px", "em", "rem", "ms", "s", "x", "y", "kb", "mb", "gb", "dpi", "bit", "bits", "i18n", "l10n", "url", "uuid", "v1", "v2", ]); // Tokens made only of digits, separators and technical characters. const TECHNICAL_ONLY_RE = /^[\d\s.,:;·×x/%()[\]{}+\-–—*=<>!?&#@'"`|~^$]*$/i; // A file name, an extension, a version, a dimension pair, a size with a unit. const TECH_TOKEN_RE = /(\.(png|webp|jpg|jpeg|bmp|gif|svg|zip|json|ts|svelte|css|md)\b)|(\bv?\d+(\.\d+)+\b)|(\d+\s*[×x]\s*\d+)|(\d+(\.\d+)?\s*(px|em|rem|%|px|s\b))/i; /** Collapse whitespace so mixed content like "Result px" can be judged. */ function normalize(value) { return value.replace(/\s+/g, " ").trim(); } /** * True when the fragment carries a word that a user is meant to read. */ export function hasUserWords(raw, allowWords = []) { const text = normalize(raw); if (text === "") return false; if (TECHNICAL_ONLY_RE.test(text)) return false; // Registry source strings and other shouted identifiers are not copy. if (/^[A-Z0-9_ -]+$/.test(text) && /[A-Z]/.test(text)) return false; const allowed = new Set(allowWords.map((word) => word.toLowerCase())); const words = text.split(/[\s·×]+/).filter(Boolean); const candidates = []; for (const word of words) { // "512", "64", "0.3" and friends carry no letters at all. if (!/[A-Za-z]/.test(word)) continue; if (TECH_TOKEN_RE.test(word)) continue; if (TECH_WORDS.has(word.toLowerCase())) continue; // Brand and product names are configured per repository, not hardcoded here. if (allowed.has(word.toLowerCase())) continue; candidates.push(word); } return candidates.length > 0; } function reportLiteral(context, node, value, kind) { context.report({ node, messageId: "hardcodedText", data: { value, kind }, }); } // svelte-eslint-parser exposes an attribute value as a LIST of nodes, so a // quoted literal is `value[0]`. Anything else ({expr}, spread, no value) means // the text is computed elsewhere and is out of scope. function literalValue(attr) { const parts = Array.isArray(attr.value) ? attr.value : [attr.value]; const first = parts.find((part) => part && typeof part === "object"); if (!first || first.type !== "SvelteLiteral") return null; return typeof first.value === "string" ? first.value : null; } export default { meta: { type: "suggestion", docs: { description: "Warn on hardcoded user-visible text in Svelte templates; use t() from the dictionaries.", category: "i18n", recommended: false, }, messages: { hardcodedText: "Hardcoded user text ({{kind}}): '{{value}}'. Use t() with a key in both locale dictionaries.", }, schema: [ { type: "object", properties: { allowWords: { type: "array", items: { type: "string" }, }, }, additionalProperties: false, }, ], }, create(context) { const { allowWords = [] } = context.options[0] ?? {}; const program = context.sourceCode?.ast ?? context.parserServices?.ast; function visitTemplate(nodes) { for (const node of nodes ?? []) { //