Files
easy-png-tools2/web/eslint-plugins/i18n/no-hardcoded-user-text.js
T

181 lines
5.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Rule: NO HARDCODED USER TEXT IN SVELTE TEMPLATES.
//
// Every user-visible string must come from the dictionaries via t(); a literal in
// the template silently ships English to the RU locale. Warn-only by design
// (report-only pilot), so the baseline stays green while the rule is calibrated.
//
// What is NOT reported (the pilot's exclusion list, from the plan):
// - brand and version strings ("easy-png-tools", "v0.1.0");
// - formats, file names and units ("PNG", "result.png", "512×512", "px", "%");
// - X/Y axis markers and other one-letter technical markers;
// - registry source strings (ALL CAPS identifiers such as "BACKGROUND");
// - /kit, which is a component gallery with deliberately hardcoded labels.
//
// Detection surface: SvelteText nodes (including the text parts of mixed content)
// and string-literal values of user-facing attributes. Values written as
// expressions ({...}) are not inspected — the value is computed elsewhere.
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 ?? []) {
// <style> and <script> content is code, not copy. Their raw text
// arrives as a text child, so the elements are skipped wholesale.
if (node.type === "SvelteStyleElement") continue;
if (node.type === "SvelteScriptElement") continue;
if (node.type === "SvelteText") {
if (hasUserWords(node.value, allowWords)) {
reportLiteral(context, node, normalize(node.value), "text");
}
continue;
}
if (node.type === "SvelteElement") {
for (const attr of node.startTag?.attributes ?? []) {
const name = attr.key?.name;
if (typeof name !== "string") continue;
if (!USER_TEXT_ATTRS.has(name)) continue;
const value = literalValue(attr);
if (value === null) continue;
if (hasUserWords(value, allowWords)) {
reportLiteral(
context,
attr,
normalize(value),
`${name} attribute`,
);
}
}
}
visitTemplate(node.children);
}
}
return {
"Program:exit"() {
visitTemplate(program?.body);
},
};
},
};