eslint-plugin-intlayer 9.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +310 -0
- package/dist/cjs/index.cjs +105 -0
- package/dist/cjs/index.cjs.map +1 -0
- package/dist/cjs/rules/enforceAdapterImport.cjs +81 -0
- package/dist/cjs/rules/enforceAdapterImport.cjs.map +1 -0
- package/dist/cjs/rules/noDynamicFieldAccess.cjs +130 -0
- package/dist/cjs/rules/noDynamicFieldAccess.cjs.map +1 -0
- package/dist/cjs/rules/noRawText.cjs +176 -0
- package/dist/cjs/rules/noRawText.cjs.map +1 -0
- package/dist/cjs/rules/staticDictionaryKey.cjs +89 -0
- package/dist/cjs/rules/staticDictionaryKey.cjs.map +1 -0
- package/dist/cjs/utils/ast.cjs +151 -0
- package/dist/cjs/utils/ast.cjs.map +1 -0
- package/dist/cjs/utils/callerMatcher.cjs +130 -0
- package/dist/cjs/utils/callerMatcher.cjs.map +1 -0
- package/dist/cjs/utils/createRule.cjs +26 -0
- package/dist/cjs/utils/createRule.cjs.map +1 -0
- package/dist/cjs/utils/templates.cjs +148 -0
- package/dist/cjs/utils/templates.cjs.map +1 -0
- package/dist/esm/index.mjs +101 -0
- package/dist/esm/index.mjs.map +1 -0
- package/dist/esm/rules/enforceAdapterImport.mjs +80 -0
- package/dist/esm/rules/enforceAdapterImport.mjs.map +1 -0
- package/dist/esm/rules/noDynamicFieldAccess.mjs +129 -0
- package/dist/esm/rules/noDynamicFieldAccess.mjs.map +1 -0
- package/dist/esm/rules/noRawText.mjs +175 -0
- package/dist/esm/rules/noRawText.mjs.map +1 -0
- package/dist/esm/rules/staticDictionaryKey.mjs +88 -0
- package/dist/esm/rules/staticDictionaryKey.mjs.map +1 -0
- package/dist/esm/utils/ast.mjs +143 -0
- package/dist/esm/utils/ast.mjs.map +1 -0
- package/dist/esm/utils/callerMatcher.mjs +126 -0
- package/dist/esm/utils/callerMatcher.mjs.map +1 -0
- package/dist/esm/utils/createRule.mjs +25 -0
- package/dist/esm/utils/createRule.mjs.map +1 -0
- package/dist/esm/utils/templates.mjs +145 -0
- package/dist/esm/utils/templates.mjs.map +1 -0
- package/dist/types/index.d.ts +67 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/rules/enforceAdapterImport.d.ts +6 -0
- package/dist/types/rules/enforceAdapterImport.d.ts.map +1 -0
- package/dist/types/rules/noDynamicFieldAccess.d.ts +7 -0
- package/dist/types/rules/noDynamicFieldAccess.d.ts.map +1 -0
- package/dist/types/rules/noRawText.d.ts +25 -0
- package/dist/types/rules/noRawText.d.ts.map +1 -0
- package/dist/types/rules/staticDictionaryKey.d.ts +7 -0
- package/dist/types/rules/staticDictionaryKey.d.ts.map +1 -0
- package/dist/types/utils/ast.d.ts +105 -0
- package/dist/types/utils/ast.d.ts.map +1 -0
- package/dist/types/utils/callerMatcher.d.ts +58 -0
- package/dist/types/utils/callerMatcher.d.ts.map +1 -0
- package/dist/types/utils/createRule.d.ts +23 -0
- package/dist/types/utils/createRule.d.ts.map +1 -0
- package/dist/types/utils/templates.d.ts +71 -0
- package/dist/types/utils/templates.d.ts.map +1 -0
- package/package.json +105 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"callerMatcher.cjs","names":["ALL_CALLERS","isCallerActive","getStaticStringValue","getObjectPropertyValueNode","getCalleeName","unwrapExpression"],"sources":["../../../src/utils/callerMatcher.ts"],"sourcesContent":["import {\n ALL_CALLERS,\n type CallerDescriptor,\n type CallerValueSource,\n isCallerActive,\n} from '@intlayer/config/callers';\nimport {\n type AstNode,\n getCalleeName,\n getObjectPropertyValueNode,\n getStaticStringValue,\n unwrapExpression,\n} from './ast';\n\n/**\n * Where the dictionary key of a matched call site lives, and whether it is\n * statically knowable.\n */\nexport type NamespaceResolution = {\n /** The node holding the key, or `null` when the call omits it entirely. */\n node: AstNode | null;\n /** The compile-time key, or `null` when it can only be known at runtime. */\n value: string | null;\n /** The registry entry that described where to look. */\n source: CallerValueSource;\n};\n\n/** A call expression recognised as an Intlayer content read. */\nexport type CallerMatch = {\n descriptor: CallerDescriptor;\n /** Resolved namespace, or `null` for callers that take no namespace here. */\n namespace: NamespaceResolution | null;\n};\n\n/**\n * Module specifiers imported by a file, as they are written in source.\n *\n * `isCallerActive` gates generic caller names (lingui's `t` / `_`) on the file\n * actually importing the library, so this must be collected before matching.\n *\n * @param program - The Program node of the analysed file.\n */\nexport const collectImportSources = (program: AstNode): Set<string> => {\n const sources = new Set<string>();\n const body = (program['body'] as AstNode[] | undefined) ?? [];\n\n for (const statement of body) {\n if (\n statement.type !== 'ImportDeclaration' &&\n statement.type !== 'ExportNamedDeclaration' &&\n statement.type !== 'ExportAllDeclaration'\n ) {\n continue;\n }\n\n const source = statement['source'] as AstNode | undefined;\n const value = source?.['value'];\n\n if (typeof value === 'string') sources.add(value);\n }\n\n return sources;\n};\n\n/**\n * The registry entries that may match inside a file, given what it imports.\n *\n * @param importSources - Specifiers collected by {@link collectImportSources}.\n */\nexport const getActiveDescriptors = (\n importSources: ReadonlySet<string>\n): CallerDescriptor[] =>\n ALL_CALLERS.filter((descriptor) => isCallerActive(descriptor, importSources));\n\n/**\n * Resolve one {@link CallerValueSource} against a call's arguments.\n *\n * @param source - The registry rule describing where to look.\n * @param callArguments - Arguments of the call expression.\n * @param descriptor - The caller being matched, needed to disambiguate overloads.\n */\nconst resolveNamespaceSource = (\n source: CallerValueSource,\n callArguments: AstNode[],\n descriptor: CallerDescriptor\n): NamespaceResolution | null => {\n if (source.from === 'fixed') {\n return { node: null, value: source.value, source };\n }\n\n if (source.from === 'argument') {\n const argument = callArguments[source.index] ?? null;\n\n if (!argument) return null;\n\n // A spread makes every positional index unknowable.\n if (argument.type === 'SpreadElement') {\n return { node: argument, value: null, source };\n }\n\n // Overload split: a caller declaring both a positional and an option source\n // for the same argument (`getTranslations('home')` vs\n // `getTranslations({ namespace: 'home' })`) is on its object overload as\n // soon as that argument is an object literal. Defer to the option source so\n // the key is read from — and reported on — the property, not the object.\n if (\n argument.type === 'ObjectExpression' &&\n descriptor.namespaceSources.some(\n (candidate) =>\n candidate.from === 'option' &&\n candidate.argumentIndex === source.index\n )\n ) {\n return null;\n }\n\n return { node: argument, value: getStaticStringValue(argument), source };\n }\n\n if (source.from === 'option') {\n const optionsArgument = callArguments[source.argumentIndex] ?? null;\n const valueNode = getObjectPropertyValueNode(\n optionsArgument,\n source.property\n );\n\n if (!valueNode) return null;\n\n return { node: valueNode, value: getStaticStringValue(valueNode), source };\n }\n\n // 'path-first-segment' — the key is the first dot-segment of the message id,\n // which is the call's own first argument (react-intl's `formatMessage`).\n const [firstArgument] = callArguments;\n const messageId = getStaticStringValue(firstArgument);\n\n return {\n node: firstArgument ?? null,\n value: messageId ? (messageId.split('.')[0] ?? null) : null,\n source,\n };\n};\n\n/**\n * Match a call expression against the shared caller registry.\n *\n * Tries every `namespaceSources` entry in declaration order and keeps the first\n * that yields a node or a value — mirroring how `@intlayer/babel` picks an\n * overload, so the linter and the compiler agree on what a call site means.\n *\n * @param callNode - A CallExpression (or TaggedTemplateExpression) node.\n * @param activeDescriptors - Result of {@link getActiveDescriptors}.\n */\nexport const matchCaller = (\n callNode: AstNode,\n activeDescriptors: CallerDescriptor[]\n): CallerMatch | null => {\n const isTaggedTemplate = callNode.type === 'TaggedTemplateExpression';\n\n if (callNode.type !== 'CallExpression' && !isTaggedTemplate) return null;\n\n const callee = getCalleeName(\n (callNode[isTaggedTemplate ? 'tag' : 'callee'] as AstNode) ?? null\n );\n\n if (!callee) return null;\n\n const callArguments = (\n ((callNode['arguments'] as AstNode[] | undefined) ?? []).map((argument) =>\n argument.type === 'SpreadElement' ? argument : unwrapExpression(argument)\n ) as (AstNode | null)[]\n ).filter((argument): argument is AstNode => argument !== null);\n\n for (const descriptor of activeDescriptors) {\n if (descriptor.callerName !== callee.name) continue;\n\n if (callee.isMethod && descriptor.matchAsMethod !== true) continue;\n\n if (isTaggedTemplate && descriptor.matchAsTaggedTemplate !== true) continue;\n\n // \"The first source that yields a static string wins\" — a source that only\n // yields a node is kept as a fallback so a dynamic key is still reportable.\n let fallback: NamespaceResolution | null = null;\n\n for (const source of descriptor.namespaceSources) {\n const namespace = resolveNamespaceSource(\n source,\n callArguments,\n descriptor\n );\n\n if (!namespace) continue;\n\n if (namespace.value !== null) return { descriptor, namespace };\n\n fallback ??= namespace;\n }\n\n return { descriptor, namespace: fallback };\n }\n\n return null;\n};\n\n/**\n * Match a JSX element against the registry's JSX-shaped callers,\n * e.g. `<FormattedMessage id=\"home.title\" />` or `<Trans ns=\"home\" …/>`.\n *\n * @param elementName - Tag name of the element.\n * @param activeDescriptors - Result of {@link getActiveDescriptors}.\n */\nexport const matchJsxCaller = (\n elementName: string | null,\n activeDescriptors: CallerDescriptor[]\n): CallerDescriptor | null => {\n if (!elementName) return null;\n\n return (\n activeDescriptors.find(\n (descriptor) =>\n descriptor.callerName === elementName &&\n descriptor.jsxIdAttribute !== undefined\n ) ?? null\n );\n};\n"],"mappings":";;;;;;;;;;;;;AA0CA,MAAa,wBAAwB,YAAkC;CACrE,MAAM,0BAAU,IAAI,IAAY;CAChC,MAAM,OAAQ,QAAQ,WAAqC,CAAC;CAE5D,KAAK,MAAM,aAAa,MAAM;EAC5B,IACE,UAAU,SAAS,uBACnB,UAAU,SAAS,4BACnB,UAAU,SAAS,wBAEnB;EAIF,MAAM,QADS,UAAU,SACL,GAAG;EAEvB,IAAI,OAAO,UAAU,UAAU,QAAQ,IAAI,KAAK;CAClD;CAEA,OAAO;AACT;;;;;;AAOA,MAAa,wBACX,kBAEAA,qCAAY,QAAQ,mBAAeC,yCAAe,YAAY,aAAa,CAAC;;;;;;;;AAS9E,MAAM,0BACJ,QACA,eACA,eAC+B;CAC/B,IAAI,OAAO,SAAS,SAClB,OAAO;EAAE,MAAM;EAAM,OAAO,OAAO;EAAO;CAAO;CAGnD,IAAI,OAAO,SAAS,YAAY;EAC9B,MAAM,WAAW,cAAc,OAAO,UAAU;EAEhD,IAAI,CAAC,UAAU,OAAO;EAGtB,IAAI,SAAS,SAAS,iBACpB,OAAO;GAAE,MAAM;GAAU,OAAO;GAAM;EAAO;EAQ/C,IACE,SAAS,SAAS,sBAClB,WAAW,iBAAiB,MACzB,cACC,UAAU,SAAS,YACnB,UAAU,kBAAkB,OAAO,KACvC,GAEA,OAAO;EAGT,OAAO;GAAE,MAAM;GAAU,OAAOC,uCAAqB,QAAQ;GAAG;EAAO;CACzE;CAEA,IAAI,OAAO,SAAS,UAAU;EAC5B,MAAM,kBAAkB,cAAc,OAAO,kBAAkB;EAC/D,MAAM,YAAYC,6CAChB,iBACA,OAAO,QACT;EAEA,IAAI,CAAC,WAAW,OAAO;EAEvB,OAAO;GAAE,MAAM;GAAW,OAAOD,uCAAqB,SAAS;GAAG;EAAO;CAC3E;CAIA,MAAM,CAAC,iBAAiB;CACxB,MAAM,YAAYA,uCAAqB,aAAa;CAEpD,OAAO;EACL,MAAM,iBAAiB;EACvB,OAAO,YAAa,UAAU,MAAM,GAAG,CAAC,CAAC,MAAM,OAAQ;EACvD;CACF;AACF;;;;;;;;;;;AAYA,MAAa,eACX,UACA,sBACuB;CACvB,MAAM,mBAAmB,SAAS,SAAS;CAE3C,IAAI,SAAS,SAAS,oBAAoB,CAAC,kBAAkB,OAAO;CAEpE,MAAM,SAASE,gCACZ,SAAS,mBAAmB,QAAQ,aAAyB,IAChE;CAEA,IAAI,CAAC,QAAQ,OAAO;CAEpB,MAAM,iBACF,SAAS,gBAA0C,CAAC,EAAC,CAAE,KAAK,aAC5D,SAAS,SAAS,kBAAkB,WAAWC,mCAAiB,QAAQ,CAC1E,CAAC,CACD,QAAQ,aAAkC,aAAa,IAAI;CAE7D,KAAK,MAAM,cAAc,mBAAmB;EAC1C,IAAI,WAAW,eAAe,OAAO,MAAM;EAE3C,IAAI,OAAO,YAAY,WAAW,kBAAkB,MAAM;EAE1D,IAAI,oBAAoB,WAAW,0BAA0B,MAAM;EAInE,IAAI,WAAuC;EAE3C,KAAK,MAAM,UAAU,WAAW,kBAAkB;GAChD,MAAM,YAAY,uBAChB,QACA,eACA,UACF;GAEA,IAAI,CAAC,WAAW;GAEhB,IAAI,UAAU,UAAU,MAAM,OAAO;IAAE;IAAY;GAAU;GAE7D,aAAa;EACf;EAEA,OAAO;GAAE;GAAY,WAAW;EAAS;CAC3C;CAEA,OAAO;AACT;;;;;;;;AASA,MAAa,kBACX,aACA,sBAC4B;CAC5B,IAAI,CAAC,aAAa,OAAO;CAEzB,OACE,kBAAkB,MACf,eACC,WAAW,eAAe,eAC1B,WAAW,mBAAmB,MAClC,KAAK;AAET"}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
|
|
2
|
+
//#region src/utils/createRule.ts
|
|
3
|
+
const DOCUMENTATION_BASE_URL = "https://github.com/aymericzip/intlayer/blob/main/packages/eslint-plugin-intlayer/README.md";
|
|
4
|
+
/**
|
|
5
|
+
* Build a rule with the plugin's shared metadata conventions applied: a
|
|
6
|
+
* documentation anchor derived from the rule name, and `meta.schema` defaulting
|
|
7
|
+
* to "no options" so an unknown option is rejected instead of ignored.
|
|
8
|
+
*
|
|
9
|
+
* @param name - The rule name as it appears in config, e.g. `'no-raw-text'`.
|
|
10
|
+
* @param rule - The rule definition.
|
|
11
|
+
*/
|
|
12
|
+
const createRule = (name, rule) => ({
|
|
13
|
+
...rule,
|
|
14
|
+
meta: {
|
|
15
|
+
...rule.meta,
|
|
16
|
+
docs: {
|
|
17
|
+
...rule.meta?.docs,
|
|
18
|
+
url: `${DOCUMENTATION_BASE_URL}#${name}`
|
|
19
|
+
},
|
|
20
|
+
schema: rule.meta?.schema ?? []
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
//#endregion
|
|
25
|
+
exports.createRule = createRule;
|
|
26
|
+
//# sourceMappingURL=createRule.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"createRule.cjs","names":[],"sources":["../../../src/utils/createRule.ts"],"sourcesContent":["import type { JSRuleDefinition } from 'eslint';\n\nconst DOCUMENTATION_BASE_URL =\n 'https://github.com/aymericzip/intlayer/blob/main/packages/eslint-plugin-intlayer/README.md';\n\n/**\n * A rule of this plugin, with its option tuple and message ids pinned so\n * `context.options` and `context.report({ messageId })` are checked rather than\n * inferred as `any[]` / `string`.\n */\nexport type IntlayerRule<\n Options extends readonly unknown[] = [],\n MessageIds extends string = string,\n> = JSRuleDefinition<{\n RuleOptions: [...Options];\n MessageIds: MessageIds;\n}>;\n\n/**\n * Build a rule with the plugin's shared metadata conventions applied: a\n * documentation anchor derived from the rule name, and `meta.schema` defaulting\n * to \"no options\" so an unknown option is rejected instead of ignored.\n *\n * @param name - The rule name as it appears in config, e.g. `'no-raw-text'`.\n * @param rule - The rule definition.\n */\nexport const createRule = <\n Options extends readonly unknown[] = [],\n MessageIds extends string = string,\n>(\n name: string,\n rule: IntlayerRule<Options, MessageIds>\n): IntlayerRule<Options, MessageIds> => ({\n ...rule,\n meta: {\n ...rule.meta,\n docs: {\n ...rule.meta?.docs,\n url: `${DOCUMENTATION_BASE_URL}#${name}`,\n },\n schema: rule.meta?.schema ?? [],\n },\n});\n"],"mappings":";;AAEA,MAAM,yBACJ;;;;;;;;;AAuBF,MAAa,cAIX,MACA,UACuC;CACvC,GAAG;CACH,MAAM;EACJ,GAAG,KAAK;EACR,MAAM;GACJ,GAAG,KAAK,MAAM;GACd,KAAK,GAAG,uBAAuB,GAAG;EACpC;EACA,QAAQ,KAAK,MAAM,UAAU,CAAC;CAChC;AACF"}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
|
|
2
|
+
//#region src/utils/templates.ts
|
|
3
|
+
/**
|
|
4
|
+
* Element node types whose text content is never user-facing copy, regardless
|
|
5
|
+
* of the `ignoreElements` option (which matches on tag name, and these carry
|
|
6
|
+
* none in some parsers).
|
|
7
|
+
*/
|
|
8
|
+
const NON_CONTENT_ELEMENT_TYPES = /* @__PURE__ */ new Set(["SvelteScriptElement", "SvelteStyleElement"]);
|
|
9
|
+
/** Element node types, per parser, that carry a tag name. */
|
|
10
|
+
const ELEMENT_TYPES = /* @__PURE__ */ new Set([
|
|
11
|
+
"VElement",
|
|
12
|
+
"SvelteElement",
|
|
13
|
+
"Element",
|
|
14
|
+
"JSXElement",
|
|
15
|
+
"JSXOpeningElement"
|
|
16
|
+
]);
|
|
17
|
+
/**
|
|
18
|
+
* Read the tag name off an element node, normalising the three shapes parsers
|
|
19
|
+
* use: a plain string (Vue, Angular), a `SvelteName` node (Svelte), and a JSX
|
|
20
|
+
* name node.
|
|
21
|
+
*
|
|
22
|
+
* @param element - An element node.
|
|
23
|
+
*/
|
|
24
|
+
const readElementName = (element) => {
|
|
25
|
+
const name = element["name"];
|
|
26
|
+
if (typeof name === "string") return name;
|
|
27
|
+
if (name && typeof name === "object") {
|
|
28
|
+
const nameNode = name;
|
|
29
|
+
if (typeof nameNode["name"] === "string") return nameNode["name"];
|
|
30
|
+
}
|
|
31
|
+
const openingElement = element["openingElement"];
|
|
32
|
+
if (openingElement) return readElementName(openingElement);
|
|
33
|
+
return null;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Walk up to the nearest enclosing element and return its tag name, or the
|
|
37
|
+
* sentinel `false` when the text sits inside a `<script>`/`<style>` block that
|
|
38
|
+
* should never be reported.
|
|
39
|
+
*
|
|
40
|
+
* @param node - The text or attribute node to start from.
|
|
41
|
+
*/
|
|
42
|
+
const resolveEnclosingElement = (node) => {
|
|
43
|
+
let current = node?.parent;
|
|
44
|
+
while (current) {
|
|
45
|
+
if (NON_CONTENT_ELEMENT_TYPES.has(current.type)) return false;
|
|
46
|
+
if (ELEMENT_TYPES.has(current.type)) return readElementName(current);
|
|
47
|
+
current = current.parent;
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Report a finding unless it sits inside a non-content element.
|
|
53
|
+
*
|
|
54
|
+
* @param report - The rule's reporter.
|
|
55
|
+
* @param node - Node to report on.
|
|
56
|
+
* @param text - The literal text.
|
|
57
|
+
* @param attributeName - Attribute name, or `null` for element text.
|
|
58
|
+
*/
|
|
59
|
+
const reportWithElement = (report, node, text, attributeName) => {
|
|
60
|
+
const elementName = resolveEnclosingElement(node);
|
|
61
|
+
if (elementName === false) return;
|
|
62
|
+
report({
|
|
63
|
+
node,
|
|
64
|
+
text,
|
|
65
|
+
attributeName,
|
|
66
|
+
elementName
|
|
67
|
+
});
|
|
68
|
+
};
|
|
69
|
+
/**
|
|
70
|
+
* Visitors covering every non-JSX template language Intlayer supports.
|
|
71
|
+
*
|
|
72
|
+
* JSX (React, Preact, Solid, Astro, Lit templates compiled through JSX) is
|
|
73
|
+
* handled by the rule itself, since `JSXText`/`JSXAttribute` also need the
|
|
74
|
+
* non-template code paths.
|
|
75
|
+
*
|
|
76
|
+
* @param report - Called for each literal text node found.
|
|
77
|
+
*/
|
|
78
|
+
const createTemplateVisitor = (report) => ({
|
|
79
|
+
VText: (node) => {
|
|
80
|
+
const astNode = node;
|
|
81
|
+
reportWithElement(report, astNode, astNode["value"], null);
|
|
82
|
+
},
|
|
83
|
+
VAttribute: (node) => {
|
|
84
|
+
const astNode = node;
|
|
85
|
+
if (astNode["directive"] === true) return;
|
|
86
|
+
const key = astNode["key"];
|
|
87
|
+
const value = astNode["value"];
|
|
88
|
+
const attributeName = key?.["name"];
|
|
89
|
+
const text = value?.["value"];
|
|
90
|
+
if (typeof attributeName !== "string" || typeof text !== "string") return;
|
|
91
|
+
reportWithElement(report, astNode, text, attributeName);
|
|
92
|
+
},
|
|
93
|
+
SvelteText: (node) => {
|
|
94
|
+
const astNode = node;
|
|
95
|
+
reportWithElement(report, astNode, astNode["value"], null);
|
|
96
|
+
},
|
|
97
|
+
SvelteAttribute: (node) => {
|
|
98
|
+
const astNode = node;
|
|
99
|
+
const attributeName = astNode["key"]?.["name"];
|
|
100
|
+
if (typeof attributeName !== "string") return;
|
|
101
|
+
const parts = astNode["value"];
|
|
102
|
+
if (parts?.length !== 1) return;
|
|
103
|
+
const part = parts[0];
|
|
104
|
+
if (part?.type !== "SvelteLiteral") return;
|
|
105
|
+
const text = part["value"];
|
|
106
|
+
if (typeof text !== "string") return;
|
|
107
|
+
reportWithElement(report, astNode, text, attributeName);
|
|
108
|
+
},
|
|
109
|
+
Text: (node) => {
|
|
110
|
+
const astNode = node;
|
|
111
|
+
const text = astNode["value"];
|
|
112
|
+
if (typeof text !== "string") return;
|
|
113
|
+
reportWithElement(report, astNode, text, null);
|
|
114
|
+
},
|
|
115
|
+
TextAttribute: (node) => {
|
|
116
|
+
const astNode = node;
|
|
117
|
+
const attributeName = astNode["name"];
|
|
118
|
+
const text = astNode["value"];
|
|
119
|
+
if (typeof attributeName !== "string" || typeof text !== "string") return;
|
|
120
|
+
reportWithElement(report, astNode, text, attributeName);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
/**
|
|
124
|
+
* Combine a script visitor and a template visitor into the single object the
|
|
125
|
+
* active parser expects.
|
|
126
|
+
*
|
|
127
|
+
* Vue keeps its template in a side AST reachable only through
|
|
128
|
+
* `defineTemplateBodyVisitor`; every other parser puts markup in the main AST,
|
|
129
|
+
* where a merged visitor object is enough.
|
|
130
|
+
*
|
|
131
|
+
* @param parserServices - `context.sourceCode.parserServices`.
|
|
132
|
+
* @param scriptVisitor - Handlers for ordinary script nodes.
|
|
133
|
+
* @param templateVisitor - Handlers for markup nodes.
|
|
134
|
+
*/
|
|
135
|
+
const combineVisitors = (parserServices, scriptVisitor, templateVisitor) => {
|
|
136
|
+
const services = parserServices;
|
|
137
|
+
if (typeof services?.defineTemplateBodyVisitor === "function") return services.defineTemplateBodyVisitor(templateVisitor, scriptVisitor);
|
|
138
|
+
return {
|
|
139
|
+
...scriptVisitor,
|
|
140
|
+
...templateVisitor
|
|
141
|
+
};
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
//#endregion
|
|
145
|
+
exports.combineVisitors = combineVisitors;
|
|
146
|
+
exports.createTemplateVisitor = createTemplateVisitor;
|
|
147
|
+
exports.resolveEnclosingElement = resolveEnclosingElement;
|
|
148
|
+
//# sourceMappingURL=templates.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"templates.cjs","names":[],"sources":["../../../src/utils/templates.ts"],"sourcesContent":["import type { AstNode, AstNodeWithParent } from './ast';\n\n/**\n * Template-language support for text-coverage rules.\n *\n * Intlayer ships integrations for React, Vue, Svelte, Angular, Astro, Solid,\n * Preact and Lit, and each ecosystem's ESLint parser models markup differently:\n *\n * | Framework | Parser | Text node | Attribute node | Lives in |\n * | --------- | ------------------------------- | ----------- | --------------- | --------------- |\n * | React/JSX | espree, typescript-eslint | `JSXText` | `JSXAttribute` | main AST |\n * | Astro | astro-eslint-parser | `JSXText` | `JSXAttribute` | main AST |\n * | Svelte | svelte-eslint-parser | `SvelteText`| `SvelteAttribute`| main AST |\n * | Angular | @angular-eslint/template-parser | `Text` | `TextAttribute` | main AST |\n * | Vue | vue-eslint-parser | `VText` | `VAttribute` | `templateBody` |\n *\n * Vue is the only one that hides its template behind\n * `parserServices.defineTemplateBodyVisitor`; every other parser exposes markup\n * in the AST a normal visitor already walks. Node type names do not collide\n * across parsers, so one merged visitor is safe to hand to any of them — a\n * Svelte file simply never emits a `VText`.\n */\n\n/** One piece of literal text found in markup. */\nexport type TemplateTextFinding = {\n /** Node to report on. */\n node: AstNode;\n /** The literal text. */\n text: string;\n /** Attribute name when the text is an attribute value, else `null`. */\n attributeName: string | null;\n /** Tag name of the enclosing element, when resolvable. */\n elementName: string | null;\n};\n\n/** Callback invoked for every literal text node the visitors find. */\nexport type TemplateTextReporter = (finding: TemplateTextFinding) => void;\n\n/** A visitor object as returned from a rule's `create`. */\nexport type TemplateVisitor = Record<string, (node: never) => void>;\n\n/**\n * Element node types whose text content is never user-facing copy, regardless\n * of the `ignoreElements` option (which matches on tag name, and these carry\n * none in some parsers).\n */\nconst NON_CONTENT_ELEMENT_TYPES = new Set([\n 'SvelteScriptElement',\n 'SvelteStyleElement',\n]);\n\n/** Element node types, per parser, that carry a tag name. */\nconst ELEMENT_TYPES = new Set([\n 'VElement',\n 'SvelteElement',\n 'Element',\n 'JSXElement',\n 'JSXOpeningElement',\n]);\n\n/**\n * Read the tag name off an element node, normalising the three shapes parsers\n * use: a plain string (Vue, Angular), a `SvelteName` node (Svelte), and a JSX\n * name node.\n *\n * @param element - An element node.\n */\nconst readElementName = (element: AstNode): string | null => {\n const name = element['name'];\n\n if (typeof name === 'string') return name;\n\n if (name && typeof name === 'object') {\n const nameNode = name as AstNode;\n\n if (typeof nameNode['name'] === 'string') return nameNode['name'];\n }\n\n // JSXElement holds its name on the opening element.\n const openingElement = element['openingElement'] as AstNode | undefined;\n\n if (openingElement) return readElementName(openingElement);\n\n return null;\n};\n\n/**\n * Walk up to the nearest enclosing element and return its tag name, or the\n * sentinel `false` when the text sits inside a `<script>`/`<style>` block that\n * should never be reported.\n *\n * @param node - The text or attribute node to start from.\n */\nexport const resolveEnclosingElement = (\n node: AstNodeWithParent | undefined\n): string | null | false => {\n let current = node?.parent;\n\n while (current) {\n if (NON_CONTENT_ELEMENT_TYPES.has(current.type)) return false;\n\n if (ELEMENT_TYPES.has(current.type)) return readElementName(current);\n\n current = current.parent;\n }\n\n return null;\n};\n\n/**\n * Report a finding unless it sits inside a non-content element.\n *\n * @param report - The rule's reporter.\n * @param node - Node to report on.\n * @param text - The literal text.\n * @param attributeName - Attribute name, or `null` for element text.\n */\nconst reportWithElement = (\n report: TemplateTextReporter,\n node: AstNodeWithParent,\n text: string,\n attributeName: string | null\n): void => {\n const elementName = resolveEnclosingElement(node);\n\n if (elementName === false) return;\n\n report({ node, text, attributeName, elementName });\n};\n\n/**\n * Visitors covering every non-JSX template language Intlayer supports.\n *\n * JSX (React, Preact, Solid, Astro, Lit templates compiled through JSX) is\n * handled by the rule itself, since `JSXText`/`JSXAttribute` also need the\n * non-template code paths.\n *\n * @param report - Called for each literal text node found.\n */\nexport const createTemplateVisitor = (\n report: TemplateTextReporter\n): TemplateVisitor => ({\n // ── Vue ────────────────────────────────────────────────────────────────\n VText: (node: never) => {\n const astNode = node as AstNodeWithParent;\n\n reportWithElement(report, astNode, astNode['value'] as string, null);\n },\n\n VAttribute: (node: never) => {\n const astNode = node as AstNodeWithParent;\n\n // Directives (`:title`, `v-bind:title`) hold an expression, not literal text.\n if (astNode['directive'] === true) return;\n\n const key = astNode['key'] as AstNode | undefined;\n const value = astNode['value'] as AstNode | undefined;\n const attributeName = key?.['name'];\n const text = value?.['value'];\n\n if (typeof attributeName !== 'string' || typeof text !== 'string') return;\n\n reportWithElement(report, astNode, text, attributeName);\n },\n\n // ── Svelte ─────────────────────────────────────────────────────────────\n SvelteText: (node: never) => {\n const astNode = node as AstNodeWithParent;\n\n reportWithElement(report, astNode, astNode['value'] as string, null);\n },\n\n SvelteAttribute: (node: never) => {\n const astNode = node as AstNodeWithParent;\n const key = astNode['key'] as AstNode | undefined;\n const attributeName = key?.['name'];\n\n if (typeof attributeName !== 'string') return;\n\n // The value is a list of parts; a single literal part means a static value,\n // anything else interpolates and is not raw copy.\n const parts = astNode['value'] as AstNode[] | undefined;\n\n if (parts?.length !== 1) return;\n\n const part = parts[0];\n\n if (part?.type !== 'SvelteLiteral') return;\n\n const text = part['value'];\n\n if (typeof text !== 'string') return;\n\n reportWithElement(report, astNode, text, attributeName);\n },\n\n // ── Angular ────────────────────────────────────────────────────────────\n Text: (node: never) => {\n const astNode = node as AstNodeWithParent;\n const text = astNode['value'];\n\n if (typeof text !== 'string') return;\n\n reportWithElement(report, astNode, text, null);\n },\n\n TextAttribute: (node: never) => {\n const astNode = node as AstNodeWithParent;\n const attributeName = astNode['name'];\n const text = astNode['value'];\n\n if (typeof attributeName !== 'string' || typeof text !== 'string') return;\n\n reportWithElement(report, astNode, text, attributeName);\n },\n});\n\n/** Parser services that expose a Vue template body. */\ntype VueParserServices = {\n defineTemplateBodyVisitor?: (\n templateVisitor: TemplateVisitor,\n scriptVisitor: TemplateVisitor\n ) => TemplateVisitor;\n};\n\n/**\n * Combine a script visitor and a template visitor into the single object the\n * active parser expects.\n *\n * Vue keeps its template in a side AST reachable only through\n * `defineTemplateBodyVisitor`; every other parser puts markup in the main AST,\n * where a merged visitor object is enough.\n *\n * @param parserServices - `context.sourceCode.parserServices`.\n * @param scriptVisitor - Handlers for ordinary script nodes.\n * @param templateVisitor - Handlers for markup nodes.\n */\nexport const combineVisitors = (\n parserServices: unknown,\n scriptVisitor: TemplateVisitor,\n templateVisitor: TemplateVisitor\n): TemplateVisitor => {\n const services = parserServices as VueParserServices | undefined;\n\n if (typeof services?.defineTemplateBodyVisitor === 'function') {\n return services.defineTemplateBodyVisitor(templateVisitor, scriptVisitor);\n }\n\n return { ...scriptVisitor, ...templateVisitor };\n};\n"],"mappings":";;;;;;;AA8CA,MAAM,4CAA4B,IAAI,IAAI,CACxC,uBACA,oBACF,CAAC;;AAGD,MAAM,gCAAgB,IAAI,IAAI;CAC5B;CACA;CACA;CACA;CACA;AACF,CAAC;;;;;;;;AASD,MAAM,mBAAmB,YAAoC;CAC3D,MAAM,OAAO,QAAQ;CAErB,IAAI,OAAO,SAAS,UAAU,OAAO;CAErC,IAAI,QAAQ,OAAO,SAAS,UAAU;EACpC,MAAM,WAAW;EAEjB,IAAI,OAAO,SAAS,YAAY,UAAU,OAAO,SAAS;CAC5D;CAGA,MAAM,iBAAiB,QAAQ;CAE/B,IAAI,gBAAgB,OAAO,gBAAgB,cAAc;CAEzD,OAAO;AACT;;;;;;;;AASA,MAAa,2BACX,SAC0B;CAC1B,IAAI,UAAU,MAAM;CAEpB,OAAO,SAAS;EACd,IAAI,0BAA0B,IAAI,QAAQ,IAAI,GAAG,OAAO;EAExD,IAAI,cAAc,IAAI,QAAQ,IAAI,GAAG,OAAO,gBAAgB,OAAO;EAEnE,UAAU,QAAQ;CACpB;CAEA,OAAO;AACT;;;;;;;;;AAUA,MAAM,qBACJ,QACA,MACA,MACA,kBACS;CACT,MAAM,cAAc,wBAAwB,IAAI;CAEhD,IAAI,gBAAgB,OAAO;CAE3B,OAAO;EAAE;EAAM;EAAM;EAAe;CAAY,CAAC;AACnD;;;;;;;;;;AAWA,MAAa,yBACX,YACqB;CAErB,QAAQ,SAAgB;EACtB,MAAM,UAAU;EAEhB,kBAAkB,QAAQ,SAAS,QAAQ,UAAoB,IAAI;CACrE;CAEA,aAAa,SAAgB;EAC3B,MAAM,UAAU;EAGhB,IAAI,QAAQ,iBAAiB,MAAM;EAEnC,MAAM,MAAM,QAAQ;EACpB,MAAM,QAAQ,QAAQ;EACtB,MAAM,gBAAgB,MAAM;EAC5B,MAAM,OAAO,QAAQ;EAErB,IAAI,OAAO,kBAAkB,YAAY,OAAO,SAAS,UAAU;EAEnE,kBAAkB,QAAQ,SAAS,MAAM,aAAa;CACxD;CAGA,aAAa,SAAgB;EAC3B,MAAM,UAAU;EAEhB,kBAAkB,QAAQ,SAAS,QAAQ,UAAoB,IAAI;CACrE;CAEA,kBAAkB,SAAgB;EAChC,MAAM,UAAU;EAEhB,MAAM,gBADM,QAAQ,MACK,GAAG;EAE5B,IAAI,OAAO,kBAAkB,UAAU;EAIvC,MAAM,QAAQ,QAAQ;EAEtB,IAAI,OAAO,WAAW,GAAG;EAEzB,MAAM,OAAO,MAAM;EAEnB,IAAI,MAAM,SAAS,iBAAiB;EAEpC,MAAM,OAAO,KAAK;EAElB,IAAI,OAAO,SAAS,UAAU;EAE9B,kBAAkB,QAAQ,SAAS,MAAM,aAAa;CACxD;CAGA,OAAO,SAAgB;EACrB,MAAM,UAAU;EAChB,MAAM,OAAO,QAAQ;EAErB,IAAI,OAAO,SAAS,UAAU;EAE9B,kBAAkB,QAAQ,SAAS,MAAM,IAAI;CAC/C;CAEA,gBAAgB,SAAgB;EAC9B,MAAM,UAAU;EAChB,MAAM,gBAAgB,QAAQ;EAC9B,MAAM,OAAO,QAAQ;EAErB,IAAI,OAAO,kBAAkB,YAAY,OAAO,SAAS,UAAU;EAEnE,kBAAkB,QAAQ,SAAS,MAAM,aAAa;CACxD;AACF;;;;;;;;;;;;;AAsBA,MAAa,mBACX,gBACA,eACA,oBACoB;CACpB,MAAM,WAAW;CAEjB,IAAI,OAAO,UAAU,8BAA8B,YACjD,OAAO,SAAS,0BAA0B,iBAAiB,aAAa;CAG1E,OAAO;EAAE,GAAG;EAAe,GAAG;CAAgB;AAChD"}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { enforceAdapterImport } from "./rules/enforceAdapterImport.mjs";
|
|
2
|
+
import { noDynamicFieldAccess } from "./rules/noDynamicFieldAccess.mjs";
|
|
3
|
+
import { noRawText } from "./rules/noRawText.mjs";
|
|
4
|
+
import { staticDictionaryKey } from "./rules/staticDictionaryKey.mjs";
|
|
5
|
+
import { getRecommendedLintRules } from "@intlayer/config/lint";
|
|
6
|
+
|
|
7
|
+
//#region src/index.ts
|
|
8
|
+
const PLUGIN_NAME = "intlayer";
|
|
9
|
+
/**
|
|
10
|
+
* Every rule this plugin ships, keyed by the name used in configuration.
|
|
11
|
+
*
|
|
12
|
+
* The rules split into two families:
|
|
13
|
+
* - **Coverage** (`no-raw-text`) — user-facing copy that never reached a
|
|
14
|
+
* dictionary. Nothing else in the toolchain reports it.
|
|
15
|
+
* - **Compiler contract** (`static-dictionary-key`, `no-dynamic-field-access`)
|
|
16
|
+
* — call shapes that type-check and run, but that the Babel/SWC passes cannot
|
|
17
|
+
* analyse, so they silently lose the optimize pass or read a purged field.
|
|
18
|
+
*
|
|
19
|
+
* Deliberately absent: unknown dictionary keys, unknown field paths and missing
|
|
20
|
+
* required locales. The generated `__DictionaryRegistry` module augmentation
|
|
21
|
+
* already makes all three compile errors, and `@intlayer/lsp` reports the first
|
|
22
|
+
* as an editor diagnostic — a third report would only add noise.
|
|
23
|
+
*/
|
|
24
|
+
const rules = {
|
|
25
|
+
"no-raw-text": noRawText,
|
|
26
|
+
"static-dictionary-key": staticDictionaryKey,
|
|
27
|
+
"no-dynamic-field-access": noDynamicFieldAccess,
|
|
28
|
+
"enforce-adapter-import": enforceAdapterImport
|
|
29
|
+
};
|
|
30
|
+
const meta = {
|
|
31
|
+
name: "eslint-plugin-intlayer",
|
|
32
|
+
version: "9.3.1"
|
|
33
|
+
};
|
|
34
|
+
const plugin = {
|
|
35
|
+
meta,
|
|
36
|
+
rules
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Build a flat config enabling the given rules under the `intlayer/` prefix.
|
|
40
|
+
*
|
|
41
|
+
* @param name - Config name, surfaced by `--print-config` and the config inspector.
|
|
42
|
+
* @param ruleSeverities - Severity per rule name.
|
|
43
|
+
*/
|
|
44
|
+
const createConfig = (name, ruleSeverities) => ({
|
|
45
|
+
name: `intlayer/${name}`,
|
|
46
|
+
plugins: { [PLUGIN_NAME]: plugin },
|
|
47
|
+
rules: Object.fromEntries(Object.entries(ruleSeverities).map(([ruleName, severity]) => [`${PLUGIN_NAME}/${ruleName}`, severity]))
|
|
48
|
+
});
|
|
49
|
+
/**
|
|
50
|
+
* Flat configs, for `eslint.config.js`:
|
|
51
|
+
*
|
|
52
|
+
* ```js
|
|
53
|
+
* import intlayer from 'eslint-plugin-intlayer';
|
|
54
|
+
*
|
|
55
|
+
* export default [intlayer.configs.recommended];
|
|
56
|
+
* ```
|
|
57
|
+
*
|
|
58
|
+
* - `recommended` — the compiler-contract rules as errors, plus `no-raw-text`
|
|
59
|
+
* as a warning so an existing codebase is not blocked on day one.
|
|
60
|
+
* - `strict` — everything as an error, including string literals outside JSX.
|
|
61
|
+
* - `contract-only` — just the rules that protect the build output; use this
|
|
62
|
+
* when translation coverage is tracked by `intlayer test` instead.
|
|
63
|
+
*
|
|
64
|
+
* `enforce-adapter-import` is opt-in: importing from the original package is
|
|
65
|
+
* perfectly valid once the bundler alias is set up, so a project that has done
|
|
66
|
+
* that should not be nagged. Only `strict` turns it on.
|
|
67
|
+
*/
|
|
68
|
+
const configs = {
|
|
69
|
+
recommended: {
|
|
70
|
+
name: `${PLUGIN_NAME}/recommended`,
|
|
71
|
+
plugins: { [PLUGIN_NAME]: plugin },
|
|
72
|
+
rules: getRecommendedLintRules()
|
|
73
|
+
},
|
|
74
|
+
strict: {
|
|
75
|
+
...createConfig("strict", {
|
|
76
|
+
"no-raw-text": "error",
|
|
77
|
+
"static-dictionary-key": "error",
|
|
78
|
+
"no-dynamic-field-access": "error",
|
|
79
|
+
"enforce-adapter-import": "error"
|
|
80
|
+
}),
|
|
81
|
+
rules: {
|
|
82
|
+
"intlayer/no-raw-text": ["error", { includeStringLiterals: true }],
|
|
83
|
+
"intlayer/static-dictionary-key": "error",
|
|
84
|
+
"intlayer/no-dynamic-field-access": "error",
|
|
85
|
+
"intlayer/enforce-adapter-import": "error"
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
"contract-only": createConfig("contract-only", {
|
|
89
|
+
"static-dictionary-key": "error",
|
|
90
|
+
"no-dynamic-field-access": "error"
|
|
91
|
+
})
|
|
92
|
+
};
|
|
93
|
+
const intlayerPlugin = {
|
|
94
|
+
meta,
|
|
95
|
+
rules,
|
|
96
|
+
configs
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
//#endregion
|
|
100
|
+
export { configs, intlayerPlugin as default, meta, rules };
|
|
101
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../../src/index.ts"],"sourcesContent":["import { getRecommendedLintRules } from '@intlayer/config/lint';\nimport type { ESLint, Linter } from 'eslint';\nimport { enforceAdapterImport } from './rules/enforceAdapterImport';\nimport { noDynamicFieldAccess } from './rules/noDynamicFieldAccess';\nimport { noRawText } from './rules/noRawText';\nimport { staticDictionaryKey } from './rules/staticDictionaryKey';\n\nconst PLUGIN_NAME = 'intlayer';\n\n/**\n * Every rule this plugin ships, keyed by the name used in configuration.\n *\n * The rules split into two families:\n * - **Coverage** (`no-raw-text`) — user-facing copy that never reached a\n * dictionary. Nothing else in the toolchain reports it.\n * - **Compiler contract** (`static-dictionary-key`, `no-dynamic-field-access`)\n * — call shapes that type-check and run, but that the Babel/SWC passes cannot\n * analyse, so they silently lose the optimize pass or read a purged field.\n *\n * Deliberately absent: unknown dictionary keys, unknown field paths and missing\n * required locales. The generated `__DictionaryRegistry` module augmentation\n * already makes all three compile errors, and `@intlayer/lsp` reports the first\n * as an editor diagnostic — a third report would only add noise.\n */\nexport const rules = {\n 'no-raw-text': noRawText,\n 'static-dictionary-key': staticDictionaryKey,\n 'no-dynamic-field-access': noDynamicFieldAccess,\n 'enforce-adapter-import': enforceAdapterImport,\n} satisfies ESLint.Plugin['rules'];\n\n/** Rule names this plugin exposes. */\nexport type IntlayerRuleName = keyof typeof rules;\n\nconst meta: { name: string; version: string } = {\n name: 'eslint-plugin-intlayer',\n version: '9.3.1',\n};\n\nconst plugin: ESLint.Plugin = { meta, rules };\n\n/** The flat configs this plugin ships. */\nexport type IntlayerConfigName = 'recommended' | 'strict' | 'contract-only';\n\n/**\n * Build a flat config enabling the given rules under the `intlayer/` prefix.\n *\n * @param name - Config name, surfaced by `--print-config` and the config inspector.\n * @param ruleSeverities - Severity per rule name.\n */\nconst createConfig = (\n name: string,\n ruleSeverities: Partial<Record<IntlayerRuleName, Linter.RuleSeverity>>\n): Linter.Config => ({\n name: `intlayer/${name}`,\n plugins: { [PLUGIN_NAME]: plugin },\n rules: Object.fromEntries(\n Object.entries(ruleSeverities).map(([ruleName, severity]) => [\n `${PLUGIN_NAME}/${ruleName}`,\n severity,\n ])\n ),\n});\n\n/**\n * Flat configs, for `eslint.config.js`:\n *\n * ```js\n * import intlayer from 'eslint-plugin-intlayer';\n *\n * export default [intlayer.configs.recommended];\n * ```\n *\n * - `recommended` — the compiler-contract rules as errors, plus `no-raw-text`\n * as a warning so an existing codebase is not blocked on day one.\n * - `strict` — everything as an error, including string literals outside JSX.\n * - `contract-only` — just the rules that protect the build output; use this\n * when translation coverage is tracked by `intlayer test` instead.\n *\n * `enforce-adapter-import` is opt-in: importing from the original package is\n * perfectly valid once the bundler alias is set up, so a project that has done\n * that should not be nagged. Only `strict` turns it on.\n */\nexport const configs: Record<IntlayerConfigName, Linter.Config> = {\n // Built from the shared preset in `@intlayer/config/lint`, which `intlayer\n // init` also writes into `.oxlintrc.json` — oxlint resolves a plugin's rules\n // but not its configs, so the two must not drift.\n recommended: {\n name: `${PLUGIN_NAME}/recommended`,\n plugins: { [PLUGIN_NAME]: plugin },\n rules: getRecommendedLintRules() as Linter.Config['rules'],\n },\n strict: {\n ...createConfig('strict', {\n 'no-raw-text': 'error',\n 'static-dictionary-key': 'error',\n 'no-dynamic-field-access': 'error',\n 'enforce-adapter-import': 'error',\n }),\n rules: {\n 'intlayer/no-raw-text': ['error', { includeStringLiterals: true }],\n 'intlayer/static-dictionary-key': 'error',\n 'intlayer/no-dynamic-field-access': 'error',\n 'intlayer/enforce-adapter-import': 'error',\n },\n },\n 'contract-only': createConfig('contract-only', {\n 'static-dictionary-key': 'error',\n 'no-dynamic-field-access': 'error',\n }),\n};\n\nexport type { NoRawTextOptions } from './rules/noRawText';\nexport { meta };\n\n/** The plugin object, as consumed by `eslint.config.js`. */\nexport type IntlayerPlugin = ESLint.Plugin & {\n meta: { name: string; version: string };\n rules: typeof rules;\n configs: Record<IntlayerConfigName, Linter.Config>;\n};\n\nconst intlayerPlugin: IntlayerPlugin = { meta, rules, configs };\n\nexport default intlayerPlugin;\n"],"mappings":";;;;;;;AAOA,MAAM,cAAc;;;;;;;;;;;;;;;;AAiBpB,MAAa,QAAQ;CACnB,eAAe;CACf,yBAAyB;CACzB,2BAA2B;CAC3B,0BAA0B;AAC5B;AAKA,MAAM,OAA0C;CAC9C,MAAM;CACN,SAAS;AACX;AAEA,MAAM,SAAwB;CAAE;CAAM;AAAM;;;;;;;AAW5C,MAAM,gBACJ,MACA,oBACmB;CACnB,MAAM,YAAY;CAClB,SAAS,GAAG,cAAc,OAAO;CACjC,OAAO,OAAO,YACZ,OAAO,QAAQ,cAAc,CAAC,CAAC,KAAK,CAAC,UAAU,cAAc,CAC3D,GAAG,YAAY,GAAG,YAClB,QACF,CAAC,CACH;AACF;;;;;;;;;;;;;;;;;;;;AAqBA,MAAa,UAAqD;CAIhE,aAAa;EACX,MAAM,GAAG,YAAY;EACrB,SAAS,GAAG,cAAc,OAAO;EACjC,OAAO,wBAAwB;CACjC;CACA,QAAQ;EACN,GAAG,aAAa,UAAU;GACxB,eAAe;GACf,yBAAyB;GACzB,2BAA2B;GAC3B,0BAA0B;EAC5B,CAAC;EACD,OAAO;GACL,wBAAwB,CAAC,SAAS,EAAE,uBAAuB,KAAK,CAAC;GACjE,kCAAkC;GAClC,oCAAoC;GACpC,mCAAmC;EACrC;CACF;CACA,iBAAiB,aAAa,iBAAiB;EAC7C,yBAAyB;EACzB,2BAA2B;CAC7B,CAAC;AACH;AAYA,MAAM,iBAAiC;CAAE;CAAM;CAAO;AAAQ"}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { toAstNode } from "../utils/ast.mjs";
|
|
2
|
+
import { createRule } from "../utils/createRule.mjs";
|
|
3
|
+
import { ALL_CALLERS } from "@intlayer/config/callers";
|
|
4
|
+
|
|
5
|
+
//#region src/rules/enforceAdapterImport.ts
|
|
6
|
+
const ADAPTER_SCOPE = "@intlayer/";
|
|
7
|
+
/**
|
|
8
|
+
* Original library specifier → the `@intlayer/*` adapter that replaces it.
|
|
9
|
+
*
|
|
10
|
+
* Derived from the shared registry rather than hard-coded: every compat
|
|
11
|
+
* descriptor lists both its original specifiers and their adapter equivalents in
|
|
12
|
+
* `importSources`, so a library added to `@intlayer/config/callers` is covered
|
|
13
|
+
* here for free.
|
|
14
|
+
*
|
|
15
|
+
* Sub-paths line up by position within a descriptor — `next-intl/server` and
|
|
16
|
+
* `@intlayer/next-intl/server` are listed together — so the mapping is built by
|
|
17
|
+
* matching each original against the adapter that ends with the same tail.
|
|
18
|
+
*/
|
|
19
|
+
const buildAdapterMap = () => {
|
|
20
|
+
const adapterBySpecifier = /* @__PURE__ */ new Map();
|
|
21
|
+
for (const descriptor of ALL_CALLERS) {
|
|
22
|
+
const adapters = descriptor.importSources.filter((source) => source.startsWith(ADAPTER_SCOPE));
|
|
23
|
+
if (adapters.length === 0) continue;
|
|
24
|
+
const originals = descriptor.importSources.filter((source) => !source.startsWith(ADAPTER_SCOPE));
|
|
25
|
+
for (const original of originals) {
|
|
26
|
+
const adapter = adapters.find((candidate) => candidate === `${ADAPTER_SCOPE}${original}`) ?? adapters.find((candidate) => candidate.endsWith(`/${original}`)) ?? adapters[0];
|
|
27
|
+
if (adapter && !adapterBySpecifier.has(original)) adapterBySpecifier.set(original, adapter);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return adapterBySpecifier;
|
|
31
|
+
};
|
|
32
|
+
const ADAPTER_BY_SPECIFIER = buildAdapterMap();
|
|
33
|
+
const enforceAdapterImport = createRule("enforce-adapter-import", {
|
|
34
|
+
meta: {
|
|
35
|
+
type: "suggestion",
|
|
36
|
+
fixable: "code",
|
|
37
|
+
docs: { description: "Import compat-library APIs from their `@intlayer/*` adapter rather than the original package" },
|
|
38
|
+
messages: { useAdapterImport: "Import from `{{adapter}}` instead of `{{original}}`. The original specifier only resolves to Intlayer when the bundler alias is configured; the adapter always does." }
|
|
39
|
+
},
|
|
40
|
+
create: (context) => {
|
|
41
|
+
/** Report and rewrite a module specifier that has an adapter equivalent. */
|
|
42
|
+
const checkSource = (node) => {
|
|
43
|
+
if (!node) return;
|
|
44
|
+
const original = node["value"];
|
|
45
|
+
if (typeof original !== "string") return;
|
|
46
|
+
const adapter = ADAPTER_BY_SPECIFIER.get(original);
|
|
47
|
+
if (!adapter) return;
|
|
48
|
+
context.report({
|
|
49
|
+
node,
|
|
50
|
+
messageId: "useAdapterImport",
|
|
51
|
+
data: {
|
|
52
|
+
adapter,
|
|
53
|
+
original
|
|
54
|
+
},
|
|
55
|
+
fix: (fixer) => {
|
|
56
|
+
const quote = (node["raw"] ?? `'${original}'`).startsWith("\"") ? "\"" : "'";
|
|
57
|
+
return fixer.replaceText(node, `${quote}${adapter}${quote}`);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
};
|
|
61
|
+
return {
|
|
62
|
+
ImportDeclaration: (node) => {
|
|
63
|
+
checkSource(toAstNode(node)["source"]);
|
|
64
|
+
},
|
|
65
|
+
ExportNamedDeclaration: (node) => {
|
|
66
|
+
checkSource(toAstNode(node)["source"]);
|
|
67
|
+
},
|
|
68
|
+
ExportAllDeclaration: (node) => {
|
|
69
|
+
checkSource(toAstNode(node)["source"]);
|
|
70
|
+
},
|
|
71
|
+
ImportExpression: (node) => {
|
|
72
|
+
checkSource(toAstNode(node)["source"]);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
//#endregion
|
|
79
|
+
export { enforceAdapterImport };
|
|
80
|
+
//# sourceMappingURL=enforceAdapterImport.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"enforceAdapterImport.mjs","names":[],"sources":["../../../src/rules/enforceAdapterImport.ts"],"sourcesContent":["import { ALL_CALLERS } from '@intlayer/config/callers';\nimport { type AstNode, toAstNode } from '../utils/ast';\nimport { createRule } from '../utils/createRule';\n\ntype MessageIds = 'useAdapterImport';\n\nconst ADAPTER_SCOPE = '@intlayer/';\n\n/**\n * Original library specifier → the `@intlayer/*` adapter that replaces it.\n *\n * Derived from the shared registry rather than hard-coded: every compat\n * descriptor lists both its original specifiers and their adapter equivalents in\n * `importSources`, so a library added to `@intlayer/config/callers` is covered\n * here for free.\n *\n * Sub-paths line up by position within a descriptor — `next-intl/server` and\n * `@intlayer/next-intl/server` are listed together — so the mapping is built by\n * matching each original against the adapter that ends with the same tail.\n */\nconst buildAdapterMap = (): Map<string, string> => {\n const adapterBySpecifier = new Map<string, string>();\n\n for (const descriptor of ALL_CALLERS) {\n const adapters = descriptor.importSources.filter((source) =>\n source.startsWith(ADAPTER_SCOPE)\n );\n\n if (adapters.length === 0) continue;\n\n const originals = descriptor.importSources.filter(\n (source) => !source.startsWith(ADAPTER_SCOPE)\n );\n\n for (const original of originals) {\n // `next-intl/server` → prefer `@intlayer/next-intl/server` over\n // `@intlayer/next-intl`, so match on the longest shared suffix.\n const adapter =\n adapters.find(\n (candidate) => candidate === `${ADAPTER_SCOPE}${original}`\n ) ??\n adapters.find((candidate) => candidate.endsWith(`/${original}`)) ??\n adapters[0];\n\n if (adapter && !adapterBySpecifier.has(original)) {\n adapterBySpecifier.set(original, adapter);\n }\n }\n }\n\n return adapterBySpecifier;\n};\n\nconst ADAPTER_BY_SPECIFIER = buildAdapterMap();\n\nexport const enforceAdapterImport = createRule<[], MessageIds>(\n 'enforce-adapter-import',\n {\n meta: {\n type: 'suggestion',\n fixable: 'code',\n docs: {\n description:\n 'Import compat-library APIs from their `@intlayer/*` adapter rather than the original package',\n },\n messages: {\n useAdapterImport:\n 'Import from `{{adapter}}` instead of `{{original}}`. The original specifier only resolves to Intlayer when the bundler alias is configured; the adapter always does.',\n },\n },\n\n create: (context) => {\n /** Report and rewrite a module specifier that has an adapter equivalent. */\n const checkSource = (node: AstNode | null | undefined): void => {\n if (!node) return;\n\n const original = node['value'];\n\n if (typeof original !== 'string') return;\n\n const adapter = ADAPTER_BY_SPECIFIER.get(original);\n\n if (!adapter) return;\n\n context.report({\n node: node as never,\n messageId: 'useAdapterImport',\n data: { adapter, original },\n fix: (fixer) => {\n const raw = (node['raw'] as string | undefined) ?? `'${original}'`;\n const quote = raw.startsWith('\"') ? '\"' : \"'\";\n\n return fixer.replaceText(\n node as never,\n `${quote}${adapter}${quote}`\n );\n },\n });\n };\n\n return {\n ImportDeclaration: (node) => {\n checkSource(toAstNode(node)['source'] as AstNode);\n },\n\n ExportNamedDeclaration: (node) => {\n checkSource(toAstNode(node)['source'] as AstNode);\n },\n\n ExportAllDeclaration: (node) => {\n checkSource(toAstNode(node)['source'] as AstNode);\n },\n\n ImportExpression: (node) => {\n checkSource(toAstNode(node)['source'] as AstNode);\n },\n };\n },\n }\n);\n"],"mappings":";;;;;AAMA,MAAM,gBAAgB;;;;;;;;;;;;;AActB,MAAM,wBAA6C;CACjD,MAAM,qCAAqB,IAAI,IAAoB;CAEnD,KAAK,MAAM,cAAc,aAAa;EACpC,MAAM,WAAW,WAAW,cAAc,QAAQ,WAChD,OAAO,WAAW,aAAa,CACjC;EAEA,IAAI,SAAS,WAAW,GAAG;EAE3B,MAAM,YAAY,WAAW,cAAc,QACxC,WAAW,CAAC,OAAO,WAAW,aAAa,CAC9C;EAEA,KAAK,MAAM,YAAY,WAAW;GAGhC,MAAM,UACJ,SAAS,MACN,cAAc,cAAc,GAAG,gBAAgB,UAClD,KACA,SAAS,MAAM,cAAc,UAAU,SAAS,IAAI,UAAU,CAAC,KAC/D,SAAS;GAEX,IAAI,WAAW,CAAC,mBAAmB,IAAI,QAAQ,GAC7C,mBAAmB,IAAI,UAAU,OAAO;EAE5C;CACF;CAEA,OAAO;AACT;AAEA,MAAM,uBAAuB,gBAAgB;AAE7C,MAAa,uBAAuB,WAClC,0BACA;CACE,MAAM;EACJ,MAAM;EACN,SAAS;EACT,MAAM,EACJ,aACE,+FACJ;EACA,UAAU,EACR,kBACE,uKACJ;CACF;CAEA,SAAS,YAAY;;EAEnB,MAAM,eAAe,SAA2C;GAC9D,IAAI,CAAC,MAAM;GAEX,MAAM,WAAW,KAAK;GAEtB,IAAI,OAAO,aAAa,UAAU;GAElC,MAAM,UAAU,qBAAqB,IAAI,QAAQ;GAEjD,IAAI,CAAC,SAAS;GAEd,QAAQ,OAAO;IACP;IACN,WAAW;IACX,MAAM;KAAE;KAAS;IAAS;IAC1B,MAAM,UAAU;KAEd,MAAM,SADO,KAAK,UAAiC,IAAI,SAAS,GAC/C,CAAC,WAAW,IAAG,IAAI,OAAM;KAE1C,OAAO,MAAM,YACX,MACA,GAAG,QAAQ,UAAU,OACvB;IACF;GACF,CAAC;EACH;EAEA,OAAO;GACL,oBAAoB,SAAS;IAC3B,YAAY,UAAU,IAAI,CAAC,CAAC,SAAoB;GAClD;GAEA,yBAAyB,SAAS;IAChC,YAAY,UAAU,IAAI,CAAC,CAAC,SAAoB;GAClD;GAEA,uBAAuB,SAAS;IAC9B,YAAY,UAAU,IAAI,CAAC,CAAC,SAAoB;GAClD;GAEA,mBAAmB,SAAS;IAC1B,YAAY,UAAU,IAAI,CAAC,CAAC,SAAoB;GAClD;EACF;CACF;AACF,CACF"}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { getStaticStringValue, toAstNode } from "../utils/ast.mjs";
|
|
2
|
+
import { createRule } from "../utils/createRule.mjs";
|
|
3
|
+
import { collectImportSources, getActiveDescriptors, matchCaller } from "../utils/callerMatcher.mjs";
|
|
4
|
+
|
|
5
|
+
//#region src/rules/noDynamicFieldAccess.ts
|
|
6
|
+
/**
|
|
7
|
+
* Property name holding the translation function in a `destructured-t` result,
|
|
8
|
+
* e.g. `const { t } = useTranslation('home')`.
|
|
9
|
+
*/
|
|
10
|
+
const TRANSLATOR_PROPERTY_NAME = "t";
|
|
11
|
+
/**
|
|
12
|
+
* True when a computed member access reads a field the compiler can still name:
|
|
13
|
+
* a literal (`content['title']`, `items[0]`) or an uninterpolated template.
|
|
14
|
+
*
|
|
15
|
+
* @param property - The `property` node of a computed MemberExpression.
|
|
16
|
+
*/
|
|
17
|
+
const isStaticComputedProperty = (property) => {
|
|
18
|
+
if (!property) return false;
|
|
19
|
+
if (property.type === "Literal") return true;
|
|
20
|
+
return getStaticStringValue(property) !== null;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* The variables a declarator binds, paired with the identifier that declared
|
|
24
|
+
* each one — needed to tell `t` apart from `i18n` in a destructuring pattern.
|
|
25
|
+
*
|
|
26
|
+
* @param variables - Result of `sourceCode.getDeclaredVariables(declarator)`.
|
|
27
|
+
* @param identifier - The declaring identifier to look up.
|
|
28
|
+
*/
|
|
29
|
+
const findVariableForIdentifier = (variables, identifier) => variables.find((variable) => variable.identifiers.some((declared) => declared === identifier)) ?? null;
|
|
30
|
+
/**
|
|
31
|
+
* Locate the identifier bound to the `t` property of an object pattern,
|
|
32
|
+
* honouring renames (`const { t: translate } = …`).
|
|
33
|
+
*
|
|
34
|
+
* @param pattern - The declarator's `id`, expected to be an ObjectPattern.
|
|
35
|
+
*/
|
|
36
|
+
const findTranslatorIdentifier = (pattern) => {
|
|
37
|
+
if (pattern.type !== "ObjectPattern") return null;
|
|
38
|
+
const properties = pattern["properties"] ?? [];
|
|
39
|
+
for (const property of properties) {
|
|
40
|
+
if (property.type !== "Property" || property["computed"] === true) continue;
|
|
41
|
+
const key = property["key"];
|
|
42
|
+
if ((key?.type === "Identifier" ? key["name"] : key?.type === "Literal" ? String(key["value"]) : null) !== TRANSLATOR_PROPERTY_NAME) continue;
|
|
43
|
+
const value = property["value"];
|
|
44
|
+
return value?.type === "Identifier" ? value : null;
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
};
|
|
48
|
+
const noDynamicFieldAccess = createRule("no-dynamic-field-access", {
|
|
49
|
+
meta: {
|
|
50
|
+
type: "problem",
|
|
51
|
+
docs: { description: "Require the field read from an Intlayer dictionary to be statically known" },
|
|
52
|
+
messages: {
|
|
53
|
+
dynamicField: "Computed access on Intlayer content is not statically analyzable. The purge pass strips fields it cannot see used, so this may read `undefined` at runtime — read the field by name instead.",
|
|
54
|
+
dynamicMessageId: "The message path passed to `{{caller}}` must be a static string. The purge pass strips fields it cannot see used, so this may read `undefined` at runtime."
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
create: (context) => {
|
|
58
|
+
const sourceCode = context.sourceCode;
|
|
59
|
+
let activeDescriptors = getActiveDescriptors(/* @__PURE__ */ new Set());
|
|
60
|
+
/** Report every computed, non-static member access on a bound variable. */
|
|
61
|
+
const checkContentVariable = (variable) => {
|
|
62
|
+
for (const reference of variable.references) {
|
|
63
|
+
const identifier = reference.identifier;
|
|
64
|
+
const parent = identifier.parent;
|
|
65
|
+
if (parent?.type !== "MemberExpression") continue;
|
|
66
|
+
if (parent["object"] !== identifier) continue;
|
|
67
|
+
if (parent["computed"] !== true) continue;
|
|
68
|
+
if (isStaticComputedProperty(parent["property"])) continue;
|
|
69
|
+
context.report({
|
|
70
|
+
node: parent,
|
|
71
|
+
messageId: "dynamicField"
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
/** Report every call of a translation function with a runtime message path. */
|
|
76
|
+
const checkTranslatorVariable = (variable, callerName) => {
|
|
77
|
+
for (const reference of variable.references) {
|
|
78
|
+
const identifier = reference.identifier;
|
|
79
|
+
const parent = identifier.parent;
|
|
80
|
+
if (parent?.type !== "CallExpression") continue;
|
|
81
|
+
if (parent["callee"] !== identifier) continue;
|
|
82
|
+
const [firstArgument] = parent["arguments"] ?? [];
|
|
83
|
+
if (!firstArgument) continue;
|
|
84
|
+
if (getStaticStringValue(firstArgument) !== null) continue;
|
|
85
|
+
context.report({
|
|
86
|
+
node: firstArgument,
|
|
87
|
+
messageId: "dynamicMessageId",
|
|
88
|
+
data: { caller: callerName }
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
return {
|
|
93
|
+
Program: (node) => {
|
|
94
|
+
activeDescriptors = getActiveDescriptors(collectImportSources(toAstNode(node)));
|
|
95
|
+
},
|
|
96
|
+
VariableDeclarator: (node) => {
|
|
97
|
+
const astNode = toAstNode(node);
|
|
98
|
+
const init = astNode["init"];
|
|
99
|
+
if (!init) return;
|
|
100
|
+
const match = matchCaller(init, activeDescriptors);
|
|
101
|
+
if (!match) return;
|
|
102
|
+
const { descriptor } = match;
|
|
103
|
+
const shape = descriptor.translationFunction;
|
|
104
|
+
const kind = shape === "content" ? "content" : shape === "return-value" || shape === "destructured-t" ? "translator" : null;
|
|
105
|
+
if (!kind) return;
|
|
106
|
+
const declaredVariables = sourceCode.getDeclaredVariables(node);
|
|
107
|
+
const id = astNode["id"];
|
|
108
|
+
if (kind === "content") {
|
|
109
|
+
for (const variable of declaredVariables) checkContentVariable(variable);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (shape === "return-value") {
|
|
113
|
+
if (id.type !== "Identifier") return;
|
|
114
|
+
const variable = findVariableForIdentifier(declaredVariables, id);
|
|
115
|
+
if (variable) checkTranslatorVariable(variable, descriptor.callerName);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const translatorIdentifier = findTranslatorIdentifier(id);
|
|
119
|
+
if (!translatorIdentifier) return;
|
|
120
|
+
const variable = findVariableForIdentifier(declaredVariables, translatorIdentifier);
|
|
121
|
+
if (variable) checkTranslatorVariable(variable, descriptor.callerName);
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
//#endregion
|
|
128
|
+
export { noDynamicFieldAccess };
|
|
129
|
+
//# sourceMappingURL=noDynamicFieldAccess.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"noDynamicFieldAccess.mjs","names":[],"sources":["../../../src/rules/noDynamicFieldAccess.ts"],"sourcesContent":["import type { Scope } from 'eslint';\nimport {\n type AstNode,\n type AstNodeWithParent,\n getStaticStringValue,\n toAstNode,\n} from '../utils/ast';\nimport {\n collectImportSources,\n getActiveDescriptors,\n matchCaller,\n} from '../utils/callerMatcher';\nimport { createRule } from '../utils/createRule';\n\ntype MessageIds = 'dynamicField' | 'dynamicMessageId';\n\n/**\n * What a variable bound from a caller call holds.\n *\n * - `content` — the dictionary content object, or a destructured subtree of\n * it (`useIntlayer`, `getIntlayer`).\n * - `translator` — a translation function taking a message path\n * (`useTranslations`, the `t` of `useTranslation`).\n */\ntype BindingKind = 'content' | 'translator';\n\n/**\n * Property name holding the translation function in a `destructured-t` result,\n * e.g. `const { t } = useTranslation('home')`.\n */\nconst TRANSLATOR_PROPERTY_NAME = 't';\n\n/**\n * True when a computed member access reads a field the compiler can still name:\n * a literal (`content['title']`, `items[0]`) or an uninterpolated template.\n *\n * @param property - The `property` node of a computed MemberExpression.\n */\nconst isStaticComputedProperty = (property: AstNode | null): boolean => {\n if (!property) return false;\n\n if (property.type === 'Literal') return true;\n\n return getStaticStringValue(property) !== null;\n};\n\n/**\n * The variables a declarator binds, paired with the identifier that declared\n * each one — needed to tell `t` apart from `i18n` in a destructuring pattern.\n *\n * @param variables - Result of `sourceCode.getDeclaredVariables(declarator)`.\n * @param identifier - The declaring identifier to look up.\n */\nconst findVariableForIdentifier = (\n variables: Scope.Variable[],\n identifier: AstNode\n): Scope.Variable | null =>\n variables.find((variable) =>\n variable.identifiers.some(\n (declared) => (declared as unknown as AstNode) === identifier\n )\n ) ?? null;\n\n/**\n * Locate the identifier bound to the `t` property of an object pattern,\n * honouring renames (`const { t: translate } = …`).\n *\n * @param pattern - The declarator's `id`, expected to be an ObjectPattern.\n */\nconst findTranslatorIdentifier = (pattern: AstNode): AstNode | null => {\n if (pattern.type !== 'ObjectPattern') return null;\n\n const properties = (pattern['properties'] as AstNode[] | undefined) ?? [];\n\n for (const property of properties) {\n if (property.type !== 'Property' || property['computed'] === true) continue;\n\n const key = property['key'] as AstNode | undefined;\n const keyName =\n key?.type === 'Identifier'\n ? (key['name'] as string)\n : key?.type === 'Literal'\n ? String(key['value'])\n : null;\n\n if (keyName !== TRANSLATOR_PROPERTY_NAME) continue;\n\n const value = property['value'] as AstNode | undefined;\n\n return value?.type === 'Identifier' ? value : null;\n }\n\n return null;\n};\n\nexport const noDynamicFieldAccess = createRule<[], MessageIds>(\n 'no-dynamic-field-access',\n {\n meta: {\n type: 'problem',\n docs: {\n description:\n 'Require the field read from an Intlayer dictionary to be statically known',\n },\n messages: {\n dynamicField:\n 'Computed access on Intlayer content is not statically analyzable. The purge pass strips fields it cannot see used, so this may read `undefined` at runtime — read the field by name instead.',\n dynamicMessageId:\n 'The message path passed to `{{caller}}` must be a static string. The purge pass strips fields it cannot see used, so this may read `undefined` at runtime.',\n },\n },\n\n create: (context) => {\n const sourceCode = context.sourceCode;\n let activeDescriptors = getActiveDescriptors(new Set());\n\n /** Report every computed, non-static member access on a bound variable. */\n const checkContentVariable = (variable: Scope.Variable): void => {\n for (const reference of variable.references) {\n const identifier =\n reference.identifier as unknown as AstNodeWithParent;\n const parent = identifier.parent;\n\n if (parent?.type !== 'MemberExpression') continue;\n\n if (parent['object'] !== identifier) continue;\n\n if (parent['computed'] !== true) continue;\n\n if (isStaticComputedProperty(parent['property'] as AstNode)) continue;\n\n context.report({\n node: parent as never,\n messageId: 'dynamicField',\n });\n }\n };\n\n /** Report every call of a translation function with a runtime message path. */\n const checkTranslatorVariable = (\n variable: Scope.Variable,\n callerName: string\n ): void => {\n for (const reference of variable.references) {\n const identifier =\n reference.identifier as unknown as AstNodeWithParent;\n const parent = identifier.parent;\n\n if (parent?.type !== 'CallExpression') continue;\n\n if (parent['callee'] !== identifier) continue;\n\n const [firstArgument] = (parent['arguments'] as AstNode[]) ?? [];\n\n if (!firstArgument) continue;\n\n if (getStaticStringValue(firstArgument) !== null) continue;\n\n context.report({\n node: firstArgument as never,\n messageId: 'dynamicMessageId',\n data: { caller: callerName },\n });\n }\n };\n\n return {\n Program: (node) => {\n activeDescriptors = getActiveDescriptors(\n collectImportSources(toAstNode(node))\n );\n },\n\n VariableDeclarator: (node) => {\n const astNode = toAstNode(node);\n const init = astNode['init'] as AstNode | undefined;\n\n if (!init) return;\n\n const match = matchCaller(init, activeDescriptors);\n\n if (!match) return;\n\n const { descriptor } = match;\n const shape = descriptor.translationFunction;\n\n const kind: BindingKind | null =\n shape === 'content'\n ? 'content'\n : shape === 'return-value' || shape === 'destructured-t'\n ? 'translator'\n : null;\n\n if (!kind) return;\n\n const declaredVariables = sourceCode.getDeclaredVariables(node);\n const id = astNode['id'] as AstNode;\n\n if (kind === 'content') {\n // Every variable the pattern binds is content (the root object or a\n // destructured subtree of it), so all of them are worth checking.\n for (const variable of declaredVariables) {\n checkContentVariable(variable);\n }\n\n return;\n }\n\n if (shape === 'return-value') {\n if (id.type !== 'Identifier') return;\n\n const variable = findVariableForIdentifier(declaredVariables, id);\n\n if (variable) {\n checkTranslatorVariable(variable, descriptor.callerName);\n }\n\n return;\n }\n\n const translatorIdentifier = findTranslatorIdentifier(id);\n\n if (!translatorIdentifier) return;\n\n const variable = findVariableForIdentifier(\n declaredVariables,\n translatorIdentifier\n );\n\n if (variable) {\n checkTranslatorVariable(variable, descriptor.callerName);\n }\n },\n };\n },\n }\n);\n"],"mappings":";;;;;;;;;AA8BA,MAAM,2BAA2B;;;;;;;AAQjC,MAAM,4BAA4B,aAAsC;CACtE,IAAI,CAAC,UAAU,OAAO;CAEtB,IAAI,SAAS,SAAS,WAAW,OAAO;CAExC,OAAO,qBAAqB,QAAQ,MAAM;AAC5C;;;;;;;;AASA,MAAM,6BACJ,WACA,eAEA,UAAU,MAAM,aACd,SAAS,YAAY,MAClB,aAAc,aAAoC,UACrD,CACF,KAAK;;;;;;;AAQP,MAAM,4BAA4B,YAAqC;CACrE,IAAI,QAAQ,SAAS,iBAAiB,OAAO;CAE7C,MAAM,aAAc,QAAQ,iBAA2C,CAAC;CAExE,KAAK,MAAM,YAAY,YAAY;EACjC,IAAI,SAAS,SAAS,cAAc,SAAS,gBAAgB,MAAM;EAEnE,MAAM,MAAM,SAAS;EAQrB,KANE,KAAK,SAAS,eACT,IAAI,UACL,KAAK,SAAS,YACZ,OAAO,IAAI,QAAQ,IACnB,UAEQ,0BAA0B;EAE1C,MAAM,QAAQ,SAAS;EAEvB,OAAO,OAAO,SAAS,eAAe,QAAQ;CAChD;CAEA,OAAO;AACT;AAEA,MAAa,uBAAuB,WAClC,2BACA;CACE,MAAM;EACJ,MAAM;EACN,MAAM,EACJ,aACE,4EACJ;EACA,UAAU;GACR,cACE;GACF,kBACE;EACJ;CACF;CAEA,SAAS,YAAY;EACnB,MAAM,aAAa,QAAQ;EAC3B,IAAI,oBAAoB,qCAAqB,IAAI,IAAI,CAAC;;EAGtD,MAAM,wBAAwB,aAAmC;GAC/D,KAAK,MAAM,aAAa,SAAS,YAAY;IAC3C,MAAM,aACJ,UAAU;IACZ,MAAM,SAAS,WAAW;IAE1B,IAAI,QAAQ,SAAS,oBAAoB;IAEzC,IAAI,OAAO,cAAc,YAAY;IAErC,IAAI,OAAO,gBAAgB,MAAM;IAEjC,IAAI,yBAAyB,OAAO,WAAsB,GAAG;IAE7D,QAAQ,OAAO;KACb,MAAM;KACN,WAAW;IACb,CAAC;GACH;EACF;;EAGA,MAAM,2BACJ,UACA,eACS;GACT,KAAK,MAAM,aAAa,SAAS,YAAY;IAC3C,MAAM,aACJ,UAAU;IACZ,MAAM,SAAS,WAAW;IAE1B,IAAI,QAAQ,SAAS,kBAAkB;IAEvC,IAAI,OAAO,cAAc,YAAY;IAErC,MAAM,CAAC,iBAAkB,OAAO,gBAA8B,CAAC;IAE/D,IAAI,CAAC,eAAe;IAEpB,IAAI,qBAAqB,aAAa,MAAM,MAAM;IAElD,QAAQ,OAAO;KACb,MAAM;KACN,WAAW;KACX,MAAM,EAAE,QAAQ,WAAW;IAC7B,CAAC;GACH;EACF;EAEA,OAAO;GACL,UAAU,SAAS;IACjB,oBAAoB,qBAClB,qBAAqB,UAAU,IAAI,CAAC,CACtC;GACF;GAEA,qBAAqB,SAAS;IAC5B,MAAM,UAAU,UAAU,IAAI;IAC9B,MAAM,OAAO,QAAQ;IAErB,IAAI,CAAC,MAAM;IAEX,MAAM,QAAQ,YAAY,MAAM,iBAAiB;IAEjD,IAAI,CAAC,OAAO;IAEZ,MAAM,EAAE,eAAe;IACvB,MAAM,QAAQ,WAAW;IAEzB,MAAM,OACJ,UAAU,YACN,YACA,UAAU,kBAAkB,UAAU,mBACpC,eACA;IAER,IAAI,CAAC,MAAM;IAEX,MAAM,oBAAoB,WAAW,qBAAqB,IAAI;IAC9D,MAAM,KAAK,QAAQ;IAEnB,IAAI,SAAS,WAAW;KAGtB,KAAK,MAAM,YAAY,mBACrB,qBAAqB,QAAQ;KAG/B;IACF;IAEA,IAAI,UAAU,gBAAgB;KAC5B,IAAI,GAAG,SAAS,cAAc;KAE9B,MAAM,WAAW,0BAA0B,mBAAmB,EAAE;KAEhE,IAAI,UACF,wBAAwB,UAAU,WAAW,UAAU;KAGzD;IACF;IAEA,MAAM,uBAAuB,yBAAyB,EAAE;IAExD,IAAI,CAAC,sBAAsB;IAE3B,MAAM,WAAW,0BACf,mBACA,oBACF;IAEA,IAAI,UACF,wBAAwB,UAAU,WAAW,UAAU;GAE3D;EACF;CACF;AACF,CACF"}
|