td-ai-tools 1.2.4 → 1.2.6
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/package.json +1 -1
- package/skills/README.md +1 -1
- package/skills/shopify-lint/SKILL.md +4 -4
- package/skills/shopify-lint/agents/openai.yaml +1 -1
- package/skills/shopify-lint/hooks/codex-stop.sh +1 -1
- package/skills/shopify-lint/scripts/__pycache__/shopify_lint.cpython-312.pyc +0 -0
- package/skills/shopify-lint/scripts/setup.sh +17 -1
- package/skills/shopify-lint/tests/__pycache__/test_shopify_lint.cpython-312.pyc +0 -0
- package/skills/shopify-lint/theme-check-theory/.theme-check.example.yml +16 -1
- package/skills/shopify-lint/theme-check-theory/README.md +89 -18
- package/skills/shopify-lint/theme-check-theory/configs/recommended.yml +16 -1
- package/skills/shopify-lint/theme-check-theory/package-lock.json +2 -2
- package/skills/shopify-lint/theme-check-theory/package.json +1 -1
- package/skills/shopify-lint/theme-check-theory/src/checks/disallowed-script-or-style-tag.test.ts +54 -0
- package/skills/shopify-lint/theme-check-theory/src/checks/disallowed-script-or-style-tag.ts +77 -0
- package/skills/shopify-lint/theme-check-theory/src/checks/hardcoded-text.test.ts +102 -0
- package/skills/shopify-lint/theme-check-theory/src/checks/hardcoded-text.ts +171 -0
- package/skills/shopify-lint/theme-check-theory/src/checks/unguarded-metafield.test.ts +76 -0
- package/skills/shopify-lint/theme-check-theory/src/checks/unguarded-metafield.ts +49 -0
- package/skills/shopify-lint/theme-check-theory/src/checks/unguarded-metaobject.test.ts +77 -0
- package/skills/shopify-lint/theme-check-theory/src/checks/unguarded-metaobject.ts +42 -0
- package/skills/shopify-lint/theme-check-theory/src/checks/unguarded-nullable-setting.test.ts +301 -0
- package/skills/shopify-lint/theme-check-theory/src/checks/unguarded-nullable-setting.ts +129 -0
- package/skills/shopify-lint/theme-check-theory/src/checks/unused-section-settings.ts +1 -1
- package/skills/shopify-lint/theme-check-theory/src/index.test.ts +17 -4
- package/skills/shopify-lint/theme-check-theory/src/index.ts +20 -6
- package/skills/shopify-lint/theme-check-theory/src/utils/null-guard.ts +79 -0
- package/skills/shopify-lint/theme-check-theory/src/utils/nullable-output-check.ts +90 -0
- package/skills/visual-regression/scripts/__pycache__/visual_regression.cpython-312.pyc +0 -0
- package/skills/shopify-lint/theme-check-theory/src/checks/unguarded-text-setting.test.ts +0 -198
- package/skills/shopify-lint/theme-check-theory/src/checks/unguarded-text-setting.ts +0 -143
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import {
|
|
2
|
+
check,
|
|
3
|
+
runLiquidCheck,
|
|
4
|
+
} from '@shopify/theme-check-common/dist/test/test-helper';
|
|
5
|
+
import { describe, expect, it } from 'vitest';
|
|
6
|
+
|
|
7
|
+
import { liquidSection } from '../test-utils';
|
|
8
|
+
import {
|
|
9
|
+
DEFAULT_NULLABLE_SETTING_TYPES,
|
|
10
|
+
UnguardedNullableSetting,
|
|
11
|
+
} from './unguarded-nullable-setting';
|
|
12
|
+
|
|
13
|
+
describe('UnguardedNullableSetting', () => {
|
|
14
|
+
it('reports raw outputs of all default nullable section and block setting types', async () => {
|
|
15
|
+
const settings = DEFAULT_NULLABLE_SETTING_TYPES.map((type) => ({
|
|
16
|
+
type,
|
|
17
|
+
id: `section_${type}`,
|
|
18
|
+
}));
|
|
19
|
+
const blockSettings = DEFAULT_NULLABLE_SETTING_TYPES.map((type) => ({
|
|
20
|
+
type,
|
|
21
|
+
id: `block_${type}`,
|
|
22
|
+
}));
|
|
23
|
+
const outputs = [
|
|
24
|
+
...settings.map(({ id }) => `{{ section.settings.${id} }}`),
|
|
25
|
+
...blockSettings.map(({ id }) => `{{ block.settings.${id} }}`),
|
|
26
|
+
].join('\n');
|
|
27
|
+
const source = liquidSection(outputs, settings, [
|
|
28
|
+
{ type: 'card', name: 'Card', settings: blockSettings },
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
const offenses = await runLiquidCheck(UnguardedNullableSetting, source);
|
|
32
|
+
|
|
33
|
+
expect(offenses).toHaveLength(DEFAULT_NULLABLE_SETTING_TYPES.length * 2);
|
|
34
|
+
expect(offenses[0].message).toBe(
|
|
35
|
+
"The setting 'section_number' can be blank and is output without a presence guard or default",
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('covers every input type documented as potentially empty, blank, or nil', () => {
|
|
40
|
+
expect(DEFAULT_NULLABLE_SETTING_TYPES).toEqual([
|
|
41
|
+
'number',
|
|
42
|
+
'text',
|
|
43
|
+
'textarea',
|
|
44
|
+
'article',
|
|
45
|
+
'article_list',
|
|
46
|
+
'blog',
|
|
47
|
+
'collection',
|
|
48
|
+
'collection_list',
|
|
49
|
+
'image_picker',
|
|
50
|
+
'link_list',
|
|
51
|
+
'metaobject',
|
|
52
|
+
'metaobject_list',
|
|
53
|
+
'page',
|
|
54
|
+
'product',
|
|
55
|
+
'product_list',
|
|
56
|
+
'richtext',
|
|
57
|
+
'inline_richtext',
|
|
58
|
+
'html',
|
|
59
|
+
'liquid',
|
|
60
|
+
'url',
|
|
61
|
+
'video',
|
|
62
|
+
'video_url',
|
|
63
|
+
]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('ignores settings with guaranteed or ambiguous empty values by default', async () => {
|
|
67
|
+
const source = liquidSection(
|
|
68
|
+
`
|
|
69
|
+
{{ section.settings.enabled }}
|
|
70
|
+
{{ section.settings.spacing }}
|
|
71
|
+
{{ section.settings.alignment }}
|
|
72
|
+
{{ section.settings.layout }}
|
|
73
|
+
{{ section.settings.color }}
|
|
74
|
+
{{ section.settings.background }}
|
|
75
|
+
{{ section.settings.color_scheme }}
|
|
76
|
+
{{ section.settings.palette }}
|
|
77
|
+
{{ section.settings.schemes }}
|
|
78
|
+
{{ section.settings.font }}
|
|
79
|
+
{{ section.settings.text_alignment }}
|
|
80
|
+
`,
|
|
81
|
+
[
|
|
82
|
+
{ type: 'checkbox', id: 'enabled' },
|
|
83
|
+
{ type: 'range', id: 'spacing' },
|
|
84
|
+
{ type: 'radio', id: 'alignment' },
|
|
85
|
+
{ type: 'select', id: 'layout' },
|
|
86
|
+
{ type: 'color', id: 'color' },
|
|
87
|
+
{ type: 'color_background', id: 'background' },
|
|
88
|
+
{ type: 'color_scheme', id: 'color_scheme' },
|
|
89
|
+
{ type: 'color_palette', id: 'palette' },
|
|
90
|
+
{ type: 'color_scheme_group', id: 'schemes' },
|
|
91
|
+
{ type: 'font_picker', id: 'font' },
|
|
92
|
+
{ type: 'text_alignment', id: 'text_alignment' },
|
|
93
|
+
],
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
expect(await runLiquidCheck(UnguardedNullableSetting, source)).toEqual([]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it.each([
|
|
100
|
+
[
|
|
101
|
+
'if',
|
|
102
|
+
'{% if section.settings.heading %}{{ section.settings.heading }}{% endif %}',
|
|
103
|
+
],
|
|
104
|
+
[
|
|
105
|
+
'unless',
|
|
106
|
+
'{% unless section.settings.heading == blank %}{{ section.settings.heading }}{% endunless %}',
|
|
107
|
+
],
|
|
108
|
+
[
|
|
109
|
+
'elsif',
|
|
110
|
+
'{% if product %}x{% elsif section.settings.heading %}{{ section.settings.heading }}{% endif %}',
|
|
111
|
+
],
|
|
112
|
+
[
|
|
113
|
+
'case',
|
|
114
|
+
'{% case section.settings.heading %}{% when "Hello" %}{{ section.settings.heading }}{% endcase %}',
|
|
115
|
+
],
|
|
116
|
+
])('accepts a setting tested in an %s conditional', async (_, liquid) => {
|
|
117
|
+
const source = liquidSection(liquid, [{ type: 'text', id: 'heading' }]);
|
|
118
|
+
|
|
119
|
+
expect(await runLiquidCheck(UnguardedNullableSetting, source)).toEqual([]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('accepts logical and comparison presence tests', async () => {
|
|
123
|
+
const source = liquidSection(
|
|
124
|
+
`
|
|
125
|
+
{% if section.settings.heading != blank and section.settings.subtitle %}
|
|
126
|
+
{{ section.settings.heading }} {{ section.settings.subtitle }}
|
|
127
|
+
{% endif %}
|
|
128
|
+
`,
|
|
129
|
+
[
|
|
130
|
+
{ type: 'text', id: 'heading' },
|
|
131
|
+
{ type: 'textarea', id: 'subtitle' },
|
|
132
|
+
],
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
expect(await runLiquidCheck(UnguardedNullableSetting, source)).toEqual([]);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('does not mistake an unrelated enclosing condition for a setting presence guard', async () => {
|
|
139
|
+
const source = liquidSection(
|
|
140
|
+
'{% if product.available %}{{ section.settings.heading }}{% endif %}',
|
|
141
|
+
[{ type: 'text', id: 'heading' }],
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
const offenses = await runLiquidCheck(UnguardedNullableSetting, source);
|
|
145
|
+
|
|
146
|
+
expect(offenses).toHaveLength(1);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('accepts output with a default filter', async () => {
|
|
150
|
+
const source = liquidSection(
|
|
151
|
+
"{{ section.settings.heading | strip | default: 'Welcome' }}",
|
|
152
|
+
[{ type: 'text', id: 'heading' }],
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
expect(await runLiquidCheck(UnguardedNullableSetting, source)).toEqual([]);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('does not treat use as another expression default as handling the setting', async () => {
|
|
159
|
+
const source = liquidSection(
|
|
160
|
+
"{{ product.title | default: section.settings.heading }} {{ section.settings.heading }}",
|
|
161
|
+
[{ type: 'text', id: 'heading' }],
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
const offenses = await runLiquidCheck(UnguardedNullableSetting, source);
|
|
165
|
+
|
|
166
|
+
expect(offenses).toHaveLength(1);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('only accepts outputs inside the branch that guards the setting', async () => {
|
|
170
|
+
const source = liquidSection(
|
|
171
|
+
`
|
|
172
|
+
{{ section.settings.heading }}
|
|
173
|
+
{% if section.settings.heading %}{{ section.settings.heading }}{% endif %}
|
|
174
|
+
{{ section.settings.heading }}
|
|
175
|
+
`,
|
|
176
|
+
[{ type: 'text', id: 'heading' }],
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
const offenses = await runLiquidCheck(UnguardedNullableSetting, source);
|
|
180
|
+
|
|
181
|
+
expect(offenses).toHaveLength(2);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('only applies a default filter to the output that uses it', async () => {
|
|
185
|
+
const source = liquidSection(
|
|
186
|
+
"{{ section.settings.heading }} / {{ section.settings.heading | default: 'Welcome' }}",
|
|
187
|
+
[{ type: 'text', id: 'heading' }],
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
const offenses = await runLiquidCheck(UnguardedNullableSetting, source);
|
|
191
|
+
|
|
192
|
+
expect(offenses).toHaveLength(1);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('does not let an elsif guard apply to an output in a sibling branch', async () => {
|
|
196
|
+
const source = liquidSection(
|
|
197
|
+
`
|
|
198
|
+
{% if product %}
|
|
199
|
+
{{ section.settings.heading }}
|
|
200
|
+
{% elsif section.settings.heading %}
|
|
201
|
+
{{ section.settings.heading }}
|
|
202
|
+
{% endif %}
|
|
203
|
+
`,
|
|
204
|
+
[{ type: 'text', id: 'heading' }],
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
const offenses = await runLiquidCheck(UnguardedNullableSetting, source);
|
|
208
|
+
|
|
209
|
+
expect(offenses).toHaveLength(1);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('does not treat a case else branch as guarded by the case expression', async () => {
|
|
213
|
+
const source = liquidSection(
|
|
214
|
+
`
|
|
215
|
+
{% case section.settings.heading %}
|
|
216
|
+
{% when 'Welcome' %}{{ section.settings.heading }}
|
|
217
|
+
{% else %}{{ section.settings.heading }}
|
|
218
|
+
{% endcase %}
|
|
219
|
+
`,
|
|
220
|
+
[{ type: 'text', id: 'heading' }],
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
const offenses = await runLiquidCheck(UnguardedNullableSetting, source);
|
|
224
|
+
|
|
225
|
+
expect(offenses).toHaveLength(1);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('accepts an output nested within the guarded branch', async () => {
|
|
229
|
+
const source = liquidSection(
|
|
230
|
+
`
|
|
231
|
+
{% if section.settings.heading %}
|
|
232
|
+
{% if product.available %}
|
|
233
|
+
<h2>{{ section.settings.heading }}</h2>
|
|
234
|
+
{% endif %}
|
|
235
|
+
{% endif %}
|
|
236
|
+
`,
|
|
237
|
+
[{ type: 'text', id: 'heading' }],
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
expect(await runLiquidCheck(UnguardedNullableSetting, source)).toEqual([]);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('reports every raw output when the setting is never handled', async () => {
|
|
244
|
+
const source = liquidSection(
|
|
245
|
+
'{{ section.settings.heading }} / {{ section.settings.heading | escape }}',
|
|
246
|
+
[{ type: 'text', id: 'heading' }],
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
const offenses = await runLiquidCheck(UnguardedNullableSetting, source);
|
|
250
|
+
|
|
251
|
+
expect(offenses).toHaveLength(2);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('only considers variable output tags, not assignments or echo tags', async () => {
|
|
255
|
+
const source = liquidSection(
|
|
256
|
+
'{% assign heading = section.settings.heading %}{% echo section.settings.heading %}',
|
|
257
|
+
[{ type: 'text', id: 'heading' }],
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
expect(await runLiquidCheck(UnguardedNullableSetting, source)).toEqual([]);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('supports nullableSettingTypes configuration', async () => {
|
|
264
|
+
const source = liquidSection('{{ section.settings.enabled }}', [
|
|
265
|
+
{ type: 'checkbox', id: 'enabled' },
|
|
266
|
+
]);
|
|
267
|
+
|
|
268
|
+
const offenses = await check(
|
|
269
|
+
{ 'sections/test.liquid': source },
|
|
270
|
+
[UnguardedNullableSetting],
|
|
271
|
+
{},
|
|
272
|
+
{
|
|
273
|
+
UnguardedNullableSetting: {
|
|
274
|
+
enabled: true,
|
|
275
|
+
nullableSettingTypes: ['checkbox'],
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
expect(offenses).toHaveLength(1);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('ignores dynamic output while still checking statically known outputs', async () => {
|
|
284
|
+
const source = liquidSection(
|
|
285
|
+
'{{ section.settings[key] }} {{ section.settings.heading }}',
|
|
286
|
+
[{ type: 'text', id: 'heading' }],
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
const offenses = await runLiquidCheck(UnguardedNullableSetting, source);
|
|
290
|
+
|
|
291
|
+
expect(offenses).toHaveLength(1);
|
|
292
|
+
expect(offenses[0].message).toContain("'heading'");
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it.each([
|
|
296
|
+
['a file without schema', '{{ section.settings.heading }}'],
|
|
297
|
+
['an invalid schema', '{{ section.settings.heading }}{% schema %}{ nope {% endschema %}'],
|
|
298
|
+
])('stays silent for %s', async (_, source) => {
|
|
299
|
+
expect(await runLiquidCheck(UnguardedNullableSetting, source)).toEqual([]);
|
|
300
|
+
});
|
|
301
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import {
|
|
2
|
+
LiquidCheckDefinition,
|
|
3
|
+
SchemaProp,
|
|
4
|
+
Severity,
|
|
5
|
+
SourceCodeType,
|
|
6
|
+
} from '@shopify/theme-check-common';
|
|
7
|
+
import {
|
|
8
|
+
NodeTypes,
|
|
9
|
+
} from '@shopify/liquid-html-parser';
|
|
10
|
+
import type {
|
|
11
|
+
LiquidVariableOutput,
|
|
12
|
+
LiquidVariableLookup,
|
|
13
|
+
} from '@shopify/liquid-html-parser';
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
getSettingDeclarations,
|
|
17
|
+
getSettingReference,
|
|
18
|
+
settingKey,
|
|
19
|
+
} from '../utils/ast';
|
|
20
|
+
import type { SettingDeclaration } from '../utils/ast';
|
|
21
|
+
import { isOutputGuarded } from '../utils/null-guard';
|
|
22
|
+
|
|
23
|
+
export const DEFAULT_NULLABLE_SETTING_TYPES = [
|
|
24
|
+
'number',
|
|
25
|
+
'text',
|
|
26
|
+
'textarea',
|
|
27
|
+
'article',
|
|
28
|
+
'article_list',
|
|
29
|
+
'blog',
|
|
30
|
+
'collection',
|
|
31
|
+
'collection_list',
|
|
32
|
+
'image_picker',
|
|
33
|
+
'link_list',
|
|
34
|
+
'metaobject',
|
|
35
|
+
'metaobject_list',
|
|
36
|
+
'page',
|
|
37
|
+
'product',
|
|
38
|
+
'product_list',
|
|
39
|
+
'richtext',
|
|
40
|
+
'inline_richtext',
|
|
41
|
+
'html',
|
|
42
|
+
'liquid',
|
|
43
|
+
'url',
|
|
44
|
+
'video',
|
|
45
|
+
'video_url',
|
|
46
|
+
] as const;
|
|
47
|
+
|
|
48
|
+
const schema = {
|
|
49
|
+
nullableSettingTypes: SchemaProp.array(
|
|
50
|
+
SchemaProp.string(),
|
|
51
|
+
[...DEFAULT_NULLABLE_SETTING_TYPES],
|
|
52
|
+
),
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const UnguardedNullableSetting: LiquidCheckDefinition<typeof schema> = {
|
|
56
|
+
meta: {
|
|
57
|
+
code: 'UnguardedNullableSetting',
|
|
58
|
+
name: 'Require guards for nullable settings',
|
|
59
|
+
docs: {
|
|
60
|
+
description: 'Reports potentially blank settings output without a presence guard or default filter.',
|
|
61
|
+
recommended: true,
|
|
62
|
+
},
|
|
63
|
+
type: SourceCodeType.LiquidHtml,
|
|
64
|
+
severity: Severity.WARNING,
|
|
65
|
+
schema,
|
|
66
|
+
},
|
|
67
|
+
create(context) {
|
|
68
|
+
const declarations: SettingDeclaration[] = [];
|
|
69
|
+
const outputs: Array<{
|
|
70
|
+
id: string;
|
|
71
|
+
key: string;
|
|
72
|
+
node: LiquidVariableOutput;
|
|
73
|
+
guarded: boolean;
|
|
74
|
+
}> = [];
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
async LiquidRawTag(node) {
|
|
78
|
+
const schemaDeclarations = getSettingDeclarations(node);
|
|
79
|
+
if (schemaDeclarations) declarations.push(...schemaDeclarations);
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
async LiquidVariableOutput(node, ancestors) {
|
|
83
|
+
if (typeof node.markup === 'string') return;
|
|
84
|
+
|
|
85
|
+
const expression = node.markup.expression;
|
|
86
|
+
if (expression.type !== NodeTypes.VariableLookup) return;
|
|
87
|
+
|
|
88
|
+
const reference = getSettingReference(expression);
|
|
89
|
+
if (!reference || reference.dynamic || !reference.id) return;
|
|
90
|
+
|
|
91
|
+
const key = settingKey({ id: reference.id, scope: reference.scope });
|
|
92
|
+
const guarded = isOutputGuarded(
|
|
93
|
+
node,
|
|
94
|
+
ancestors,
|
|
95
|
+
key,
|
|
96
|
+
getSettingReferenceKey,
|
|
97
|
+
);
|
|
98
|
+
outputs.push({ id: reference.id, key, node, guarded });
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
async onCodePathEnd() {
|
|
102
|
+
const nullableTypes = new Set(context.settings.nullableSettingTypes);
|
|
103
|
+
const nullableSettings = new Set(
|
|
104
|
+
declarations
|
|
105
|
+
.filter(({ type }) => nullableTypes.has(type))
|
|
106
|
+
.map(settingKey),
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
for (const output of outputs) {
|
|
110
|
+
if (!nullableSettings.has(output.key) || output.guarded) continue;
|
|
111
|
+
|
|
112
|
+
context.report({
|
|
113
|
+
message:
|
|
114
|
+
`The setting '${output.id}' can be blank and is output without a presence guard or default`,
|
|
115
|
+
startIndex: output.node.position.start,
|
|
116
|
+
endIndex: output.node.position.end,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
function getSettingReferenceKey(node: LiquidVariableLookup): string | undefined {
|
|
125
|
+
const reference = getSettingReference(node);
|
|
126
|
+
if (!reference || reference.dynamic || !reference.id) return undefined;
|
|
127
|
+
|
|
128
|
+
return settingKey(reference);
|
|
129
|
+
}
|
|
@@ -17,7 +17,7 @@ export const UnusedSectionSettings: LiquidCheckDefinition = {
|
|
|
17
17
|
name: 'Prevent unused section settings',
|
|
18
18
|
docs: {
|
|
19
19
|
description: 'Reports section and block settings that are never referenced in their file.',
|
|
20
|
-
recommended:
|
|
20
|
+
recommended: false,
|
|
21
21
|
},
|
|
22
22
|
type: SourceCodeType.LiquidHtml,
|
|
23
23
|
severity: Severity.WARNING,
|
|
@@ -4,17 +4,30 @@ import { describe, expect, it } from 'vitest';
|
|
|
4
4
|
import { checks } from './index';
|
|
5
5
|
|
|
6
6
|
describe('theme-check-theory module', () => {
|
|
7
|
-
it('exports
|
|
7
|
+
it('exports all warning-level Liquid checks for Theme Check to load', () => {
|
|
8
8
|
expect(checks.map(({ meta }) => meta.code)).toEqual([
|
|
9
|
+
'DisallowedScriptOrStyleTag',
|
|
10
|
+
'HardcodedText',
|
|
9
11
|
'UnusedSectionSettings',
|
|
10
|
-
'
|
|
12
|
+
'UnguardedNullableSetting',
|
|
13
|
+
'UnguardedMetafield',
|
|
14
|
+
'UnguardedMetaobject',
|
|
11
15
|
]);
|
|
12
16
|
|
|
13
17
|
for (const check of checks) {
|
|
14
18
|
expect(check.meta.type).toBe(SourceCodeType.LiquidHtml);
|
|
15
19
|
expect(check.meta.severity).toBe(Severity.WARNING);
|
|
16
|
-
expect(check.meta.docs.recommended).toBe(true);
|
|
17
20
|
}
|
|
21
|
+
|
|
22
|
+
expect(
|
|
23
|
+
Object.fromEntries(checks.map(({ meta }) => [meta.code, meta.docs.recommended])),
|
|
24
|
+
).toEqual({
|
|
25
|
+
DisallowedScriptOrStyleTag: true,
|
|
26
|
+
HardcodedText: true,
|
|
27
|
+
UnusedSectionSettings: false,
|
|
28
|
+
UnguardedNullableSetting: true,
|
|
29
|
+
UnguardedMetafield: true,
|
|
30
|
+
UnguardedMetaobject: true,
|
|
31
|
+
});
|
|
18
32
|
});
|
|
19
33
|
});
|
|
20
|
-
|
|
@@ -1,11 +1,25 @@
|
|
|
1
|
+
import { DisallowedScriptOrStyleTag } from './checks/disallowed-script-or-style-tag';
|
|
2
|
+
import { HardcodedText } from './checks/hardcoded-text';
|
|
1
3
|
import { UnusedSectionSettings } from './checks/unused-section-settings';
|
|
2
|
-
import {
|
|
4
|
+
import { UnguardedMetafield } from './checks/unguarded-metafield';
|
|
5
|
+
import { UnguardedMetaobject } from './checks/unguarded-metaobject';
|
|
6
|
+
import { UnguardedNullableSetting } from './checks/unguarded-nullable-setting';
|
|
3
7
|
|
|
8
|
+
export { DisallowedScriptOrStyleTag } from './checks/disallowed-script-or-style-tag';
|
|
9
|
+
export { HardcodedText } from './checks/hardcoded-text';
|
|
4
10
|
export { UnusedSectionSettings } from './checks/unused-section-settings';
|
|
11
|
+
export { UnguardedMetafield } from './checks/unguarded-metafield';
|
|
12
|
+
export { UnguardedMetaobject } from './checks/unguarded-metaobject';
|
|
5
13
|
export {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
} from './checks/unguarded-
|
|
9
|
-
|
|
10
|
-
export const checks = [UnusedSectionSettings, UnguardedTextSetting];
|
|
14
|
+
DEFAULT_NULLABLE_SETTING_TYPES,
|
|
15
|
+
UnguardedNullableSetting,
|
|
16
|
+
} from './checks/unguarded-nullable-setting';
|
|
11
17
|
|
|
18
|
+
export const checks = [
|
|
19
|
+
DisallowedScriptOrStyleTag,
|
|
20
|
+
HardcodedText,
|
|
21
|
+
UnusedSectionSettings,
|
|
22
|
+
UnguardedNullableSetting,
|
|
23
|
+
UnguardedMetafield,
|
|
24
|
+
UnguardedMetaobject,
|
|
25
|
+
];
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import {
|
|
2
|
+
NodeTypes,
|
|
3
|
+
} from '@shopify/liquid-html-parser';
|
|
4
|
+
import type {
|
|
5
|
+
LiquidHtmlNode,
|
|
6
|
+
LiquidVariableLookup,
|
|
7
|
+
LiquidVariableOutput,
|
|
8
|
+
} from '@shopify/liquid-html-parser';
|
|
9
|
+
|
|
10
|
+
export type ReferenceKeyGetter = (
|
|
11
|
+
node: LiquidVariableLookup,
|
|
12
|
+
) => string | undefined;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Applies the output-level guards shared by nullable settings and objects.
|
|
16
|
+
* The caller defines reference identity so a condition on an object can guard
|
|
17
|
+
* output of one of that object's descendant properties.
|
|
18
|
+
*/
|
|
19
|
+
export function isOutputGuarded(
|
|
20
|
+
node: LiquidVariableOutput,
|
|
21
|
+
ancestors: LiquidHtmlNode[],
|
|
22
|
+
key: string,
|
|
23
|
+
getReferenceKey: ReferenceKeyGetter,
|
|
24
|
+
): boolean {
|
|
25
|
+
if (typeof node.markup === 'string') return false;
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
node.markup.filters.some(({ name }) => name === 'default') ||
|
|
29
|
+
isGuardedByConditional(key, ancestors, getReferenceKey)
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isGuardedByConditional(
|
|
34
|
+
key: string,
|
|
35
|
+
ancestors: LiquidHtmlNode[],
|
|
36
|
+
getReferenceKey: ReferenceKeyGetter,
|
|
37
|
+
): boolean {
|
|
38
|
+
return ancestors.some((ancestor, index) => {
|
|
39
|
+
if (ancestor.type !== NodeTypes.LiquidBranch) return false;
|
|
40
|
+
|
|
41
|
+
const parent = ancestors[index - 1];
|
|
42
|
+
if (!parent || parent.type !== NodeTypes.LiquidTag) return false;
|
|
43
|
+
|
|
44
|
+
if (
|
|
45
|
+
ancestor.name === null &&
|
|
46
|
+
(parent.name === 'if' || parent.name === 'unless')
|
|
47
|
+
) {
|
|
48
|
+
return containsReference(parent.markup, key, getReferenceKey);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (ancestor.name === 'elsif') {
|
|
52
|
+
return containsReference(ancestor.markup, key, getReferenceKey);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (ancestor.name === 'when' && parent.name === 'case') {
|
|
56
|
+
return containsReference(parent.markup, key, getReferenceKey);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return false;
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function containsReference(
|
|
64
|
+
value: unknown,
|
|
65
|
+
key: string,
|
|
66
|
+
getReferenceKey: ReferenceKeyGetter,
|
|
67
|
+
visited = new Set<object>(),
|
|
68
|
+
): boolean {
|
|
69
|
+
if (!value || typeof value !== 'object' || visited.has(value)) return false;
|
|
70
|
+
visited.add(value);
|
|
71
|
+
|
|
72
|
+
if ('type' in value && value.type === NodeTypes.VariableLookup) {
|
|
73
|
+
return getReferenceKey(value as LiquidVariableLookup) === key;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return Object.values(value).some((child) =>
|
|
77
|
+
containsReference(child, key, getReferenceKey, visited),
|
|
78
|
+
);
|
|
79
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import {
|
|
2
|
+
LiquidCheckDefinition,
|
|
3
|
+
Severity,
|
|
4
|
+
SourceCodeType,
|
|
5
|
+
} from '@shopify/theme-check-common';
|
|
6
|
+
import {
|
|
7
|
+
NodeTypes,
|
|
8
|
+
} from '@shopify/liquid-html-parser';
|
|
9
|
+
import type {
|
|
10
|
+
LiquidVariableLookup,
|
|
11
|
+
} from '@shopify/liquid-html-parser';
|
|
12
|
+
|
|
13
|
+
import { isOutputGuarded } from './null-guard';
|
|
14
|
+
|
|
15
|
+
export type NullableObjectReference = {
|
|
16
|
+
key: string;
|
|
17
|
+
name: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type NullableObjectReferenceGetter = (
|
|
21
|
+
node: LiquidVariableLookup,
|
|
22
|
+
) => NullableObjectReference | undefined;
|
|
23
|
+
|
|
24
|
+
type NullableOutputCheckOptions = {
|
|
25
|
+
code: string;
|
|
26
|
+
name: string;
|
|
27
|
+
description: string;
|
|
28
|
+
objectName: string;
|
|
29
|
+
getReference: NullableObjectReferenceGetter;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Builds checks for nullable Liquid objects whose identity is fully encoded in
|
|
34
|
+
* a variable lookup. Schema-backed settings use the same guard analysis but
|
|
35
|
+
* retain their separate declaration-aware check.
|
|
36
|
+
*/
|
|
37
|
+
export function createNullableOutputCheck({
|
|
38
|
+
code,
|
|
39
|
+
name,
|
|
40
|
+
description,
|
|
41
|
+
objectName,
|
|
42
|
+
getReference,
|
|
43
|
+
}: NullableOutputCheckOptions): LiquidCheckDefinition {
|
|
44
|
+
return {
|
|
45
|
+
meta: {
|
|
46
|
+
code,
|
|
47
|
+
name,
|
|
48
|
+
docs: {
|
|
49
|
+
description,
|
|
50
|
+
recommended: true,
|
|
51
|
+
},
|
|
52
|
+
type: SourceCodeType.LiquidHtml,
|
|
53
|
+
severity: Severity.WARNING,
|
|
54
|
+
schema: {},
|
|
55
|
+
},
|
|
56
|
+
create(context) {
|
|
57
|
+
return {
|
|
58
|
+
async LiquidVariableOutput(node, ancestors) {
|
|
59
|
+
if (typeof node.markup === 'string') return;
|
|
60
|
+
|
|
61
|
+
const expression = node.markup.expression;
|
|
62
|
+
if (expression.type !== NodeTypes.VariableLookup) return;
|
|
63
|
+
|
|
64
|
+
const reference = getReference(expression);
|
|
65
|
+
if (!reference) return;
|
|
66
|
+
|
|
67
|
+
const getReferenceKey = (lookup: LiquidVariableLookup) =>
|
|
68
|
+
getReference(lookup)?.key;
|
|
69
|
+
if (
|
|
70
|
+
isOutputGuarded(
|
|
71
|
+
node,
|
|
72
|
+
ancestors,
|
|
73
|
+
reference.key,
|
|
74
|
+
getReferenceKey,
|
|
75
|
+
)
|
|
76
|
+
) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
context.report({
|
|
81
|
+
message:
|
|
82
|
+
`The ${objectName} '${reference.name}' can be nil and is output without a presence guard or default`,
|
|
83
|
+
startIndex: node.position.start,
|
|
84
|
+
endIndex: node.position.end,
|
|
85
|
+
});
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|