Skip to content

Commit ce491dc

Browse files
committed
feat: add first rule and GitHub Actions CI
- Implement no-single-variable-message rule - Add 20 test cases for the rule - Set up GitHub Actions workflow (test, lint, build jobs) - Format codebase with new prettier settings
1 parent a6d7533 commit ce491dc

File tree

8 files changed

+309
-38
lines changed

8 files changed

+309
-38
lines changed

.cursor/rules/project-infrastructure.mdc

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,3 +177,30 @@ Note: `jiti` is required by ESLint 9 for loading TypeScript config files (`eslin
177177
- `npm run test` - Run tests
178178
- `npm run test:watch` - Run tests in watch mode
179179
- `npm run typecheck` - Type-check without emitting
180+
181+
## CI/CD
182+
183+
### GitHub Actions
184+
185+
The project uses GitHub Actions for continuous integration with three parallel jobs:
186+
187+
```yaml
188+
# .github/workflows/ci.yml
189+
jobs:
190+
test: # typecheck + unit tests
191+
lint: # eslint + prettier
192+
build: # compile + upload artifact
193+
```
194+
195+
### CI Checks
196+
197+
| Job | Steps |
198+
|-----|-------|
199+
| **test** | `npm run typecheck`, `npm test` |
200+
| **lint** | `npm run lint`, `npm run format:check` |
201+
| **build** | `npm run build`, upload `dist/` artifact |
202+
203+
### Triggers
204+
205+
- **Push** to `main` branch
206+
- **Pull requests** targeting `main`

.github/workflows/ci.yml

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
test:
11+
name: Test
12+
runs-on: ubuntu-latest
13+
steps:
14+
- uses: actions/checkout@v4
15+
16+
- uses: actions/setup-node@v4
17+
with:
18+
node-version: 24
19+
cache: npm
20+
21+
- run: npm ci
22+
23+
- run: npm run typecheck
24+
name: Type Check
25+
26+
- run: npm test
27+
name: Unit Tests
28+
29+
lint:
30+
name: Lint & Format
31+
runs-on: ubuntu-latest
32+
steps:
33+
- uses: actions/checkout@v4
34+
35+
- uses: actions/setup-node@v4
36+
with:
37+
node-version: 24
38+
cache: npm
39+
40+
- run: npm ci
41+
42+
- run: npm run lint
43+
name: ESLint
44+
45+
- run: npm run format:check
46+
name: Prettier
47+
48+
build:
49+
name: Build
50+
runs-on: ubuntu-latest
51+
steps:
52+
- uses: actions/checkout@v4
53+
54+
- uses: actions/setup-node@v4
55+
with:
56+
node-version: 24
57+
cache: npm
58+
59+
- run: npm ci
60+
61+
- run: npm run build
62+
name: Build
63+
64+
- uses: actions/upload-artifact@v4
65+
with:
66+
name: dist
67+
path: dist/
68+
retention-days: 7

eslint.config.ts

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import eslint from "@eslint/js";
2-
import tseslint from "typescript-eslint";
1+
import eslint from "@eslint/js"
2+
import tseslint from "typescript-eslint"
33

44
export default [
55
eslint.configs.recommended,
@@ -9,12 +9,12 @@ export default [
99
languageOptions: {
1010
parserOptions: {
1111
projectService: {
12-
allowDefaultProject: ["*.config.ts"],
12+
allowDefaultProject: ["*.config.ts"]
1313
},
14-
tsconfigRootDir: import.meta.dirname,
14+
tsconfigRootDir: import.meta.dirname
1515
},
16-
ecmaVersion: 2024,
17-
},
16+
ecmaVersion: 2024
17+
}
1818
},
1919
{
2020
rules: {
@@ -23,24 +23,24 @@ export default [
2323
"error",
2424
{
2525
argsIgnorePattern: "^_",
26-
varsIgnorePattern: "^_",
27-
},
26+
varsIgnorePattern: "^_"
27+
}
2828
],
2929
"@typescript-eslint/consistent-type-imports": [
3030
"error",
3131
{
3232
prefer: "type-imports",
33-
fixStyle: "inline-type-imports",
34-
},
33+
fixStyle: "inline-type-imports"
34+
}
3535
],
3636
"@typescript-eslint/consistent-type-definitions": ["error", "interface"],
3737
"@typescript-eslint/no-unnecessary-condition": "error",
3838
"@typescript-eslint/prefer-nullish-coalescing": "error",
3939
"@typescript-eslint/prefer-optional-chain": "error",
40-
"@typescript-eslint/strict-boolean-expressions": "error",
41-
},
40+
"@typescript-eslint/strict-boolean-expressions": "error"
41+
}
4242
},
4343
{
44-
ignores: ["dist/**", "node_modules/**", "coverage/**"],
45-
},
46-
];
44+
ignores: ["dist/**", "node_modules/**", "coverage/**"]
45+
}
46+
]

src/index.ts

Lines changed: 15 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,32 +4,29 @@
44
* @packageDocumentation
55
*/
66

7-
// TODO: Import rules when implemented
8-
// import { noComplexExpressionsInMessage } from "./rules/no-complex-expressions-in-message.js";
7+
import { noSingleVariableMessage } from "./rules/no-single-variable-message.js"
98

109
const plugin = {
1110
meta: {
1211
name: "eslint-plugin-lingui-typescript",
13-
version: "1.0.0",
12+
version: "1.0.0"
1413
},
1514
rules: {
16-
// Rules will be added here as they are implemented
15+
"no-single-variable-message": noSingleVariableMessage
1716
},
18-
configs: {},
17+
configs: {} as Record<string, unknown>
1918
}
2019

21-
// Add self-reference for flat config
22-
const flatRecommended = {
23-
plugins: {
24-
"lingui-ts": plugin,
25-
},
26-
rules: {
27-
// Recommended rules will be added here
28-
},
29-
};
30-
20+
// Add flat config with self-reference
3121
plugin.configs = {
32-
"flat/recommended": flatRecommended,
33-
};
22+
"flat/recommended": {
23+
plugins: {
24+
"lingui-ts": plugin
25+
},
26+
rules: {
27+
"lingui-ts/no-single-variable-message": "error"
28+
}
29+
}
30+
}
3431

35-
export default plugin;
32+
export default plugin
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { RuleTester } from "@typescript-eslint/rule-tester"
2+
import { afterAll, describe, it } from "vitest"
3+
4+
import { noSingleVariableMessage } from "./no-single-variable-message.js"
5+
6+
RuleTester.afterAll = afterAll
7+
RuleTester.describe = describe
8+
RuleTester.it = it
9+
10+
const ruleTester = new RuleTester({
11+
languageOptions: {
12+
parserOptions: {
13+
ecmaVersion: 2022,
14+
sourceType: "module",
15+
ecmaFeatures: {
16+
jsx: true
17+
}
18+
}
19+
}
20+
})
21+
22+
ruleTester.run("no-single-variable-message", noSingleVariableMessage, {
23+
valid: [
24+
// Template with text and variable
25+
"t`Hello ${name}`",
26+
"t`Status: ${status}`",
27+
"t`${count} items`",
28+
29+
// Template with only text
30+
"t`Hello World`",
31+
32+
// Template with multiple variables
33+
"t`${first} and ${second}`",
34+
35+
// JSX with text and variable
36+
"<Trans>Hello {name}</Trans>",
37+
"<Trans>Status: {status}</Trans>",
38+
"<Trans>{count} items</Trans>",
39+
40+
// JSX with only text
41+
"<Trans>Hello World</Trans>",
42+
43+
// JSX with multiple children
44+
"<Trans>{first} and {second}</Trans>",
45+
46+
// Non-Lingui tagged templates (should be ignored)
47+
"css`${variable}`",
48+
"html`${content}`",
49+
50+
// Non-Trans JSX elements (should be ignored)
51+
"<Plural>{count}</Plural>",
52+
"<div>{content}</div>"
53+
],
54+
invalid: [
55+
// Template with only variable
56+
{
57+
code: "t`${status}`",
58+
errors: [{ messageId: "singleVariable" }]
59+
},
60+
{
61+
code: "t`${user.name}`",
62+
errors: [{ messageId: "singleVariable" }]
63+
},
64+
// Template with variable and whitespace only
65+
{
66+
code: "t` ${status} `",
67+
errors: [{ messageId: "singleVariable" }]
68+
},
69+
70+
// JSX with only variable
71+
{
72+
code: "<Trans>{status}</Trans>",
73+
errors: [{ messageId: "singleVariable" }]
74+
},
75+
{
76+
code: "<Trans>{user.name}</Trans>",
77+
errors: [{ messageId: "singleVariable" }]
78+
},
79+
// JSX with variable and whitespace only
80+
{
81+
code: "<Trans> {status} </Trans>",
82+
errors: [{ messageId: "singleVariable" }]
83+
}
84+
]
85+
})
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/utils"
2+
3+
import { createRule } from "../utils/create-rule.js"
4+
5+
type MessageId = "singleVariable"
6+
7+
/**
8+
* Checks if a tagged template has only a single expression and no text content.
9+
*/
10+
function isSingleVariableTemplate(node: TSESTree.TemplateLiteral): boolean {
11+
// Must have exactly one expression
12+
if (node.expressions.length !== 1) {
13+
return false
14+
}
15+
16+
// All quasis must be empty (no text content)
17+
return node.quasis.every((quasi) => quasi.value.raw.trim() === "")
18+
}
19+
20+
/**
21+
* Checks if a JSX element has only a single expression child and no text content.
22+
*/
23+
function isSingleVariableJSX(children: TSESTree.JSXChild[]): boolean {
24+
const meaningfulChildren = children.filter((child) => {
25+
if (child.type === AST_NODE_TYPES.JSXText) {
26+
return child.value.trim() !== ""
27+
}
28+
return true
29+
})
30+
31+
// Must have exactly one meaningful child that is an expression
32+
if (meaningfulChildren.length !== 1) {
33+
return false
34+
}
35+
36+
const onlyChild = meaningfulChildren[0]
37+
return onlyChild?.type === AST_NODE_TYPES.JSXExpressionContainer
38+
}
39+
40+
export const noSingleVariableMessage = createRule<[], MessageId>({
41+
name: "no-single-variable-message",
42+
meta: {
43+
type: "problem",
44+
docs: {
45+
description: "Disallow Lingui messages that consist only of a single variable"
46+
},
47+
messages: {
48+
singleVariable:
49+
"Translation message should not consist only of a single variable. Add surrounding text for context."
50+
},
51+
schema: []
52+
},
53+
defaultOptions: [],
54+
create(context) {
55+
return {
56+
// Check t`${variable}` pattern
57+
TaggedTemplateExpression(node): void {
58+
if (node.tag.type !== AST_NODE_TYPES.Identifier || node.tag.name !== "t") {
59+
return
60+
}
61+
62+
if (isSingleVariableTemplate(node.quasi)) {
63+
context.report({
64+
node,
65+
messageId: "singleVariable"
66+
})
67+
}
68+
},
69+
70+
// Check <Trans>{variable}</Trans> pattern
71+
JSXElement(node): void {
72+
const openingElement = node.openingElement
73+
if (openingElement.name.type !== AST_NODE_TYPES.JSXIdentifier || openingElement.name.name !== "Trans") {
74+
return
75+
}
76+
77+
if (isSingleVariableJSX(node.children)) {
78+
context.report({
79+
node,
80+
messageId: "singleVariable"
81+
})
82+
}
83+
}
84+
}
85+
}
86+
})

src/utils/create-rule.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { ESLintUtils } from "@typescript-eslint/utils"
2+
3+
/**
4+
* Creates a typed ESLint rule with documentation URL generation.
5+
*/
6+
export const createRule = ESLintUtils.RuleCreator(
7+
(name) => `https://github.com/user/eslint-plugin-lingui-typescript/blob/main/docs/rules/${name}.md`
8+
)

0 commit comments

Comments
 (0)