@platformos/platformos-check-common 0.0.8 → 0.0.9
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/CHANGELOG.md +8 -0
- package/README.md +4 -4
- package/dist/checks/graphql-variables/index.js +4 -0
- package/dist/checks/graphql-variables/index.js.map +1 -1
- package/dist/checks/liquid-html-syntax-error/checks/InvalidLoopArguments.js +1 -1
- package/dist/checks/liquid-html-syntax-error/checks/InvalidLoopArguments.js.map +1 -1
- package/dist/checks/liquid-html-syntax-error/checks/InvalidTagSyntax.d.ts +19 -0
- package/dist/checks/liquid-html-syntax-error/checks/InvalidTagSyntax.js +79 -0
- package/dist/checks/liquid-html-syntax-error/checks/InvalidTagSyntax.js.map +1 -0
- package/dist/checks/liquid-html-syntax-error/checks/UnknownTag.d.ts +3 -0
- package/dist/checks/liquid-html-syntax-error/checks/UnknownTag.js +32 -0
- package/dist/checks/liquid-html-syntax-error/checks/UnknownTag.js.map +1 -0
- package/dist/checks/liquid-html-syntax-error/index.js +21 -3
- package/dist/checks/liquid-html-syntax-error/index.js.map +1 -1
- package/dist/checks/matching-translations/index.js +103 -68
- package/dist/checks/matching-translations/index.js.map +1 -1
- package/dist/checks/undefined-object/index.js +6 -1
- package/dist/checks/undefined-object/index.js.map +1 -1
- package/dist/context-utils.d.ts +16 -0
- package/dist/context-utils.js +30 -1
- package/dist/context-utils.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +17 -1
- package/dist/index.js.map +1 -1
- package/dist/liquid-doc/arguments.js +8 -1
- package/dist/liquid-doc/arguments.js.map +1 -1
- package/dist/liquid-doc/utils.d.ts +2 -2
- package/dist/liquid-doc/utils.js +10 -0
- package/dist/liquid-doc/utils.js.map +1 -1
- package/dist/path.d.ts +1 -1
- package/dist/path.js +12 -1
- package/dist/path.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/types.d.ts +8 -0
- package/dist/types.js.map +1 -1
- package/package.json +2 -2
- package/src/checks/duplicate-render-partial-arguments/index.spec.ts +12 -12
- package/src/checks/graphql-variables/index.spec.ts +95 -0
- package/src/checks/graphql-variables/index.ts +4 -0
- package/src/checks/img-width-and-height/index.ts +1 -1
- package/src/checks/invalid-hash-assign-target/index.spec.ts +26 -26
- package/src/checks/json-syntax-error/index.ts +1 -1
- package/src/checks/liquid-html-syntax-error/checks/InvalidLoopArguments.ts +2 -2
- package/src/checks/liquid-html-syntax-error/checks/InvalidTagSyntax.spec.ts +259 -0
- package/src/checks/liquid-html-syntax-error/checks/InvalidTagSyntax.ts +89 -0
- package/src/checks/liquid-html-syntax-error/checks/UnknownTag.spec.ts +293 -0
- package/src/checks/liquid-html-syntax-error/checks/UnknownTag.ts +43 -0
- package/src/checks/liquid-html-syntax-error/index.ts +24 -3
- package/src/checks/matching-translations/index.spec.ts +114 -24
- package/src/checks/matching-translations/index.ts +102 -81
- package/src/checks/metadata-params/index.ts +1 -1
- package/src/checks/missing-partial/index.ts +6 -6
- package/src/checks/undefined-object/index.spec.ts +29 -2
- package/src/checks/undefined-object/index.ts +7 -1
- package/src/checks/unused-assign/index.ts +1 -1
- package/src/checks/valid-json/index.ts +1 -1
- package/src/checks/valid-render-partial-argument-types/index.spec.ts +13 -13
- package/src/context-utils.ts +42 -1
- package/src/disabled-checks/index.spec.ts +26 -61
- package/src/disabled-checks/index.ts +2 -4
- package/src/disabled-checks/test-checks.ts +4 -4
- package/src/ignore.spec.ts +4 -4
- package/src/index.ts +18 -0
- package/src/liquid-doc/arguments.ts +9 -3
- package/src/liquid-doc/liquidDoc.spec.ts +1 -1
- package/src/liquid-doc/utils.ts +13 -5
- package/src/path.ts +16 -1
- package/src/test/MockApp.ts +2 -2
- package/src/test/MockFileSystem.spec.ts +10 -11
- package/src/test/contain-offense.spec.ts +11 -3
- package/src/test/test-helper.ts +24 -28
- package/src/types.ts +8 -0
- package/src/visitor.spec.ts +2 -2
- package/src/types/schemas/index.ts +0 -3
- package/src/types/schemas/preset.ts +0 -52
- package/src/types/schemas/setting.ts +0 -320
- package/src/types/schemas/template.ts +0 -34
|
@@ -9,6 +9,8 @@ import { detectInvalidLoopArguments } from './checks/InvalidLoopArguments';
|
|
|
9
9
|
import { detectConditionalNodeUnsupportedParenthesis } from './checks/InvalidConditionalNodeParenthesis';
|
|
10
10
|
import { detectInvalidFilterName } from './checks/InvalidFilterName';
|
|
11
11
|
import { detectInvalidPipeSyntax } from './checks/InvalidPipeSyntax';
|
|
12
|
+
import { detectUnknownTag } from './checks/UnknownTag';
|
|
13
|
+
import { detectInvalidTagSyntax } from './checks/InvalidTagSyntax';
|
|
12
14
|
import { isWithinRawTagThatDoesNotParseItsContents } from '../utils';
|
|
13
15
|
|
|
14
16
|
type LineColPosition = {
|
|
@@ -64,11 +66,21 @@ export const LiquidHTMLSyntaxError: LiquidCheckDefinition = {
|
|
|
64
66
|
async LiquidTag(node, ancestors) {
|
|
65
67
|
if (isWithinRawTagThatDoesNotParseItsContents(ancestors)) return;
|
|
66
68
|
|
|
69
|
+
const tags = (await tagsPromise) ?? [];
|
|
70
|
+
|
|
71
|
+
// Unknown tags are fatal — no point in further syntax checks.
|
|
72
|
+
const unknownTagProblem = detectUnknownTag(node, tags);
|
|
73
|
+
if (unknownTagProblem) {
|
|
74
|
+
context.report(unknownTagProblem);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Run specific sub-checks first — they provide better error messages and autofixes.
|
|
67
79
|
const problems = [
|
|
68
80
|
detectMultipleAssignValues(node),
|
|
69
81
|
detectInvalidEchoValue(node),
|
|
70
82
|
detectInvalidLoopRange(node),
|
|
71
|
-
detectInvalidLoopArguments(node,
|
|
83
|
+
detectInvalidLoopArguments(node, tags),
|
|
72
84
|
].filter(Boolean) as Problem<SourceCodeType.LiquidHtml>[];
|
|
73
85
|
|
|
74
86
|
// Fixers for `detectConditionalNodeUnsupportedParenthesis` and `detectInvalidConditionalNode` consume
|
|
@@ -80,9 +92,18 @@ export const LiquidHTMLSyntaxError: LiquidCheckDefinition = {
|
|
|
80
92
|
problems.push(conditionalNodeProblem);
|
|
81
93
|
}
|
|
82
94
|
|
|
95
|
+
// InvalidTagSyntax is a catch-all for known tags with unparseable markup.
|
|
96
|
+
// Only fire it if no more specific sub-check already reported on this tag.
|
|
97
|
+
if (problems.length === 0) {
|
|
98
|
+
const invalidSyntaxProblem = detectInvalidTagSyntax(node, tags);
|
|
99
|
+
if (invalidSyntaxProblem) {
|
|
100
|
+
problems.push(invalidSyntaxProblem);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
83
104
|
problems.forEach(context.report);
|
|
84
105
|
|
|
85
|
-
const filterProblems = await detectInvalidFilterName(node, await filtersPromise);
|
|
106
|
+
const filterProblems = await detectInvalidFilterName(node, (await filtersPromise) ?? []);
|
|
86
107
|
if (filterProblems.length > 0) {
|
|
87
108
|
filterProblems.forEach((filterProblem) => context.report(filterProblem));
|
|
88
109
|
}
|
|
@@ -108,7 +129,7 @@ export const LiquidHTMLSyntaxError: LiquidCheckDefinition = {
|
|
|
108
129
|
async LiquidVariableOutput(node, ancestors) {
|
|
109
130
|
if (isWithinRawTagThatDoesNotParseItsContents(ancestors)) return;
|
|
110
131
|
|
|
111
|
-
const filterProblems = await detectInvalidFilterName(node, await filtersPromise);
|
|
132
|
+
const filterProblems = await detectInvalidFilterName(node, (await filtersPromise) ?? []);
|
|
112
133
|
if (filterProblems.length > 0) {
|
|
113
134
|
filterProblems.forEach((problem) => context.report(problem));
|
|
114
135
|
}
|
|
@@ -4,36 +4,36 @@ import { MatchingTranslations } from '../../checks/matching-translations/index';
|
|
|
4
4
|
|
|
5
5
|
describe('Module: MatchingTranslations', async () => {
|
|
6
6
|
it('should report offenses when the translation file is missing a key', async () => {
|
|
7
|
-
const
|
|
7
|
+
const app = {
|
|
8
8
|
'app/translations/en.yml': 'en:\n hello: Hello\n world: World\n',
|
|
9
9
|
'app/translations/pt-BR.yml': 'pt-BR:\n hello: Olá\n',
|
|
10
10
|
};
|
|
11
11
|
|
|
12
|
-
const offenses = await check(
|
|
12
|
+
const offenses = await check(app, [MatchingTranslations]);
|
|
13
13
|
|
|
14
14
|
expect(offenses).to.be.of.length(1);
|
|
15
15
|
expect(offenses).to.containOffense("The translation for 'world' is missing");
|
|
16
16
|
});
|
|
17
17
|
|
|
18
18
|
it('should report offenses when the default translation is missing a key', async () => {
|
|
19
|
-
const
|
|
19
|
+
const app = {
|
|
20
20
|
'app/translations/en.yml': 'en:\n hello: Hello\n',
|
|
21
21
|
'app/translations/pt-BR.yml': 'pt-BR:\n hello: Olá\n world: Mundo\n',
|
|
22
22
|
};
|
|
23
23
|
|
|
24
|
-
const offenses = await check(
|
|
24
|
+
const offenses = await check(app, [MatchingTranslations]);
|
|
25
25
|
|
|
26
26
|
expect(offenses).to.be.of.length(1);
|
|
27
|
-
expect(offenses).to.containOffense("A
|
|
27
|
+
expect(offenses).to.containOffense("A translation for 'world' does not exist in the en locale");
|
|
28
28
|
});
|
|
29
29
|
|
|
30
30
|
it('should report offenses when nested translation keys do not exist', async () => {
|
|
31
|
-
const
|
|
31
|
+
const app = {
|
|
32
32
|
'app/translations/en.yml': 'en:\n hello:\n world: Hello, world!\n',
|
|
33
33
|
'app/translations/pt-BR.yml': 'pt-BR:\n hello: {}\n',
|
|
34
34
|
};
|
|
35
35
|
|
|
36
|
-
const offenses = await check(
|
|
36
|
+
const offenses = await check(app, [MatchingTranslations]);
|
|
37
37
|
|
|
38
38
|
expect(offenses).to.be.of.length(1);
|
|
39
39
|
expect(offenses).to.containOffense({
|
|
@@ -43,16 +43,16 @@ describe('Module: MatchingTranslations', async () => {
|
|
|
43
43
|
});
|
|
44
44
|
|
|
45
45
|
it('should report offenses when translation shapes do not match', async () => {
|
|
46
|
-
const
|
|
46
|
+
const app = {
|
|
47
47
|
'app/translations/en.yml': 'en:\n hello:\n world: Hello, world!\n',
|
|
48
48
|
'app/translations/pt-BR.yml': 'pt-BR:\n hello: Olá\n',
|
|
49
49
|
};
|
|
50
50
|
|
|
51
|
-
const offenses = await check(
|
|
51
|
+
const offenses = await check(app, [MatchingTranslations]);
|
|
52
52
|
|
|
53
53
|
expect(offenses).to.be.of.length(2);
|
|
54
54
|
expect(offenses).to.containOffense({
|
|
55
|
-
message: "A
|
|
55
|
+
message: "A translation for 'hello' does not exist in the en locale",
|
|
56
56
|
uri: `file:///app/translations/pt-BR.yml`,
|
|
57
57
|
});
|
|
58
58
|
expect(offenses).to.containOffense({
|
|
@@ -62,22 +62,22 @@ describe('Module: MatchingTranslations', async () => {
|
|
|
62
62
|
});
|
|
63
63
|
|
|
64
64
|
it('should report offenses when nested translation keys do not match', async () => {
|
|
65
|
-
const
|
|
65
|
+
const app = {
|
|
66
66
|
'app/translations/en.yml': 'en:\n hello:\n world: Hello, world!\n',
|
|
67
67
|
'app/translations/fr.yml': 'fr:\n hello:\n monde: Bonjour, monde\n',
|
|
68
68
|
'app/translations/es-ES.yml':
|
|
69
69
|
'es-ES:\n hello:\n world: Hello, world!\n mundo:\n hola: "¡Hola, mundo!"\n',
|
|
70
70
|
};
|
|
71
71
|
|
|
72
|
-
const offenses = await check(
|
|
72
|
+
const offenses = await check(app, [MatchingTranslations]);
|
|
73
73
|
|
|
74
74
|
expect(offenses).to.be.of.length(3);
|
|
75
75
|
expect(offenses).to.containOffense({
|
|
76
|
-
message: "A
|
|
76
|
+
message: "A translation for 'hello.monde' does not exist in the en locale",
|
|
77
77
|
uri: `file:///app/translations/fr.yml`,
|
|
78
78
|
});
|
|
79
79
|
expect(offenses).to.containOffense({
|
|
80
|
-
message: "A
|
|
80
|
+
message: "A translation for 'hello.mundo.hola' does not exist in the en locale",
|
|
81
81
|
uri: `file:///app/translations/es-ES.yml`,
|
|
82
82
|
});
|
|
83
83
|
expect(offenses).to.containOffense({
|
|
@@ -87,57 +87,147 @@ describe('Module: MatchingTranslations', async () => {
|
|
|
87
87
|
});
|
|
88
88
|
|
|
89
89
|
it('should not report offenses when default translations do not exist (no en.yml)', async () => {
|
|
90
|
-
const
|
|
90
|
+
const app = {
|
|
91
91
|
'app/translations/pt-BR.yml': 'pt-BR:\n hello: Olá\n',
|
|
92
92
|
};
|
|
93
93
|
|
|
94
|
-
const offenses = await check(
|
|
94
|
+
const offenses = await check(app, [MatchingTranslations]);
|
|
95
95
|
|
|
96
96
|
expect(offenses).to.be.of.length(0);
|
|
97
97
|
});
|
|
98
98
|
|
|
99
99
|
it('should not report offenses when translations match', async () => {
|
|
100
|
-
const
|
|
100
|
+
const app = {
|
|
101
101
|
'app/translations/en.yml': 'en:\n hello: Hello\n world: World\n',
|
|
102
102
|
'app/translations/pt-BR.yml': 'pt-BR:\n hello: Olá\n world: Mundo\n',
|
|
103
103
|
};
|
|
104
104
|
|
|
105
|
-
const offenses = await check(
|
|
105
|
+
const offenses = await check(app, [MatchingTranslations]);
|
|
106
106
|
|
|
107
107
|
expect(offenses).to.be.of.length(0);
|
|
108
108
|
});
|
|
109
109
|
|
|
110
110
|
it('should not report offenses when nested translations match', async () => {
|
|
111
|
-
const
|
|
111
|
+
const app = {
|
|
112
112
|
'app/translations/en.yml': 'en:\n hello:\n world: Hello, world!\n',
|
|
113
113
|
'app/translations/pt-BR.yml': 'pt-BR:\n hello:\n world: Olá, mundo!\n',
|
|
114
114
|
'app/translations/fr.yml': 'fr:\n hello:\n world: Bonjour, monde\n',
|
|
115
115
|
};
|
|
116
116
|
|
|
117
|
-
const offenses = await check(
|
|
117
|
+
const offenses = await check(app, [MatchingTranslations]);
|
|
118
118
|
|
|
119
119
|
expect(offenses).to.be.of.length(0);
|
|
120
120
|
});
|
|
121
121
|
|
|
122
122
|
it('should not report offenses and ignore pluralization', async () => {
|
|
123
|
-
const
|
|
123
|
+
const app = {
|
|
124
124
|
'app/translations/en.yml': 'en:\n hello:\n one: Hello, you\n other: "Hello, y\'all"\n',
|
|
125
125
|
'app/translations/pt-BR.yml':
|
|
126
126
|
'pt-BR:\n hello:\n zero: Estou sozinho :(\n few: "Olá, galerinha :)"\n',
|
|
127
127
|
};
|
|
128
128
|
|
|
129
|
-
const offenses = await check(
|
|
129
|
+
const offenses = await check(app, [MatchingTranslations]);
|
|
130
130
|
|
|
131
131
|
expect(offenses).to.be.of.length(0);
|
|
132
132
|
});
|
|
133
133
|
|
|
134
134
|
it('should not highlight anything if the file is unparseable', async () => {
|
|
135
|
-
const
|
|
135
|
+
const app = {
|
|
136
136
|
'app/translations/en.yml': 'en:\n hello:\n world: Hello, world!\n',
|
|
137
137
|
'app/translations/pt-BR.yml': 'pt-BR:\n hello: :\n bad yaml',
|
|
138
138
|
};
|
|
139
139
|
|
|
140
|
-
const offenses = await check(
|
|
140
|
+
const offenses = await check(app, [MatchingTranslations]);
|
|
141
|
+
expect(offenses).to.have.length(0);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// --- Multi-file / multi-scope tests ---
|
|
145
|
+
|
|
146
|
+
it('should not flag keys from a different translation scope (module vs app)', async () => {
|
|
147
|
+
// Module translations are auto-prefixed with their module name at runtime, so each
|
|
148
|
+
// module is its own isolated scope. The app scope should never need keys from
|
|
149
|
+
// modules/common-styling/public/translations/en.yml.
|
|
150
|
+
const app = {
|
|
151
|
+
'app/translations/en.yml': 'en:\n hello: Hello\n',
|
|
152
|
+
'app/translations/pt-BR.yml': 'pt-BR:\n hello: Olá\n',
|
|
153
|
+
'modules/common-styling/public/translations/en.yml':
|
|
154
|
+
'en:\n password:\n toggle_visibility: Toggle\n',
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const offenses = await check(app, [MatchingTranslations]);
|
|
158
|
+
expect(offenses).to.have.length(0);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('should report missing keys in a module non-en file against that module own en translations', async () => {
|
|
162
|
+
const app = {
|
|
163
|
+
'app/translations/en.yml': 'en:\n hello: Hello\n',
|
|
164
|
+
'modules/common-styling/public/translations/en.yml':
|
|
165
|
+
'en:\n password:\n toggle_visibility: Toggle\n',
|
|
166
|
+
'modules/common-styling/public/translations/pt-BR.yml': 'pt-BR:\n other: Outro\n',
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const offenses = await check(app, [MatchingTranslations]);
|
|
170
|
+
expect(offenses).to.have.length(1);
|
|
171
|
+
expect(offenses).to.containOffense({
|
|
172
|
+
message: "The translation for 'password.toggle_visibility' is missing",
|
|
173
|
+
uri: 'file:///modules/common-styling/public/translations/pt-BR.yml',
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('should skip files inside the en/ locale sub-directory (they are English source files)', async () => {
|
|
178
|
+
// Files like app/translations/en/validation.yml are English — the check must not
|
|
179
|
+
// lint them as if they were a "non-English" locale file to compare.
|
|
180
|
+
const app = {
|
|
181
|
+
'app/translations/en/validation.yml': 'en:\n required: Required\n',
|
|
182
|
+
'app/translations/pt-BR/validation.yml': 'pt-BR:\n required: Obrigatório\n',
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const offenses = await check(app, [MatchingTranslations]);
|
|
186
|
+
expect(offenses).to.have.length(0);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('should aggregate multiple en/*.yml files within one scope as the reference set', async () => {
|
|
190
|
+
// Within the app scope, en/auth.yml and en/checkout.yml both contribute to the
|
|
191
|
+
// reference; pt-BR.yml must cover all of them.
|
|
192
|
+
const app = {
|
|
193
|
+
'app/translations/en/auth.yml': 'en:\n login: Log in\n',
|
|
194
|
+
'app/translations/en/checkout.yml': 'en:\n submit: Submit\n',
|
|
195
|
+
'app/translations/pt-BR.yml': 'pt-BR:\n login: Entrar\n',
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const offenses = await check(app, [MatchingTranslations]);
|
|
199
|
+
expect(offenses).to.have.length(1);
|
|
200
|
+
expect(offenses).to.containOffense({
|
|
201
|
+
message: "The translation for 'submit' is missing",
|
|
202
|
+
uri: 'file:///app/translations/pt-BR.yml',
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('should aggregate en.yml and en/*.yml together as the scope reference set', async () => {
|
|
207
|
+
const app = {
|
|
208
|
+
'app/translations/en.yml': 'en:\n hello: Hello\n',
|
|
209
|
+
'app/translations/en/auth.yml': 'en:\n login: Log in\n',
|
|
210
|
+
'app/translations/pt-BR.yml': 'pt-BR:\n hello: Olá\n',
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const offenses = await check(app, [MatchingTranslations]);
|
|
214
|
+
expect(offenses).to.have.length(1);
|
|
215
|
+
expect(offenses).to.containOffense({
|
|
216
|
+
message: "The translation for 'login' is missing",
|
|
217
|
+
uri: 'file:///app/translations/pt-BR.yml',
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('should not report a key as missing if it is covered by another file in the same locale scope', async () => {
|
|
222
|
+
// pt-BR/validation.yml covers 'required' — pt-BR.yml should not be blamed for it
|
|
223
|
+
const app = {
|
|
224
|
+
'app/translations/en.yml': 'en:\n hello: Hello\n',
|
|
225
|
+
'app/translations/en/validation.yml': 'en:\n required: Required\n',
|
|
226
|
+
'app/translations/pt-BR.yml': 'pt-BR:\n hello: Olá\n',
|
|
227
|
+
'app/translations/pt-BR/validation.yml': 'pt-BR:\n required: Obrigatório\n',
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const offenses = await check(app, [MatchingTranslations]);
|
|
141
231
|
expect(offenses).to.have.length(0);
|
|
142
232
|
});
|
|
143
233
|
});
|
|
@@ -5,10 +5,39 @@ import {
|
|
|
5
5
|
Severity,
|
|
6
6
|
SourceCodeType,
|
|
7
7
|
PropertyNode,
|
|
8
|
+
ObjectNode,
|
|
8
9
|
} from '../../types';
|
|
9
10
|
|
|
10
11
|
const PLURALIZATION_KEYS = new Set(['zero', 'one', 'two', 'few', 'many', 'other']);
|
|
11
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Returns the locale declared in a YAML translation file by reading its first
|
|
15
|
+
* top-level key (e.g. `en`, `pt-BR`, `fr`). platformOS determines a file's
|
|
16
|
+
* locale from content, not from its path.
|
|
17
|
+
*/
|
|
18
|
+
function getLocaleFromAst(ast: JSONNode | Error): string | null {
|
|
19
|
+
if (ast instanceof Error) return null;
|
|
20
|
+
if (ast.type !== 'Object') return null;
|
|
21
|
+
const firstProp = (ast as ObjectNode).children[0];
|
|
22
|
+
if (!firstProp || firstProp.type !== 'Property') return null;
|
|
23
|
+
return firstProp.key.value || null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Extracts the translations base directory from a relative file path.
|
|
28
|
+
*
|
|
29
|
+
* e.g. `app/translations/pt-BR.yml` → `app/translations`
|
|
30
|
+
* `app/translations/pt-BR/validation.yml` → `app/translations`
|
|
31
|
+
* `modules/x/public/translations/en.yml` → `modules/x/public/translations`
|
|
32
|
+
*
|
|
33
|
+
* Returns `null` if the path doesn't contain a `/translations/` segment.
|
|
34
|
+
*/
|
|
35
|
+
function getTranslationRelativeBase(relativePath: string): string | null {
|
|
36
|
+
const idx = relativePath.lastIndexOf('/translations/');
|
|
37
|
+
if (idx === -1) return null;
|
|
38
|
+
return relativePath.substring(0, idx + '/translations'.length);
|
|
39
|
+
}
|
|
40
|
+
|
|
12
41
|
export const MatchingTranslations: YAMLCheckDefinition = {
|
|
13
42
|
meta: {
|
|
14
43
|
code: 'MatchingTranslations',
|
|
@@ -25,48 +54,63 @@ export const MatchingTranslations: YAMLCheckDefinition = {
|
|
|
25
54
|
},
|
|
26
55
|
|
|
27
56
|
create(context) {
|
|
28
|
-
// State
|
|
29
|
-
const
|
|
30
|
-
const
|
|
57
|
+
// ── State ──────────────────────────────────────────────────────────────
|
|
58
|
+
const enTranslations = new Set<string>(); // keys present in the en scope
|
|
59
|
+
const missingFromLocale = new Set<string>(); // en keys absent from the entire locale scope
|
|
31
60
|
const nodesByPath = new Map<string, PropertyNode>();
|
|
61
|
+
|
|
32
62
|
const file = context.file as YAMLSourceCode;
|
|
33
63
|
const fileUri = file.uri;
|
|
34
64
|
const relativePath = context.toRelativePath(fileUri);
|
|
35
65
|
const ast = file.ast;
|
|
66
|
+
|
|
67
|
+
// ── Guard: only lint translation files ────────────────────────────────
|
|
36
68
|
const isTranslationFile = relativePath.includes('/translations/');
|
|
37
|
-
// In platformOS, en.yml is the reference locale; skip running the check on it
|
|
38
|
-
const basename = fileUri.split('/').pop() ?? '';
|
|
39
|
-
const isDefaultTranslationsFile = basename.replace(/\.ya?ml$/, '') === 'en';
|
|
40
69
|
|
|
41
|
-
|
|
42
|
-
|
|
70
|
+
// The locale is always the first top-level key in the YAML file (e.g. `en`,
|
|
71
|
+
// `pt-BR`). platformOS resolves locale from content, not from the file path.
|
|
72
|
+
const locale = getLocaleFromAst(ast);
|
|
73
|
+
|
|
74
|
+
if (!isTranslationFile || !locale || locale === 'en' || ast instanceof Error) {
|
|
43
75
|
return {};
|
|
44
76
|
}
|
|
45
77
|
|
|
46
|
-
//
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
78
|
+
// ── Derive scope (translation base URI) ──────────────────────────────
|
|
79
|
+
const relativeBase = getTranslationRelativeBase(relativePath);
|
|
80
|
+
if (!relativeBase) return {};
|
|
81
|
+
|
|
82
|
+
const translationBaseUri = context.toUri(relativeBase);
|
|
50
83
|
|
|
51
|
-
|
|
52
|
-
|
|
84
|
+
// A "primary" locale file is the top-level `{locale}.yml` (not inside a
|
|
85
|
+
// locale sub-directory like `pt-BR/`). Only the primary file reports
|
|
86
|
+
// missing translations to avoid duplicate offenses across split files.
|
|
87
|
+
const pathAfterBase = relativePath.substring(relativeBase.length + 1);
|
|
88
|
+
const isPrimaryLocaleFile = !pathAfterBase.includes('/');
|
|
53
89
|
|
|
90
|
+
// ── Helpers ───────────────────────────────────────────────────────────
|
|
91
|
+
const isTerminalNode = ({ type }: JSONNode) => type === 'Literal';
|
|
92
|
+
const isPluralizationNode = (node: PropertyNode) => PLURALIZATION_KEYS.has(node.key.value);
|
|
54
93
|
const isPluralizationPath = (path: string) =>
|
|
55
94
|
[...PLURALIZATION_KEYS].some((key) => path.endsWith(key));
|
|
56
95
|
|
|
57
|
-
const
|
|
58
|
-
const
|
|
96
|
+
const countCommonParts = (a: string[], b: string[]): number => {
|
|
97
|
+
const min = Math.min(a.length, b.length);
|
|
98
|
+
for (let i = 0; i < min; i++) if (a[i] !== b[i]) return i;
|
|
99
|
+
return min;
|
|
100
|
+
};
|
|
59
101
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
102
|
+
const closestTranslationKey = (key: string) => {
|
|
103
|
+
const keyParts = key.split('.');
|
|
104
|
+
let closest = '';
|
|
105
|
+
let max = 0;
|
|
106
|
+
for (const path of nodesByPath.keys()) {
|
|
107
|
+
const common = countCommonParts(path.split('.'), keyParts);
|
|
108
|
+
if (common > max) {
|
|
109
|
+
max = common;
|
|
110
|
+
closest = path;
|
|
63
111
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const childPaths = jsonPaths(childJson);
|
|
67
|
-
|
|
68
|
-
return acc.concat(childPaths.map((path) => `${key}.${path}`));
|
|
69
|
-
}, []);
|
|
112
|
+
}
|
|
113
|
+
return nodesByPath.get(closest) ?? ast;
|
|
70
114
|
};
|
|
71
115
|
|
|
72
116
|
// Strip the locale prefix (first Property in the ancestors chain).
|
|
@@ -74,90 +118,67 @@ export const MatchingTranslations: YAMLCheckDefinition = {
|
|
|
74
118
|
// We want paths like 'hello', not 'en.hello'.
|
|
75
119
|
const objectPath = (nodes: JSONNode[]) => {
|
|
76
120
|
const props = nodes.filter((n): n is PropertyNode => n.type === 'Property');
|
|
77
|
-
if (props.length <= 1) return '';
|
|
121
|
+
if (props.length <= 1) return '';
|
|
78
122
|
return props
|
|
79
123
|
.slice(1)
|
|
80
124
|
.map((p) => p.key.value)
|
|
81
125
|
.join('.');
|
|
82
126
|
};
|
|
83
127
|
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
return i;
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
return minLength;
|
|
94
|
-
};
|
|
95
|
-
|
|
96
|
-
const closestTranslationKey = (translationKey: string) => {
|
|
97
|
-
const translationKeyParts = translationKey.split('.');
|
|
98
|
-
let closestMatch = '';
|
|
99
|
-
let maxCommonParts = 0;
|
|
100
|
-
|
|
101
|
-
for (const path of nodesByPath.keys()) {
|
|
102
|
-
const pathParts = path.split('.');
|
|
103
|
-
const commonParts = countCommonParts(pathParts, translationKeyParts);
|
|
104
|
-
|
|
105
|
-
if (commonParts > maxCommonParts) {
|
|
106
|
-
maxCommonParts = commonParts;
|
|
107
|
-
closestMatch = path;
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
return nodesByPath.get(closestMatch) ?? ast;
|
|
112
|
-
};
|
|
128
|
+
const jsonPaths = (json: any): string[] =>
|
|
129
|
+
Object.keys(json).reduce((acc: string[], key: string) => {
|
|
130
|
+
if (typeof json[key] !== 'object') return acc.concat(key);
|
|
131
|
+
return acc.concat(jsonPaths(json[key]).map((p) => `${key}.${p}`));
|
|
132
|
+
}, []);
|
|
113
133
|
|
|
114
134
|
return {
|
|
115
135
|
async onCodePathStart() {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
136
|
+
// Aggregate ALL en translations in this scope (en.yml + en/*.yml)
|
|
137
|
+
const en = await context.getTranslationsForBase(translationBaseUri, 'en');
|
|
138
|
+
jsonPaths(en).forEach(Set.prototype.add, enTranslations);
|
|
139
|
+
|
|
140
|
+
if (!isPrimaryLocaleFile) return;
|
|
141
|
+
|
|
142
|
+
// For the primary locale file: pre-compute which en keys are absent
|
|
143
|
+
// from the entire locale scope (locale.yml + locale/*.yml).
|
|
144
|
+
const localeAgg = await context.getTranslationsForBase(translationBaseUri, locale);
|
|
145
|
+
const localeKeys = new Set(jsonPaths(localeAgg));
|
|
146
|
+
for (const key of enTranslations) {
|
|
147
|
+
if (!localeKeys.has(key)) missingFromLocale.add(key);
|
|
148
|
+
}
|
|
122
149
|
},
|
|
123
150
|
|
|
124
151
|
async Property(node, ancestors) {
|
|
125
152
|
const path = objectPath(ancestors.concat(node));
|
|
126
|
-
|
|
127
|
-
if (!path) return; // skip the root locale key (e.g. 'pt-BR')
|
|
153
|
+
if (!path) return;
|
|
128
154
|
|
|
129
155
|
nodesByPath.set(path, node);
|
|
130
156
|
|
|
131
|
-
if (!hasDefaultTranslations()) return;
|
|
132
157
|
if (isPluralizationNode(node)) return;
|
|
133
158
|
if (!isTerminalNode(node.value)) return;
|
|
159
|
+
if (!enTranslations.size) return; // no en reference — skip
|
|
134
160
|
|
|
135
|
-
if (
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
161
|
+
if (!enTranslations.has(path)) {
|
|
162
|
+
context.report({
|
|
163
|
+
message: `A translation for '${path}' does not exist in the en locale`,
|
|
164
|
+
startIndex: node.loc!.start.offset,
|
|
165
|
+
endIndex: node.loc!.end.offset,
|
|
166
|
+
});
|
|
140
167
|
}
|
|
141
|
-
|
|
142
|
-
context.report({
|
|
143
|
-
message: `A default translation for '${path}' does not exist`,
|
|
144
|
-
startIndex: node.loc!.start.offset,
|
|
145
|
-
endIndex: node.loc!.end.offset,
|
|
146
|
-
});
|
|
147
168
|
},
|
|
148
169
|
|
|
149
170
|
async onCodePathEnd() {
|
|
150
|
-
|
|
151
|
-
const closest = closestTranslationKey(path);
|
|
152
|
-
|
|
153
|
-
if (isPluralizationPath(path)) return;
|
|
171
|
+
if (!isPrimaryLocaleFile) return;
|
|
154
172
|
|
|
173
|
+
for (const key of missingFromLocale) {
|
|
174
|
+
if (isPluralizationPath(key)) continue;
|
|
175
|
+
const closest = closestTranslationKey(key);
|
|
155
176
|
context.report({
|
|
156
|
-
message: `The translation for '${
|
|
177
|
+
message: `The translation for '${key}' is missing`,
|
|
157
178
|
startIndex: closest.loc!.start.offset,
|
|
158
179
|
endIndex: closest.loc!.end.offset,
|
|
159
180
|
});
|
|
160
|
-
}
|
|
181
|
+
}
|
|
161
182
|
},
|
|
162
183
|
};
|
|
163
184
|
},
|
|
@@ -97,7 +97,7 @@ export const MetadataParamsCheck: LiquidCheckDefinition = {
|
|
|
97
97
|
|
|
98
98
|
return {
|
|
99
99
|
async RenderMarkup(node) {
|
|
100
|
-
const targetFile = 'value' in node.
|
|
100
|
+
const targetFile = 'value' in node.partial ? node.partial.value : node.partial.name;
|
|
101
101
|
if (!targetFile) {
|
|
102
102
|
return;
|
|
103
103
|
}
|
|
@@ -27,20 +27,20 @@ export const MissingPartial: LiquidCheckDefinition<typeof schema> = {
|
|
|
27
27
|
|
|
28
28
|
return {
|
|
29
29
|
async RenderMarkup(node) {
|
|
30
|
-
if (node.
|
|
30
|
+
if (node.partial.type === NodeTypes.VariableLookup) return;
|
|
31
31
|
|
|
32
|
-
const
|
|
32
|
+
const partial = node.partial;
|
|
33
33
|
const location = await locator.locate(
|
|
34
34
|
URI.parse(context.config.rootUri),
|
|
35
35
|
'render',
|
|
36
|
-
|
|
36
|
+
partial.value,
|
|
37
37
|
);
|
|
38
38
|
|
|
39
39
|
if (!location) {
|
|
40
40
|
context.report({
|
|
41
|
-
message: `'${
|
|
42
|
-
startIndex: node.
|
|
43
|
-
endIndex: node.
|
|
41
|
+
message: `'${partial.value}' does not exist`,
|
|
42
|
+
startIndex: node.partial.position.start,
|
|
43
|
+
endIndex: node.partial.position.end,
|
|
44
44
|
});
|
|
45
45
|
}
|
|
46
46
|
},
|
|
@@ -242,7 +242,7 @@ describe('Module: UndefinedObject', () => {
|
|
|
242
242
|
}
|
|
243
243
|
});
|
|
244
244
|
|
|
245
|
-
it('should report an offense when object is undefined in a "
|
|
245
|
+
it('should report an offense when object is undefined in a "partial" file with doc tags that are missing the associated param', async () => {
|
|
246
246
|
const sourceCode = `
|
|
247
247
|
{% doc %}
|
|
248
248
|
{% enddoc %}
|
|
@@ -259,7 +259,7 @@ describe('Module: UndefinedObject', () => {
|
|
|
259
259
|
expect(offenses.map((e) => e.message)).toEqual(["Unknown object 'my_var' used."]);
|
|
260
260
|
});
|
|
261
261
|
|
|
262
|
-
it('should not report an offense when object is defined with @param in a
|
|
262
|
+
it('should not report an offense when object is defined with @param in a partial file', async () => {
|
|
263
263
|
const sourceCode = `
|
|
264
264
|
{% doc %}
|
|
265
265
|
@param {string} text
|
|
@@ -410,4 +410,31 @@ describe('Module: UndefinedObject', () => {
|
|
|
410
410
|
expect(offenses).toHaveLength(1);
|
|
411
411
|
expect(offenses.map((e) => e.message)).toEqual(["Unknown object 'job_id' used."]);
|
|
412
412
|
});
|
|
413
|
+
|
|
414
|
+
it('should not report an offense when object is defined with a parse_json tag', async () => {
|
|
415
|
+
const sourceCode = `
|
|
416
|
+
{% parse_json groups_data %}
|
|
417
|
+
{ "hello": "world" }
|
|
418
|
+
{% endparse_json %}
|
|
419
|
+
{{ groups_data }}
|
|
420
|
+
`;
|
|
421
|
+
|
|
422
|
+
const offenses = await runLiquidCheck(UndefinedObject, sourceCode);
|
|
423
|
+
|
|
424
|
+
expect(offenses).toHaveLength(0);
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it('should report an offense when parse_json variable is used before the tag', async () => {
|
|
428
|
+
const sourceCode = `
|
|
429
|
+
{{ groups_data }}
|
|
430
|
+
{% parse_json groups_data %}
|
|
431
|
+
{ "hello": "world" }
|
|
432
|
+
{% endparse_json %}
|
|
433
|
+
`;
|
|
434
|
+
|
|
435
|
+
const offenses = await runLiquidCheck(UndefinedObject, sourceCode);
|
|
436
|
+
|
|
437
|
+
expect(offenses).toHaveLength(1);
|
|
438
|
+
expect(offenses[0].message).toBe("Unknown object 'groups_data' used.");
|
|
439
|
+
});
|
|
413
440
|
});
|