@platformos/platformos-check-common 0.0.12 → 0.0.13
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 +31 -0
- package/dist/checks/circular-render/index.d.ts +2 -0
- package/dist/checks/circular-render/index.js +164 -0
- package/dist/checks/circular-render/index.js.map +1 -0
- package/dist/checks/index.d.ts +1 -1
- package/dist/checks/index.js +6 -0
- package/dist/checks/index.js.map +1 -1
- package/dist/checks/missing-page/index.d.ts +2 -0
- package/dist/checks/missing-page/index.js +73 -0
- package/dist/checks/missing-page/index.js.map +1 -0
- package/dist/checks/missing-partial/index.js +31 -31
- package/dist/checks/missing-partial/index.js.map +1 -1
- package/dist/checks/missing-render-partial-arguments/index.d.ts +2 -0
- package/dist/checks/missing-render-partial-arguments/index.js +37 -0
- package/dist/checks/missing-render-partial-arguments/index.js.map +1 -0
- package/dist/checks/nested-graphql-query/index.d.ts +2 -0
- package/dist/checks/nested-graphql-query/index.js +146 -0
- package/dist/checks/nested-graphql-query/index.js.map +1 -0
- package/dist/checks/translation-key-exists/index.js +16 -19
- package/dist/checks/translation-key-exists/index.js.map +1 -1
- package/dist/checks/translation-utils.d.ts +20 -0
- package/dist/checks/translation-utils.js +51 -0
- package/dist/checks/translation-utils.js.map +1 -0
- package/dist/checks/undefined-object/index.js +21 -0
- package/dist/checks/undefined-object/index.js.map +1 -1
- package/dist/checks/unused-translation-key/index.d.ts +4 -0
- package/dist/checks/unused-translation-key/index.js +85 -0
- package/dist/checks/unused-translation-key/index.js.map +1 -0
- package/dist/checks/valid-render-partial-argument-types/index.js +2 -1
- package/dist/checks/valid-render-partial-argument-types/index.js.map +1 -1
- package/dist/context-utils.d.ts +2 -1
- package/dist/context-utils.js +31 -1
- package/dist/context-utils.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/liquid-doc/arguments.js +4 -0
- package/dist/liquid-doc/arguments.js.map +1 -1
- package/dist/liquid-doc/utils.d.ts +10 -2
- package/dist/liquid-doc/utils.js +26 -1
- package/dist/liquid-doc/utils.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/types.d.ts +8 -1
- package/dist/types.js.map +1 -1
- package/dist/url-helpers.d.ts +55 -0
- package/dist/url-helpers.js +334 -0
- package/dist/url-helpers.js.map +1 -0
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.js +1 -0
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/levenshtein.d.ts +3 -0
- package/dist/utils/levenshtein.js +39 -0
- package/dist/utils/levenshtein.js.map +1 -0
- package/package.json +2 -2
- package/src/checks/graphql/index.spec.ts +2 -2
- package/src/checks/index.ts +6 -0
- package/src/checks/missing-page/index.spec.ts +755 -0
- package/src/checks/missing-page/index.ts +89 -0
- package/src/checks/missing-partial/index.spec.ts +361 -0
- package/src/checks/missing-partial/index.ts +39 -47
- package/src/checks/missing-render-partial-arguments/index.spec.ts +74 -0
- package/src/checks/missing-render-partial-arguments/index.ts +44 -0
- package/src/checks/nested-graphql-query/index.spec.ts +175 -0
- package/src/checks/nested-graphql-query/index.ts +203 -0
- package/src/checks/parser-blocking-script/index.spec.ts +7 -3
- package/src/checks/translation-key-exists/index.spec.ts +79 -2
- package/src/checks/translation-key-exists/index.ts +18 -27
- package/src/checks/translation-utils.ts +63 -0
- package/src/checks/undefined-object/index.spec.ts +30 -0
- package/src/checks/undefined-object/index.ts +27 -1
- package/src/checks/unused-assign/index.spec.ts +1 -1
- package/src/checks/unused-doc-param/index.spec.ts +4 -2
- package/src/checks/valid-doc-param-types/index.spec.ts +1 -1
- package/src/checks/valid-render-partial-argument-types/index.spec.ts +24 -1
- package/src/checks/valid-render-partial-argument-types/index.ts +3 -2
- package/src/checks/variable-name/index.spec.ts +1 -1
- package/src/context-utils.ts +33 -1
- package/src/index.ts +3 -0
- package/src/liquid-doc/arguments.ts +6 -0
- package/src/liquid-doc/utils.ts +26 -2
- package/src/types.ts +9 -1
- package/src/url-helpers.spec.ts +241 -0
- package/src/url-helpers.ts +363 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/levenshtein.ts +41 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { HtmlElement, LiquidTag } from '@platformos/liquid-html-parser';
|
|
2
|
+
import { RouteTable } from '@platformos/platformos-common';
|
|
3
|
+
import {
|
|
4
|
+
shouldSkipUrl,
|
|
5
|
+
isValuedAttrNode,
|
|
6
|
+
getAttrName,
|
|
7
|
+
extractUrlPattern,
|
|
8
|
+
getEffectiveMethod,
|
|
9
|
+
tryExtractAssignUrl,
|
|
10
|
+
} from '../../url-helpers';
|
|
11
|
+
import { LiquidCheckDefinition, Severity, SourceCodeType } from '../../types';
|
|
12
|
+
import { isHtmlTag } from '../utils';
|
|
13
|
+
|
|
14
|
+
export const MissingPage: LiquidCheckDefinition = {
|
|
15
|
+
meta: {
|
|
16
|
+
code: 'MissingPage',
|
|
17
|
+
name: 'Missing page for route',
|
|
18
|
+
docs: {
|
|
19
|
+
description:
|
|
20
|
+
'Reports links and form actions that point to routes with no corresponding platformOS page.',
|
|
21
|
+
recommended: true,
|
|
22
|
+
url: 'https://documentation.platformos.com/developer-guide/platformos-check/checks/missing-page',
|
|
23
|
+
},
|
|
24
|
+
type: SourceCodeType.LiquidHtml,
|
|
25
|
+
severity: Severity.WARNING,
|
|
26
|
+
schema: {},
|
|
27
|
+
targets: [],
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
create(context) {
|
|
31
|
+
let routeTable: RouteTable;
|
|
32
|
+
// Tracks {% assign %} variable mappings incrementally in document order.
|
|
33
|
+
// This means each <a> / <form> sees only the assigns that precede it,
|
|
34
|
+
// and reassignments correctly shadow earlier values at their position.
|
|
35
|
+
const variableMap = new Map<string, string>();
|
|
36
|
+
|
|
37
|
+
function checkUrlAttribute(attr: Parameters<typeof extractUrlPattern>[0], method: string) {
|
|
38
|
+
const urlPattern = extractUrlPattern(attr, variableMap);
|
|
39
|
+
if (urlPattern === null) return;
|
|
40
|
+
if (shouldSkipUrl(urlPattern)) return;
|
|
41
|
+
|
|
42
|
+
if (!routeTable.hasMatch(urlPattern, method)) {
|
|
43
|
+
const methodLabel = method.toUpperCase();
|
|
44
|
+
context.report({
|
|
45
|
+
message: `No page found for route '${urlPattern}' (${methodLabel})`,
|
|
46
|
+
startIndex: attr.value[0].position.start,
|
|
47
|
+
endIndex: attr.value[attr.value.length - 1].position.end,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
async onCodePathStart() {
|
|
54
|
+
// Front-load the route table build so individual HtmlElement visits don't wait.
|
|
55
|
+
routeTable = await context.getRouteTable();
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
async LiquidTag(node: LiquidTag) {
|
|
59
|
+
const extracted = tryExtractAssignUrl(node);
|
|
60
|
+
if (extracted) {
|
|
61
|
+
variableMap.set(extracted.name, extracted.urlPattern);
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
async HtmlElement(node) {
|
|
66
|
+
if (isHtmlTag(node, 'a')) {
|
|
67
|
+
const hrefAttr = node.attributes.find(
|
|
68
|
+
(a) => isValuedAttrNode(a) && getAttrName(a) === 'href',
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
if (hrefAttr && isValuedAttrNode(hrefAttr)) {
|
|
72
|
+
checkUrlAttribute(hrefAttr, 'get');
|
|
73
|
+
}
|
|
74
|
+
} else if (isHtmlTag(node, 'form')) {
|
|
75
|
+
const actionAttr = node.attributes.find(
|
|
76
|
+
(a) => isValuedAttrNode(a) && getAttrName(a) === 'action',
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
if (actionAttr && isValuedAttrNode(actionAttr)) {
|
|
80
|
+
const method = getEffectiveMethod(node as HtmlElement);
|
|
81
|
+
if (method !== null) {
|
|
82
|
+
checkUrlAttribute(actionAttr, method);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
},
|
|
89
|
+
};
|
|
@@ -47,6 +47,19 @@ describe('Module: MissingPartial', () => {
|
|
|
47
47
|
'app/views/partials/partial.liquid': file,
|
|
48
48
|
}),
|
|
49
49
|
},
|
|
50
|
+
{
|
|
51
|
+
testCase: 'should report the missing partial to be rendered with "theme_render_rc"',
|
|
52
|
+
file: "{% theme_render_rc 'missing' %}",
|
|
53
|
+
expected: {
|
|
54
|
+
message: "'missing' does not exist",
|
|
55
|
+
uri: 'file:///app/views/partials/partial.liquid',
|
|
56
|
+
start: { index: 19, line: 0, character: 19 },
|
|
57
|
+
end: { index: 28, line: 0, character: 28 },
|
|
58
|
+
},
|
|
59
|
+
filesWith: (file: string) => ({
|
|
60
|
+
'app/views/partials/partial.liquid': file,
|
|
61
|
+
}),
|
|
62
|
+
},
|
|
50
63
|
];
|
|
51
64
|
for (const { testCase, file, expected, filesWith } of testCases) {
|
|
52
65
|
const offenses = await check(filesWith(file), [MissingPartial]);
|
|
@@ -58,4 +71,352 @@ describe('Module: MissingPartial', () => {
|
|
|
58
71
|
});
|
|
59
72
|
}
|
|
60
73
|
});
|
|
74
|
+
|
|
75
|
+
it('should not report when the partial exists for theme_render_rc', async () => {
|
|
76
|
+
const offenses = await check(
|
|
77
|
+
{
|
|
78
|
+
'app/views/partials/partial.liquid':
|
|
79
|
+
"{% theme_render_rc 'my_product', class: 'featured' %}",
|
|
80
|
+
'app/views/partials/my_product.liquid': '<div>Product</div>',
|
|
81
|
+
},
|
|
82
|
+
[MissingPartial],
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
expect(offenses).to.have.length(0);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should not report theme_render_rc with variable lookup', async () => {
|
|
89
|
+
const offenses = await check(
|
|
90
|
+
{
|
|
91
|
+
'app/views/partials/partial.liquid':
|
|
92
|
+
"{% theme_render_rc 'existing' for products as product %}",
|
|
93
|
+
'app/views/partials/existing.liquid': '{{ product }}',
|
|
94
|
+
},
|
|
95
|
+
[MissingPartial],
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
expect(offenses).to.have.length(0);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('theme_render_rc with theme_search_paths', () => {
|
|
102
|
+
it('should find partial via first search path (highest priority)', async () => {
|
|
103
|
+
const offenses = await check(
|
|
104
|
+
{
|
|
105
|
+
'app/config.yml': 'theme_search_paths:\n - theme/dress\n - theme/simple',
|
|
106
|
+
'app/views/partials/page.liquid': "{% theme_render_rc 'another/super/partial' %}",
|
|
107
|
+
'app/views/partials/theme/dress/another/super/partial.liquid': 'dress partial',
|
|
108
|
+
'app/views/partials/theme/simple/another/super/partial.liquid': 'simple partial',
|
|
109
|
+
'app/views/partials/another/super/partial.liquid': 'default partial',
|
|
110
|
+
},
|
|
111
|
+
[MissingPartial],
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
expect(offenses).to.have.length(0);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('should find partial via second search path when first does not have it', async () => {
|
|
118
|
+
const offenses = await check(
|
|
119
|
+
{
|
|
120
|
+
'app/config.yml': 'theme_search_paths:\n - theme/dress\n - theme/simple',
|
|
121
|
+
'app/views/partials/page.liquid': "{% theme_render_rc 'my/partial' %}",
|
|
122
|
+
'app/views/partials/theme/simple/my/partial.liquid': 'simple partial',
|
|
123
|
+
},
|
|
124
|
+
[MissingPartial],
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
expect(offenses).to.have.length(0);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('should fallback to default path when no search path matches', async () => {
|
|
131
|
+
const offenses = await check(
|
|
132
|
+
{
|
|
133
|
+
'app/config.yml': 'theme_search_paths:\n - theme/dress\n - theme/simple',
|
|
134
|
+
'app/views/partials/page.liquid': "{% theme_render_rc 'default' %}",
|
|
135
|
+
'app/views/partials/default.liquid': 'default partial',
|
|
136
|
+
},
|
|
137
|
+
[MissingPartial],
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
expect(offenses).to.have.length(0);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('should report missing when partial is not found in any search path or fallback', async () => {
|
|
144
|
+
const offenses = await check(
|
|
145
|
+
{
|
|
146
|
+
'app/config.yml': 'theme_search_paths:\n - theme/dress\n - theme/simple',
|
|
147
|
+
'app/views/partials/page.liquid': "{% theme_render_rc 'my/missing' %}",
|
|
148
|
+
},
|
|
149
|
+
[MissingPartial],
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
expect(offenses).to.have.length(1);
|
|
153
|
+
expect(offenses).to.containOffense({
|
|
154
|
+
check: 'MissingPartial',
|
|
155
|
+
message: "'my/missing' does not exist",
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('should respect empty string in search paths as default path position', async () => {
|
|
160
|
+
// With ['theme/dress', '', 'theme/simple'], the empty string means "default path"
|
|
161
|
+
// appears between dress and simple in priority order.
|
|
162
|
+
// So if dress doesn't have it but default path does, use default (skip simple).
|
|
163
|
+
const offenses = await check(
|
|
164
|
+
{
|
|
165
|
+
'app/config.yml': "theme_search_paths:\n - theme/dress\n - ''\n - theme/simple",
|
|
166
|
+
'app/views/partials/page.liquid': "{% theme_render_rc 'my/partial' %}",
|
|
167
|
+
'app/views/partials/my/partial.liquid': 'default partial',
|
|
168
|
+
'app/views/partials/theme/simple/my/partial.liquid': 'simple partial',
|
|
169
|
+
},
|
|
170
|
+
[MissingPartial],
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
expect(offenses).to.have.length(0);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('should expand Liquid expressions as wildcards and find matching partial', async () => {
|
|
177
|
+
// {{ context.constants.MY_THEME }} acts as a wildcard matching any subdirectory.
|
|
178
|
+
// With theme/custom_theme/my/partial.liquid existing, the wildcard should match it.
|
|
179
|
+
const offenses = await check(
|
|
180
|
+
{
|
|
181
|
+
'app/config.yml':
|
|
182
|
+
'theme_search_paths:\n - theme/{{ context.constants.MY_THEME }}\n - theme/simple',
|
|
183
|
+
'app/views/partials/page.liquid': "{% theme_render_rc 'my/partial' %}",
|
|
184
|
+
'app/views/partials/theme/custom_theme/my/partial.liquid': 'custom theme partial',
|
|
185
|
+
},
|
|
186
|
+
[MissingPartial],
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
expect(offenses).to.have.length(0);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('should find partial via static path when dynamic path has no match', async () => {
|
|
193
|
+
const offenses = await check(
|
|
194
|
+
{
|
|
195
|
+
'app/config.yml':
|
|
196
|
+
'theme_search_paths:\n - theme/{{ context.constants.MY_THEME }}\n - theme/simple',
|
|
197
|
+
'app/views/partials/page.liquid': "{% theme_render_rc 'product' %}",
|
|
198
|
+
'app/views/partials/theme/simple/product.liquid': 'simple partial',
|
|
199
|
+
},
|
|
200
|
+
[MissingPartial],
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
expect(offenses).to.have.length(0);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('should report missing when wildcard expansion finds no matching partial', async () => {
|
|
207
|
+
// Dynamic path expands but the partial doesn't exist in any expanded directory
|
|
208
|
+
const offenses = await check(
|
|
209
|
+
{
|
|
210
|
+
'app/config.yml': 'theme_search_paths:\n - theme/{{ context.constants.MY_THEME }}',
|
|
211
|
+
'app/views/partials/page.liquid': "{% theme_render_rc 'missing' %}",
|
|
212
|
+
'app/views/partials/theme/custom/other.liquid': 'wrong partial',
|
|
213
|
+
},
|
|
214
|
+
[MissingPartial],
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
expect(offenses).to.have.length(1);
|
|
218
|
+
expect(offenses).to.containOffense({
|
|
219
|
+
check: 'MissingPartial',
|
|
220
|
+
message: "'missing' does not exist",
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('should handle multiple Liquid expressions in a path', async () => {
|
|
225
|
+
// E.g. "{{ context.constants.BRAND }}/{{ context.constants.TIER }}" - both segments are wildcards
|
|
226
|
+
const offenses = await check(
|
|
227
|
+
{
|
|
228
|
+
'app/config.yml':
|
|
229
|
+
'theme_search_paths:\n - "{{ context.constants.BRAND }}/{{ context.constants.TIER }}"',
|
|
230
|
+
'app/views/partials/page.liquid': "{% theme_render_rc 'card' %}",
|
|
231
|
+
'app/views/partials/acme/premium/card.liquid': 'acme premium card',
|
|
232
|
+
},
|
|
233
|
+
[MissingPartial],
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
expect(offenses).to.have.length(0);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('should render partial which includes path in its name', async () => {
|
|
240
|
+
// When the partial name itself includes the search path prefix
|
|
241
|
+
const offenses = await check(
|
|
242
|
+
{
|
|
243
|
+
'app/config.yml': 'theme_search_paths:\n - theme/simple',
|
|
244
|
+
'app/views/partials/page.liquid': "{% theme_render_rc 'theme/simple/my/partial' %}",
|
|
245
|
+
'app/views/partials/theme/simple/my/partial.liquid': 'simple partial',
|
|
246
|
+
},
|
|
247
|
+
[MissingPartial],
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
expect(offenses).to.have.length(0);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('should work with nested partial directories', async () => {
|
|
254
|
+
const offenses = await check(
|
|
255
|
+
{
|
|
256
|
+
'app/config.yml': 'theme_search_paths:\n - theme/dress\n - theme/simple',
|
|
257
|
+
'app/views/partials/page.liquid': "{% theme_render_rc 'components/card' %}",
|
|
258
|
+
'app/views/partials/theme/dress/components/card.liquid': 'dress card',
|
|
259
|
+
},
|
|
260
|
+
[MissingPartial],
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
expect(offenses).to.have.length(0);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('should not affect regular render tags even when config exists', async () => {
|
|
267
|
+
// 'card' exists under the search path prefix, so theme_render_rc would find it,
|
|
268
|
+
// but render ignores search paths entirely and looks only in the default locations.
|
|
269
|
+
const offenses = await check(
|
|
270
|
+
{
|
|
271
|
+
'app/config.yml': 'theme_search_paths:\n - theme/dress\n - theme/simple',
|
|
272
|
+
'app/views/partials/page.liquid': "{% render 'card' %}",
|
|
273
|
+
'app/views/partials/theme/simple/card.liquid': 'simple card',
|
|
274
|
+
},
|
|
275
|
+
[MissingPartial],
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
expect(offenses).to.have.length(1);
|
|
279
|
+
expect(offenses).to.containOffense({
|
|
280
|
+
check: 'MissingPartial',
|
|
281
|
+
message: "'card' does not exist",
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('should also search in app/lib with search paths', async () => {
|
|
286
|
+
const offenses = await check(
|
|
287
|
+
{
|
|
288
|
+
'app/config.yml': 'theme_search_paths:\n - theme/dress',
|
|
289
|
+
'app/views/partials/page.liquid': "{% theme_render_rc 'my_helper' %}",
|
|
290
|
+
'app/lib/theme/dress/my_helper.liquid': 'helper from lib',
|
|
291
|
+
},
|
|
292
|
+
[MissingPartial],
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
expect(offenses).to.have.length(0);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('should fallback to standard render resolution when no config.yml exists', async () => {
|
|
299
|
+
const offenses = await check(
|
|
300
|
+
{
|
|
301
|
+
'app/views/partials/page.liquid': "{% theme_render_rc 'existing' %}",
|
|
302
|
+
'app/views/partials/existing.liquid': 'found it',
|
|
303
|
+
},
|
|
304
|
+
[MissingPartial],
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
expect(offenses).to.have.length(0);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('should handle theme_render_rc with named arguments and search paths', async () => {
|
|
311
|
+
const offenses = await check(
|
|
312
|
+
{
|
|
313
|
+
'app/config.yml': 'theme_search_paths:\n - theme/dress',
|
|
314
|
+
'app/views/partials/page.liquid':
|
|
315
|
+
"{% theme_render_rc 'product', class: 'featured', size: 'large' %}",
|
|
316
|
+
'app/views/partials/theme/dress/product.liquid': '{{ class }} {{ size }}',
|
|
317
|
+
},
|
|
318
|
+
[MissingPartial],
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
expect(offenses).to.have.length(0);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it('should handle theme_render_rc with for/with and search paths', async () => {
|
|
325
|
+
const offenses = await check(
|
|
326
|
+
{
|
|
327
|
+
'app/config.yml': 'theme_search_paths:\n - theme/dress',
|
|
328
|
+
'app/views/partials/page.liquid': "{% theme_render_rc 'item' for products as product %}",
|
|
329
|
+
'app/views/partials/theme/dress/item.liquid': '{{ product }}',
|
|
330
|
+
},
|
|
331
|
+
[MissingPartial],
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
expect(offenses).to.have.length(0);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('should treat empty theme_search_paths array same as absent', async () => {
|
|
338
|
+
const offenses = await check(
|
|
339
|
+
{
|
|
340
|
+
'app/config.yml': 'theme_search_paths: []',
|
|
341
|
+
'app/views/partials/page.liquid': "{% theme_render_rc 'existing' %}",
|
|
342
|
+
'app/views/partials/existing.liquid': 'found it',
|
|
343
|
+
},
|
|
344
|
+
[MissingPartial],
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
expect(offenses).to.have.length(0);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it('should handle malformed theme_search_paths (not an array)', async () => {
|
|
351
|
+
const offenses = await check(
|
|
352
|
+
{
|
|
353
|
+
'app/config.yml': 'theme_search_paths: some_string',
|
|
354
|
+
'app/views/partials/page.liquid': "{% theme_render_rc 'existing' %}",
|
|
355
|
+
'app/views/partials/existing.liquid': 'found it',
|
|
356
|
+
},
|
|
357
|
+
[MissingPartial],
|
|
358
|
+
);
|
|
359
|
+
|
|
360
|
+
expect(offenses).to.have.length(0);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it('should handle multiple theme_render_rc tags in one file', async () => {
|
|
364
|
+
const offenses = await check(
|
|
365
|
+
{
|
|
366
|
+
'app/config.yml': 'theme_search_paths:\n - theme/dress\n - theme/simple',
|
|
367
|
+
'app/views/partials/page.liquid':
|
|
368
|
+
"{% theme_render_rc 'header' %} {% theme_render_rc 'footer' %} {% theme_render_rc 'missing' %}",
|
|
369
|
+
'app/views/partials/theme/dress/header.liquid': 'header',
|
|
370
|
+
'app/views/partials/theme/simple/footer.liquid': 'footer',
|
|
371
|
+
},
|
|
372
|
+
[MissingPartial],
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
expect(offenses).to.have.length(1);
|
|
376
|
+
expect(offenses).to.containOffense({
|
|
377
|
+
check: 'MissingPartial',
|
|
378
|
+
message: "'missing' does not exist",
|
|
379
|
+
});
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it('should handle theme_render_rc inside {% liquid %} block', async () => {
|
|
383
|
+
const offenses = await check(
|
|
384
|
+
{
|
|
385
|
+
'app/config.yml': 'theme_search_paths:\n - theme/dress',
|
|
386
|
+
'app/views/partials/page.liquid': "{% liquid\n theme_render_rc 'card'\n%}",
|
|
387
|
+
'app/views/partials/theme/dress/card.liquid': 'card',
|
|
388
|
+
},
|
|
389
|
+
[MissingPartial],
|
|
390
|
+
);
|
|
391
|
+
|
|
392
|
+
expect(offenses).to.have.length(0);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it('should resolve module-prefixed partials via fallback', async () => {
|
|
396
|
+
const offenses = await check(
|
|
397
|
+
{
|
|
398
|
+
'app/config.yml': 'theme_search_paths:\n - theme/dress',
|
|
399
|
+
'app/views/partials/page.liquid': "{% theme_render_rc 'modules/shop/card' %}",
|
|
400
|
+
'modules/shop/public/views/partials/card.liquid': 'module card',
|
|
401
|
+
},
|
|
402
|
+
[MissingPartial],
|
|
403
|
+
);
|
|
404
|
+
|
|
405
|
+
expect(offenses).to.have.length(0);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it('should handle non-string entries in theme_search_paths gracefully', async () => {
|
|
409
|
+
const offenses = await check(
|
|
410
|
+
{
|
|
411
|
+
'app/config.yml': 'theme_search_paths:\n - 123\n - true',
|
|
412
|
+
'app/views/partials/page.liquid': "{% theme_render_rc 'card' %}",
|
|
413
|
+
'app/views/partials/card.liquid': 'default card',
|
|
414
|
+
},
|
|
415
|
+
[MissingPartial],
|
|
416
|
+
);
|
|
417
|
+
|
|
418
|
+
// 123/card and true/card won't exist, but fallback to unprefixed finds it
|
|
419
|
+
expect(offenses).to.have.length(0);
|
|
420
|
+
});
|
|
421
|
+
});
|
|
61
422
|
});
|
|
@@ -1,8 +1,16 @@
|
|
|
1
|
-
import { NodeTypes } from '@platformos/liquid-html-parser';
|
|
1
|
+
import { LiquidHtmlNode, NamedTags, NodeTypes } from '@platformos/liquid-html-parser';
|
|
2
2
|
import { LiquidCheckDefinition, SchemaProp, Severity, SourceCodeType } from '../../types';
|
|
3
|
-
import { DocumentsLocator } from '@platformos/platformos-common';
|
|
3
|
+
import { DocumentsLocator, DocumentType, loadSearchPaths } from '@platformos/platformos-common';
|
|
4
4
|
import { URI } from 'vscode-uri';
|
|
5
5
|
|
|
6
|
+
function getTagName(ancestors: LiquidHtmlNode[]): DocumentType {
|
|
7
|
+
const parent = ancestors.at(-1);
|
|
8
|
+
if (parent?.type === NodeTypes.LiquidTag && parent.name === NamedTags.theme_render_rc) {
|
|
9
|
+
return 'theme_render_rc';
|
|
10
|
+
}
|
|
11
|
+
return 'render';
|
|
12
|
+
}
|
|
13
|
+
|
|
6
14
|
const schema = {
|
|
7
15
|
ignoreMissing: SchemaProp.array(SchemaProp.string(), []),
|
|
8
16
|
};
|
|
@@ -24,62 +32,46 @@ export const MissingPartial: LiquidCheckDefinition<typeof schema> = {
|
|
|
24
32
|
|
|
25
33
|
create(context) {
|
|
26
34
|
const locator = new DocumentsLocator(context.fs);
|
|
35
|
+
const rootUri = URI.parse(context.config.rootUri);
|
|
36
|
+
let searchPathsPromise: Promise<string[] | null> | undefined;
|
|
27
37
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
38
|
+
function getSearchPaths(): Promise<string[] | null> {
|
|
39
|
+
searchPathsPromise ??= loadSearchPaths(context.fs, rootUri);
|
|
40
|
+
return searchPathsPromise;
|
|
41
|
+
}
|
|
31
42
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
43
|
+
async function reportIfMissing(
|
|
44
|
+
docType: DocumentType,
|
|
45
|
+
name: string,
|
|
46
|
+
position: LiquidHtmlNode['position'],
|
|
47
|
+
) {
|
|
48
|
+
const searchPaths = docType === 'theme_render_rc' ? await getSearchPaths() : null;
|
|
49
|
+
const location = await locator.locate(rootUri, docType, name, searchPaths);
|
|
50
|
+
if (!location) {
|
|
51
|
+
context.report({
|
|
52
|
+
message: `'${name}' does not exist`,
|
|
53
|
+
startIndex: position.start,
|
|
54
|
+
endIndex: position.end,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
38
58
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
endIndex: node.partial.position.end,
|
|
44
|
-
});
|
|
59
|
+
return {
|
|
60
|
+
async RenderMarkup(node, ancestors) {
|
|
61
|
+
if (node.partial.type !== NodeTypes.VariableLookup) {
|
|
62
|
+
await reportIfMissing(getTagName(ancestors), node.partial.value, node.partial.position);
|
|
45
63
|
}
|
|
46
64
|
},
|
|
47
65
|
|
|
48
66
|
async FunctionMarkup(node) {
|
|
49
|
-
if (node.partial.type
|
|
50
|
-
|
|
51
|
-
const partial = node.partial;
|
|
52
|
-
const location = await locator.locate(
|
|
53
|
-
URI.parse(context.config.rootUri),
|
|
54
|
-
'function',
|
|
55
|
-
partial.value,
|
|
56
|
-
);
|
|
57
|
-
|
|
58
|
-
if (!location) {
|
|
59
|
-
context.report({
|
|
60
|
-
message: `'${partial.value}' does not exist`,
|
|
61
|
-
startIndex: node.partial.position.start,
|
|
62
|
-
endIndex: node.partial.position.end,
|
|
63
|
-
});
|
|
67
|
+
if (node.partial.type !== NodeTypes.VariableLookup) {
|
|
68
|
+
await reportIfMissing('function', node.partial.value, node.partial.position);
|
|
64
69
|
}
|
|
65
70
|
},
|
|
66
71
|
|
|
67
72
|
async GraphQLMarkup(node) {
|
|
68
|
-
if (node.graphql.type
|
|
69
|
-
|
|
70
|
-
const graphql = node.graphql;
|
|
71
|
-
const location = await locator.locate(
|
|
72
|
-
URI.parse(context.config.rootUri),
|
|
73
|
-
'graphql',
|
|
74
|
-
graphql.value,
|
|
75
|
-
);
|
|
76
|
-
|
|
77
|
-
if (!location) {
|
|
78
|
-
context.report({
|
|
79
|
-
message: `'${graphql.value}' does not exist`,
|
|
80
|
-
startIndex: node.graphql.position.start,
|
|
81
|
-
endIndex: node.graphql.position.end,
|
|
82
|
-
});
|
|
73
|
+
if (node.graphql.type !== NodeTypes.VariableLookup) {
|
|
74
|
+
await reportIfMissing('graphql', node.graphql.value, node.graphql.position);
|
|
83
75
|
}
|
|
84
76
|
},
|
|
85
77
|
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { applySuggestions, runLiquidCheck } from '../../test';
|
|
3
|
+
import { MissingRenderPartialArguments } from '.';
|
|
4
|
+
|
|
5
|
+
function check(partial: string, source: string) {
|
|
6
|
+
return runLiquidCheck(
|
|
7
|
+
MissingRenderPartialArguments,
|
|
8
|
+
source,
|
|
9
|
+
undefined,
|
|
10
|
+
{},
|
|
11
|
+
{ 'app/views/partials/card.liquid': partial },
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const partialWithRequiredParams = `
|
|
16
|
+
{% doc %}
|
|
17
|
+
@param {string} title - The card title
|
|
18
|
+
@param {string} [subtitle] - Optional subtitle
|
|
19
|
+
{% enddoc %}
|
|
20
|
+
`;
|
|
21
|
+
|
|
22
|
+
describe('Module: MissingRenderPartialArguments', () => {
|
|
23
|
+
it('should not report when partial has no LiquidDoc', async () => {
|
|
24
|
+
const offenses = await check('<h1>card</h1>', `{% render 'card' %}`);
|
|
25
|
+
expect(offenses).to.have.length(0);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('should not report when all required params are provided', async () => {
|
|
29
|
+
const offenses = await check(partialWithRequiredParams, `{% render 'card', title: 'Hello' %}`);
|
|
30
|
+
expect(offenses).to.have.length(0);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('should not report for missing optional params', async () => {
|
|
34
|
+
const offenses = await check(partialWithRequiredParams, `{% render 'card', title: 'Hello' %}`);
|
|
35
|
+
expect(offenses).to.have.length(0);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should report ERROR when a required param is missing', async () => {
|
|
39
|
+
const offenses = await check(partialWithRequiredParams, `{% render 'card' %}`);
|
|
40
|
+
expect(offenses).to.have.length(1);
|
|
41
|
+
expect(offenses[0].message).to.equal(
|
|
42
|
+
"Missing required argument 'title' in render tag for partial 'card'.",
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should suggest adding the missing required param', async () => {
|
|
47
|
+
const source = `{% render 'card' %}`;
|
|
48
|
+
const offenses = await check(partialWithRequiredParams, source);
|
|
49
|
+
expect(offenses[0].suggest).to.have.length(1);
|
|
50
|
+
expect(offenses[0].suggest![0].message).to.equal("Add required argument 'title'");
|
|
51
|
+
const fixed = applySuggestions(source, offenses[0]);
|
|
52
|
+
expect(fixed).to.not.be.undefined;
|
|
53
|
+
expect(fixed![0]).to.equal("{% render 'card', title: '' %}");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('should report one ERROR per missing required param', async () => {
|
|
57
|
+
const partial = `
|
|
58
|
+
{% doc %}
|
|
59
|
+
@param {string} title - title
|
|
60
|
+
@param {string} body - body
|
|
61
|
+
{% enddoc %}
|
|
62
|
+
`;
|
|
63
|
+
const offenses = await check(partial, `{% render 'card' %}`);
|
|
64
|
+
expect(offenses).to.have.length(2);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should not report for dynamic partials', async () => {
|
|
68
|
+
const offenses = await runLiquidCheck(
|
|
69
|
+
MissingRenderPartialArguments,
|
|
70
|
+
`{% render partial_name %}`,
|
|
71
|
+
);
|
|
72
|
+
expect(offenses).to.have.length(0);
|
|
73
|
+
});
|
|
74
|
+
});
|