Skip to content

Commit dedcfee

Browse files
authored
Merge pull request #49 from Darshan808/enhance-no-untranslated-strings
Improve `@jupyter/no-untranslated-string` for `JSX`
2 parents 5a745e7 + ca7f25b commit dedcfee

11 files changed

Lines changed: 161 additions & 38 deletions

.github/workflows/downstream.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ jobs:
4545
run: |
4646
npx eslint --config eslint.downstream.config.mjs \
4747
'jupyterlab/packages/*/src/**/*.ts' \
48+
'jupyterlab/packages/*/src/**/*.tsx' \
4849
--max-warnings=9999
4950
env:
5051
NODE_PATH: ./jupyterlab/node_modules
@@ -87,6 +88,7 @@ jobs:
8788
run: |
8889
npx eslint --config eslint.downstream.config.mjs \
8990
'notebook/packages/*/src/**/*.ts' \
91+
'notebook/packages/*/src/**/*.tsx' \
9092
--max-warnings=9999
9193
env:
9294
NODE_PATH: ./notebook/node_modules
@@ -129,6 +131,7 @@ jobs:
129131
run: |
130132
npx eslint --config eslint.downstream.config.mjs \
131133
'jupyterlite/packages/*/src/**/*.ts' \
134+
'jupyterlite/packages/*/src/**/*.tsx' \
132135
--max-warnings=9999
133136
env:
134137
NODE_PATH: ./jupyterlite/node_modules

