mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-11-13 17:08:20 +00:00
chore: add new lint rule - check hardcoded text
This commit is contained in:
@@ -8,6 +8,7 @@
|
||||
* new locales are picked up automatically.
|
||||
*/
|
||||
import dictConsistency from "./dict-consistency.js";
|
||||
import noHardcodedUserText from "./no-hardcoded-user-text.js";
|
||||
|
||||
export default {
|
||||
meta: {
|
||||
@@ -16,5 +17,6 @@ export default {
|
||||
},
|
||||
rules: {
|
||||
"dict-consistency": dictConsistency,
|
||||
"no-hardcoded-user-text": noHardcodedUserText,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
// Lists shared by the i18n rules.
|
||||
|
||||
// Attributes whose string value is shown to a user, so a literal there is
|
||||
// hardcoded copy. Technical attributes (variant, tone, type, icon, size, role,
|
||||
// href, id, class, name) are deliberately absent.
|
||||
export const USER_TEXT_ATTRS = new Set([
|
||||
"alt",
|
||||
"aria-label",
|
||||
"description",
|
||||
"emptyText",
|
||||
"error",
|
||||
"eyebrow",
|
||||
"hint",
|
||||
"label",
|
||||
"placeholder",
|
||||
"suffix",
|
||||
"title",
|
||||
"unit",
|
||||
]);
|
||||
@@ -0,0 +1,180 @@
|
||||
// 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);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user