eslint.downstream.config.mjs

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,10 @@ export default [
2121
// JupyterLab
2222
{
2323
basePath: __dirname,
24-
files: ['jupyterlab/packages/*/src/**/*.ts'],
24+
files: [
25+
'jupyterlab/packages/*/src/**/*.ts',
26+
'jupyterlab/packages/*/src/**/*.tsx'
27+
],
2528
plugins: {
2629
'jupyter': resolvedPlugin,
2730
'@typescript-eslint': resolvedTsPlugin
@@ -50,7 +53,10 @@ export default [
5053
// Notebook
5154
{
5255
basePath: __dirname,
53-
files: ['notebook/packages/*/src/**/*.ts'],
56+
files: [
57+
'notebook/packages/*/src/**/*.ts',
58+
'notebook/packages/*/src/**/*.tsx'
59+
],
5460
plugins: {
5561
'jupyter': resolvedPlugin,
5662
'@typescript-eslint': resolvedTsPlugin
@@ -79,7 +85,10 @@ export default [
7985
// Jupyterlite
8086
{
8187
basePath: __dirname,
82-
files: ['jupyterlite/packages/*/src/**/*.ts'],
88+
files: [
89+
'jupyterlite/packages/*/src/**/*.ts',
90+
'jupyterlite/packages/*/src/**/*.tsx'
91+
],
8392
plugins: {
8493
'jupyter': resolvedPlugin,
8594
'@typescript-eslint': resolvedTsPlugin

src/rules/no-untranslated-string.ts

Lines changed: 79 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -8,31 +8,33 @@ import { isAddCommandCall } from '../utils/commands';
88
import { getObjectProperties } from '../utils/plugin-utils';
99
import { createRule } from '../utils/create-rule';
1010

11-
/**
12-
* Returns true if the node is a non-empty raw string literal that should be
13-
* wrapped in a translation call. Handles:
14-
* - String Literal: 'string' or "string"
15-
* - TemplateLiteral with no expressions: `string`
16-
* - Concise ArrowFunctionExpression whose body is one of the above
17-
*/
18-
function isRawStringNode(node: TSESTree.Node): boolean {
19-
if (node.type === 'Literal') {
20-
return typeof node.value === 'string' && node.value.length > 0;
11+
function hasLetters(str: string): boolean {
12+
return /\p{L}/u.test(str);
13+
}
14+
15+
function getRawStringValue(node: TSESTree.Node): string | null {
16+
if (node.type === 'Literal' && typeof node.value === 'string') {
17+
return node.value;
2118
}
22-
if (node.type === 'TemplateLiteral') {
23-
if (node.expressions.length > 0) {
24-
return false;
25-
}
26-
const cooked = node.quasis.map(q => q.value.cooked ?? '').join('');
27-
return cooked.length > 0;
19+
if (node.type === 'TemplateLiteral' && node.expressions.length === 0) {
20+
return node.quasis.map(q => q.value.cooked ?? '').join('');
2821
}
2922
if (
3023
node.type === 'ArrowFunctionExpression' &&
3124
node.body.type !== 'BlockStatement'
3225
) {
33-
return isRawStringNode(node.body);
26+
return getRawStringValue(node.body);
3427
}
35-
return false;
28+
return null;
29+
}
30+
31+
/**
32+
* Returns true if the node is a non-empty raw string literal that should be
33+
* wrapped in a translation call.
34+
*/
35+
function isRawStringNode(node: TSESTree.Node): boolean {
36+
const rawValue = getRawStringValue(node);
37+
return rawValue !== null && rawValue.trim().length > 0;
3638
}
3739

3840
function isSetAttributeCall(node: TSESTree.CallExpression): boolean {
@@ -71,9 +73,11 @@ function isDialogButtonCall(node: TSESTree.CallExpression): boolean {
7173
}
7274

7375
const MONITORED_COMMAND_PROPS = ['label', 'caption', 'usage'];
74-
const MONITORED_SET_ATTRIBUTE_ATTRS = ['aria-label', 'aria-description', 'title'];
76+
const MONITORED_A11Y_ATTRS = ['aria-label', 'aria-description', 'title'];
77+
const MONITORED_SET_ATTRIBUTE_ATTRS = MONITORED_A11Y_ATTRS;
7578
const MONITORED_ASSIGNMENT_PROPS = ['title', 'ariaLabel'];
7679
const MONITORED_DIALOG_PROPS = ['title', 'body'];
80+
const MONITORED_JSX_ATTRS = MONITORED_A11Y_ATTRS;
7781

7882
const noUntranslatedString = createRule({
7983
name: 'no-untranslated-string',
@@ -100,11 +104,23 @@ const noUntranslatedString = createRule({
100104
untranslatedJsxText:
101105
'JSX text content has an untranslated string literal. Wrap it with {trans.__(...)}'
102106
},
103-
schema: []
107+
schema: [
108+
{
109+
type: 'object',
110+
properties: {
111+
enforcePunctuation: { type: 'boolean' }
112+
},
113+
additionalProperties: false
114+
}
115+
]
104116
},
105-
defaultOptions: [],
117+
defaultOptions: [{ enforcePunctuation: false }],
106118

107119
create(context) {
120+
const enforcePunctuation =
121+
(context.options[0] as { enforcePunctuation?: boolean })
122+
?.enforcePunctuation ?? false;
123+
108124
return {
109125
CallExpression(node) {
110126
// Branch A: commands.addCommand(id, { label, caption, usage })
@@ -265,9 +281,27 @@ const noUntranslatedString = createRule({
265281
}
266282
},
267283

284+
// Accessibility attribute with a plain string: <span aria-label="text" />
285+
JSXAttribute(node) {
286+
if (!node.value || node.value.type === 'JSXExpressionContainer') {
287+
return;
288+
}
289+
const attrName =
290+
node.name.type === 'JSXIdentifier' ? node.name.name : null;
291+
if (!attrName || !MONITORED_JSX_ATTRS.includes(attrName)) {
292+
return;
293+
}
294+
if (isRawStringNode(node.value)) {
295+
context.report({
296+
node: node.value,
297+
messageId: 'untranslatedJsxText'
298+
});
299+
}
300+
},
301+
268302
// Raw text between JSX tags: <span>Untranslated text</span>
269303
JSXText(node) {
270-
if (node.value.trim().length > 0) {
304+
if (node.value.trim().length > 0 && (enforcePunctuation || hasLetters(node.value))) {
271305
context.report({
272306
node,
273307
messageId: 'untranslatedJsxText'
@@ -280,11 +314,30 @@ const noUntranslatedString = createRule({
280314
if (node.expression.type === 'JSXEmptyExpression') {
281315
return;
282316
}
317+
if (node.parent.type === 'JSXAttribute') {
318+
const attrName =
319+
node.parent.name.type === 'JSXIdentifier'
320+
? node.parent.name.name
321+
: null;
322+
if (!attrName || !MONITORED_JSX_ATTRS.includes(attrName)) {
323+
return;
324+
}
325+
if (isRawStringNode(node.expression)) {
326+
context.report({
327+
node: node.expression,
328+
messageId: 'untranslatedJsxText'
329+
});
330+
}
331+
return;
332+
}
283333
if (isRawStringNode(node.expression)) {
284-
context.report({
285-
node: node.expression,
286-
messageId: 'untranslatedJsxText'
287-
});
334+
const value = getRawStringValue(node.expression);
335+
if (value !== null && (enforcePunctuation ? value.trim().length > 0 : hasLetters(value))) {
336+
context.report({
337+
node: node.expression,
338+
messageId: 'untranslatedJsxText'
339+
});
340+
}
288341
}
289342
}
290343
};

tests/jupyter-no-untranslated-string.test.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,13 @@ jsxRuleTester.run('no-untranslated-string (JSX)', noUntranslatedString, {
247247
valid: [
248248
// --- JSX: translated expression ---
249249
{ code: `const el = <span>{trans.__('Error message:')}</span>;` },
250-
{ code: `const el = (\n <div>\n <span>{trans.__('Label')}</span>\n </div>\n);` }
250+
{ code: `const el = (\n <div>\n <span>{trans.__('Label')}</span>\n </div>\n);` },
251+
{ code: `<div className={'normal-class-string'} />` },
252+
{ code: `<div id={'my-id'} />` },
253+
{ code: `<span aria-label={trans.__('Close')} />` },
254+
// Punctuation-only JSX text should not be flagged
255+
{ code: `<span>,</span>` },
256+
{ code: `<span>{' + '}</span>` }
251257
],
252258

253259
invalid: [
@@ -260,6 +266,49 @@ jsxRuleTester.run('no-untranslated-string (JSX)', noUntranslatedString, {
260266
{
261267
code: `const el = <span>{'raw string'}</span>;`,
262268
errors: [{ messageId: 'untranslatedJsxText' }]
269+
},
270+
// --- JSX accessibility attributes must be translated ---
271+
{
272+
code: `<button aria-label={'Close dialog'} />`,
273+
errors: [{ messageId: 'untranslatedJsxText' }]
274+
},
275+
{
276+
code: `<div title={'My tooltip'} />`,
277+
errors: [{ messageId: 'untranslatedJsxText' }]
278+
},
279+
{
280+
code: `<span aria-description={'Describes something'} />`,
281+
errors: [{ messageId: 'untranslatedJsxText' }]
282+
},
283+
{
284+
code: `<span aria-description="Describes something" />`,
285+
errors: [{ messageId: 'untranslatedJsxText' }]
286+
}
287+
]
288+
});
289+
290+
// enforcePunctuation option tests
291+
jsxRuleTester.run('no-untranslated-string (JSX, enforcePunctuation)', noUntranslatedString, {
292+
valid: [
293+
// Empty strings still ignored even with enforcePunctuation
294+
{ code: `<span>{''}</span>`, options: [{ enforcePunctuation: true }] }
295+
],
296+
invalid: [
297+
// Punctuation-only JSX text flagged when enforcePunctuation: true
298+
{
299+
code: `<div>,</div>`,
300+
options: [{ enforcePunctuation: true }],
301+
errors: [{ messageId: 'untranslatedJsxText' }]
302+
},
303+
{
304+
code: `<span>{' - '}</span>`,
305+
options: [{ enforcePunctuation: true }],
306+
errors: [{ messageId: 'untranslatedJsxText' }]
307+
},
308+
{
309+
code: `<span>{'.'}</span>`,
310+
options: [{ enforcePunctuation: true }],
311+
errors: [{ messageId: 'untranslatedJsxText' }]
263312
}
264313
]
265314
});

website/docs/rules/command-described-by.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# `jupyter/command-described-by`
1+
# `command-described-by`
22

33
Ensure JupyterLab command registrations include a `describedBy` property.
44

website/docs/rules/no-translation-concatenation.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# `jupyter/no-translation-concatenation`
1+
# `no-translation-concatenation`
22

33
Forbid string concatenation inside JupyterLab translation wrapper calls.
44

website/docs/rules/no-untranslated-string.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# `jupyter/no-untranslated-string`
1+
# `no-untranslated-string`
22

33
Require user-facing string literals to be wrapped in a translation call such as `trans.__()`.
44

@@ -94,4 +94,10 @@ const el = <span>{trans.__('Error message:')}</span>;
9494

9595
## Options
9696

97-
This rule has no options.
97+
```ts
98+
{
99+
"enforcePunctuation": false
100+
}
101+
```
102+
103+
Set `enforcePunctuation` option to `true` to enforce translation of punctuation characters such as `,`, `-`, `+`, and other symbols.

website/docs/rules/plugin-activation-args.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# `jupyter/plugin-activation-args`
1+
# `plugin-activation-args`
22

33
Ensure JupyterLab plugin `activate` arguments match the order and count of `requires` and `optional` tokens.
44

website/docs/rules/plugin-description.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# `jupyter/plugin-description`
1+
# `plugin-description`
22

33
Ensure all `JupyterFrontEndPlugin` objects define a non-empty `description` property.
44

website/docs/rules/token-format.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# `jupyter/token-format`
1+
# `token-format`
22

33
Ensure JupyterLab `Token` ids follow the `<package>:<TokenSymbol>` naming convention where the symbol is a valid JavaScript identifier.
44

0 commit comments

Comments
 (0)