td-ai-tools 1.2.5 → 1.2.8

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.
Files changed (30) hide show
  1. package/package.json +1 -1
  2. package/skills/README.md +1 -1
  3. package/skills/shopify-lint/SKILL.md +4 -4
  4. package/skills/shopify-lint/agents/openai.yaml +1 -1
  5. package/skills/shopify-lint/scripts/__pycache__/shopify_lint.cpython-312.pyc +0 -0
  6. package/skills/shopify-lint/scripts/setup.sh +19 -9
  7. package/skills/shopify-lint/tests/__pycache__/test_shopify_lint.cpython-312.pyc +0 -0
  8. package/skills/shopify-lint/theme-check-theory/.theme-check.example.yml +16 -1
  9. package/skills/shopify-lint/theme-check-theory/README.md +95 -18
  10. package/skills/shopify-lint/theme-check-theory/configs/recommended.yml +16 -1
  11. package/skills/shopify-lint/theme-check-theory/package-lock.json +2 -2
  12. package/skills/shopify-lint/theme-check-theory/package.json +1 -1
  13. package/skills/shopify-lint/theme-check-theory/src/checks/disallowed-script-or-style-tag.test.ts +87 -0
  14. package/skills/shopify-lint/theme-check-theory/src/checks/disallowed-script-or-style-tag.ts +161 -0
  15. package/skills/shopify-lint/theme-check-theory/src/checks/hardcoded-text.test.ts +147 -0
  16. package/skills/shopify-lint/theme-check-theory/src/checks/hardcoded-text.ts +243 -0
  17. package/skills/shopify-lint/theme-check-theory/src/checks/unguarded-metafield.test.ts +76 -0
  18. package/skills/shopify-lint/theme-check-theory/src/checks/unguarded-metafield.ts +49 -0
  19. package/skills/shopify-lint/theme-check-theory/src/checks/unguarded-metaobject.test.ts +77 -0
  20. package/skills/shopify-lint/theme-check-theory/src/checks/unguarded-metaobject.ts +42 -0
  21. package/skills/shopify-lint/theme-check-theory/src/checks/unguarded-nullable-setting.test.ts +301 -0
  22. package/skills/shopify-lint/theme-check-theory/src/checks/unguarded-nullable-setting.ts +129 -0
  23. package/skills/shopify-lint/theme-check-theory/src/checks/unused-section-settings.ts +1 -1
  24. package/skills/shopify-lint/theme-check-theory/src/index.test.ts +17 -4
  25. package/skills/shopify-lint/theme-check-theory/src/index.ts +20 -6
  26. package/skills/shopify-lint/theme-check-theory/src/utils/null-guard.ts +79 -0
  27. package/skills/shopify-lint/theme-check-theory/src/utils/nullable-output-check.ts +90 -0
  28. package/skills/visual-regression/scripts/__pycache__/visual_regression.cpython-312.pyc +0 -0
  29. package/skills/shopify-lint/theme-check-theory/src/checks/unguarded-text-setting.test.ts +0 -198
  30. package/skills/shopify-lint/theme-check-theory/src/checks/unguarded-text-setting.ts +0 -143
@@ -0,0 +1,147 @@
1
+ import {
2
+ runLiquidCheck,
3
+ } from '@shopify/theme-check-common/dist/test/test-helper';
4
+ import { describe, expect, it } from 'vitest';
5
+
6
+ import { HardcodedText } from './hardcoded-text';
7
+
8
+ describe('HardcodedText', () => {
9
+ it('reports rendered text and direct string output', async () => {
10
+ const source = `
11
+ Welcome to our store
12
+ <h2>Featured products</h2>
13
+ {{ 'Limited edition' }}
14
+ {% echo "Shop now" %}
15
+ `;
16
+
17
+ const offenses = await runLiquidCheck(HardcodedText, source);
18
+
19
+ expect(offenses).toHaveLength(4);
20
+ expect(
21
+ offenses.every(({ check }) => check === 'HardcodedText'),
22
+ ).toBe(true);
23
+ expect(
24
+ offenses.every(
25
+ ({ message }) =>
26
+ message ===
27
+ 'Replace hard-coded text with a section setting, translation key, metafield, or metaobject',
28
+ ),
29
+ ).toBe(true);
30
+ });
31
+
32
+ it('reports hard-coded text in common user-facing attributes', async () => {
33
+ const source = `
34
+ <img src="{{ image | image_url }}" alt="Product photo">
35
+ <button title='Close dialog' aria-label="Close">×</button>
36
+ <input placeholder="Email address">
37
+ `;
38
+
39
+ const offenses = await runLiquidCheck(HardcodedText, source);
40
+
41
+ expect(offenses).toHaveLength(4);
42
+ });
43
+
44
+ it('reports labels in button-like input value attributes', async () => {
45
+ const source = `
46
+ <input type="submit" value="Search">
47
+ <input type=button value='Add to cart'>
48
+ <input type='RESET' value="Clear form">
49
+ `;
50
+
51
+ const offenses = await runLiquidCheck(HardcodedText, source);
52
+
53
+ expect(offenses).toHaveLength(3);
54
+ });
55
+
56
+ it('ignores value attributes that are not rendered as labels', async () => {
57
+ const source = `
58
+ <input type="hidden" value="technical-id">
59
+ <input type="text" value="Initial form value">
60
+ <button value="submitted-value">{{ button_label }}</button>
61
+ `;
62
+
63
+ expect(await runLiquidCheck(HardcodedText, source)).toEqual([]);
64
+ });
65
+
66
+ it('reports static fragments in otherwise dynamic rendered text', async () => {
67
+ const source = `
68
+ <p>Welcome, {{ customer.name }}!</p>
69
+ <img alt="Photo of {{ product.title }}" src="{{ product.featured_image | image_url }}">
70
+ `;
71
+
72
+ const offenses = await runLiquidCheck(HardcodedText, source);
73
+
74
+ expect(offenses).toHaveLength(2);
75
+ });
76
+
77
+ it('reports hard-coded default filter fallbacks', async () => {
78
+ const source = `
79
+ {{ product.title | default: 'Free shipping on all orders' }}
80
+ {% echo section.settings.label | default: "Shop now" %}
81
+ {{ translated_title | t | default: 'Fallback after translation' }}
82
+ `;
83
+
84
+ const offenses = await runLiquidCheck(HardcodedText, source);
85
+
86
+ expect(offenses).toHaveLength(3);
87
+ });
88
+
89
+ it('accepts settings, translation keys, metafields, and metaobjects', async () => {
90
+ const source = `
91
+ <h2>{{ section.settings.heading }}</h2>
92
+ <p>{{ 'general.welcome' | t }}</p>
93
+ <p>{{ "general.subtitle" | translate }}</p>
94
+ <p>{{ product.metafields.content.description }}</p>
95
+ <p>{{ metaobjects.testimonials.homepage.quote }}</p>
96
+ <p>{{ product.title | default: 'products.fallback' | t }}</p>
97
+ <img alt="{{ section.settings.image_alt }}" src="{{ section.settings.image | image_url }}">
98
+ `;
99
+
100
+ expect(await runLiquidCheck(HardcodedText, source)).toEqual([]);
101
+ });
102
+
103
+ it('ignores entity-only text while still reporting surrounding copy', async () => {
104
+ const entities = `
105
+ &nbsp; &times; &mdash; &hellip; &copy; &#169; &#x00a0;
106
+ `;
107
+ const copy = '&nbsp;Welcome&nbsp;';
108
+
109
+ expect(await runLiquidCheck(HardcodedText, entities)).toEqual([]);
110
+ expect(await runLiquidCheck(HardcodedText, copy)).toHaveLength(1);
111
+ });
112
+
113
+ it.each([
114
+ [
115
+ 'stylesheet',
116
+ '{% stylesheet %}.card::after { content: "Hard coded"; }{% endstylesheet %}',
117
+ ],
118
+ [
119
+ 'javascript',
120
+ '{% javascript %}console.log("Hard coded");{% endjavascript %}',
121
+ ],
122
+ [
123
+ 'schema',
124
+ '{% schema %}{"name":"Hard coded","settings":[]}{% endschema %}',
125
+ ],
126
+ ])('ignores text inside the %s raw tag', async (_, source) => {
127
+ expect(await runLiquidCheck(HardcodedText, source)).toEqual([]);
128
+ });
129
+
130
+ it('ignores comments, technical attributes, punctuation, and Liquid logic strings', async () => {
131
+ const source = `
132
+ <!-- Hard coded HTML comment -->
133
+ {% comment %}Hard coded Liquid comment{% endcomment %}
134
+ <div class="hard-coded" id="hard-coded" data-label="Hard coded">—</div>
135
+ {% assign state = 'active' %}
136
+ {% if request.page_type == 'product' %}{{ product.title }}{% endif %}
137
+ `;
138
+
139
+ expect(await runLiquidCheck(HardcodedText, source)).toEqual([]);
140
+ });
141
+
142
+ it('still reports rendered text inside other raw tags', async () => {
143
+ const source = '{% raw %}Hard coded raw output{% endraw %}';
144
+
145
+ expect(await runLiquidCheck(HardcodedText, source)).toHaveLength(1);
146
+ });
147
+ });
@@ -0,0 +1,243 @@
1
+ import {
2
+ NodeTypes,
3
+ } from '@shopify/liquid-html-parser';
4
+ import type {
5
+ AttributeNode,
6
+ HtmlVoidElement,
7
+ LiquidHtmlNode,
8
+ LiquidRawTag,
9
+ LiquidString,
10
+ LiquidVariable,
11
+ TextNode,
12
+ } from '@shopify/liquid-html-parser';
13
+ import {
14
+ LiquidCheckDefinition,
15
+ Severity,
16
+ SourceCodeType,
17
+ } from '@shopify/theme-check-common';
18
+
19
+ const EXEMPT_RAW_TAGS = new Set([
20
+ 'comment',
21
+ 'javascript',
22
+ 'schema',
23
+ 'stylesheet',
24
+ ]);
25
+
26
+ const TEXT_ATTRIBUTES = new Set([
27
+ 'alt',
28
+ 'aria-description',
29
+ 'aria-label',
30
+ 'aria-placeholder',
31
+ 'placeholder',
32
+ 'title',
33
+ ]);
34
+
35
+ const TRANSLATION_FILTERS = new Set(['t', 'translate']);
36
+
37
+ const BUTTON_INPUT_TYPES = new Set(['button', 'reset', 'submit']);
38
+
39
+ const HTML_CHARACTER_REFERENCE =
40
+ /&(?:#\d+|#x[\da-f]+|[a-z][\da-z]+);/giu;
41
+
42
+ type ValuedAttribute = Extract<AttributeNode, { value: unknown }>;
43
+
44
+ function isValuedAttribute(node: LiquidHtmlNode): node is ValuedAttribute {
45
+ return (
46
+ node.type === NodeTypes.AttrDoubleQuoted ||
47
+ node.type === NodeTypes.AttrSingleQuoted ||
48
+ node.type === NodeTypes.AttrUnquoted
49
+ );
50
+ }
51
+
52
+ function staticAttributeName(node: ValuedAttribute): string | undefined {
53
+ if (
54
+ node.name.length !== 1 ||
55
+ node.name[0].type !== NodeTypes.TextNode
56
+ ) {
57
+ return undefined;
58
+ }
59
+
60
+ return node.name[0].value.toLowerCase();
61
+ }
62
+
63
+ function staticAttributeValue(node: ValuedAttribute): string | undefined {
64
+ if (
65
+ node.value.length !== 1 ||
66
+ node.value[0].type !== NodeTypes.TextNode
67
+ ) {
68
+ return undefined;
69
+ }
70
+
71
+ return node.value[0].value.toLowerCase();
72
+ }
73
+
74
+ function staticInputType(node: HtmlVoidElement): string | undefined {
75
+ for (const attribute of node.attributes) {
76
+ if (
77
+ isValuedAttribute(attribute) &&
78
+ staticAttributeName(attribute) === 'type'
79
+ ) {
80
+ return staticAttributeValue(attribute);
81
+ }
82
+ }
83
+
84
+ return undefined;
85
+ }
86
+
87
+ function isInputButtonValue(ancestors: LiquidHtmlNode[]): boolean {
88
+ let element: HtmlVoidElement | undefined;
89
+ for (let index = ancestors.length - 1; index >= 0; index -= 1) {
90
+ const ancestor = ancestors[index];
91
+ if (ancestor.type === NodeTypes.HtmlVoidElement) {
92
+ element = ancestor;
93
+ break;
94
+ }
95
+ }
96
+
97
+ return (
98
+ element?.name.toLowerCase() === 'input' &&
99
+ BUTTON_INPUT_TYPES.has(staticInputType(element) ?? '')
100
+ );
101
+ }
102
+
103
+ function rawTagAncestor(
104
+ ancestors: LiquidHtmlNode[],
105
+ ): LiquidRawTag | undefined {
106
+ for (let index = ancestors.length - 1; index >= 0; index -= 1) {
107
+ const ancestor = ancestors[index];
108
+ if (ancestor.type === NodeTypes.LiquidRawTag) return ancestor;
109
+ }
110
+
111
+ return undefined;
112
+ }
113
+
114
+ function hasMeaningfulText(value: string): boolean {
115
+ return /[\p{L}\p{N}]/u.test(
116
+ value.replace(HTML_CHARACTER_REFERENCE, ''),
117
+ );
118
+ }
119
+
120
+ function isHtmlElementName(node: TextNode, parent: LiquidHtmlNode): boolean {
121
+ if (
122
+ parent.type !== NodeTypes.HtmlElement &&
123
+ parent.type !== NodeTypes.HtmlSelfClosingElement
124
+ ) {
125
+ return false;
126
+ }
127
+
128
+ return parent.name.some((part) => part === node);
129
+ }
130
+
131
+ function isTranslation(variable: LiquidVariable): boolean {
132
+ return variable.filters.some(({ name }) => TRANSLATION_FILTERS.has(name));
133
+ }
134
+
135
+ export const HardcodedText: LiquidCheckDefinition = {
136
+ meta: {
137
+ code: 'HardcodedText',
138
+ name: 'Prevent hard-coded storefront text',
139
+ docs: {
140
+ description:
141
+ 'Reports rendered text that should come from a setting, translation, metafield, or metaobject.',
142
+ recommended: true,
143
+ },
144
+ type: SourceCodeType.LiquidHtml,
145
+ severity: Severity.WARNING,
146
+ schema: {},
147
+ },
148
+ create(context) {
149
+ function report(startIndex: number, endIndex: number) {
150
+ context.report({
151
+ message:
152
+ 'Replace hard-coded text with a section setting, translation key, metafield, or metaobject',
153
+ startIndex,
154
+ endIndex,
155
+ });
156
+ }
157
+
158
+ function reportString(string: LiquidString) {
159
+ if (!hasMeaningfulText(string.value)) return;
160
+ report(string.position.start, string.position.end);
161
+ }
162
+
163
+ function reportLiteralOutput(variable: LiquidVariable) {
164
+ if (
165
+ variable.expression.type === NodeTypes.String &&
166
+ !isTranslation(variable)
167
+ ) {
168
+ reportString(variable.expression);
169
+ }
170
+
171
+ for (
172
+ let filterIndex = 0;
173
+ filterIndex < variable.filters.length;
174
+ filterIndex += 1
175
+ ) {
176
+ const filter = variable.filters[filterIndex];
177
+ if (filter.name !== 'default') continue;
178
+
179
+ const fallbackIsTranslated = variable.filters
180
+ .slice(filterIndex + 1)
181
+ .some(({ name }) => TRANSLATION_FILTERS.has(name));
182
+ if (fallbackIsTranslated) continue;
183
+
184
+ for (const argument of filter.args) {
185
+ if (argument.type === NodeTypes.String) {
186
+ reportString(argument);
187
+ }
188
+ }
189
+ }
190
+ }
191
+
192
+ return {
193
+ async TextNode(node, ancestors) {
194
+ if (!hasMeaningfulText(node.value)) return;
195
+
196
+ const rawTag = rawTagAncestor(ancestors);
197
+ if (rawTag) {
198
+ if (EXEMPT_RAW_TAGS.has(rawTag.name.toLowerCase())) return;
199
+ report(node.position.start, node.position.end);
200
+ return;
201
+ }
202
+
203
+ const parent = ancestors.at(-1);
204
+ if (!parent || isHtmlElementName(node, parent)) return;
205
+
206
+ if (isValuedAttribute(parent)) {
207
+ if (!parent.value.some((part) => part === node)) return;
208
+
209
+ const attributeName = staticAttributeName(parent);
210
+ if (
211
+ (attributeName && TEXT_ATTRIBUTES.has(attributeName)) ||
212
+ (attributeName === 'value' &&
213
+ isInputButtonValue(ancestors))
214
+ ) {
215
+ report(node.position.start, node.position.end);
216
+ }
217
+ return;
218
+ }
219
+
220
+ if (parent.type === NodeTypes.RawMarkup) return;
221
+
222
+ if (
223
+ parent.type === NodeTypes.Document ||
224
+ parent.type === NodeTypes.HtmlElement ||
225
+ parent.type === NodeTypes.LiquidBranch ||
226
+ parent.type === NodeTypes.LiquidTag
227
+ ) {
228
+ report(node.position.start, node.position.end);
229
+ }
230
+ },
231
+
232
+ async LiquidVariableOutput(node) {
233
+ if (typeof node.markup === 'string') return;
234
+ reportLiteralOutput(node.markup);
235
+ },
236
+
237
+ async LiquidTag(node) {
238
+ if (node.name !== 'echo' || typeof node.markup === 'string') return;
239
+ reportLiteralOutput(node.markup);
240
+ },
241
+ };
242
+ },
243
+ };
@@ -0,0 +1,76 @@
1
+ import {
2
+ runLiquidCheck,
3
+ } from '@shopify/theme-check-common/dist/test/test-helper';
4
+ import { describe, expect, it } from 'vitest';
5
+
6
+ import { UnguardedMetafield } from './unguarded-metafield';
7
+
8
+ describe('UnguardedMetafield', () => {
9
+ it('reports metafield objects and their descendant values', async () => {
10
+ const source = `
11
+ {{ product.metafields.details.subtitle }}
12
+ {{ product.metafields.details.subtitle.value }}
13
+ {{ collection.metafields['details']['hero'].value }}
14
+ `;
15
+
16
+ const offenses = await runLiquidCheck(UnguardedMetafield, source);
17
+
18
+ expect(offenses).toHaveLength(3);
19
+ expect(offenses[0].message).toBe(
20
+ "The metafield 'product.metafields.details.subtitle' can be nil and is output without a presence guard or default",
21
+ );
22
+ });
23
+
24
+ it.each([
25
+ [
26
+ 'if',
27
+ '{% if product.metafields.details.subtitle %}{{ product.metafields.details.subtitle.value }}{% endif %}',
28
+ ],
29
+ [
30
+ 'unless',
31
+ '{% unless product.metafields.details.subtitle == blank %}{{ product.metafields.details.subtitle.value }}{% endunless %}',
32
+ ],
33
+ [
34
+ 'descendant value',
35
+ '{% if product.metafields.details.subtitle.value %}{{ product.metafields.details.subtitle.value }}{% endif %}',
36
+ ],
37
+ ])('accepts a metafield tested in an %s guard', async (_, source) => {
38
+ expect(await runLiquidCheck(UnguardedMetafield, source)).toEqual([]);
39
+ });
40
+
41
+ it('accepts a default filter on the output', async () => {
42
+ const source =
43
+ "{{ product.metafields.details.subtitle.value | default: 'Not provided' }}";
44
+
45
+ expect(await runLiquidCheck(UnguardedMetafield, source)).toEqual([]);
46
+ });
47
+
48
+ it('does not mistake a different metafield guard for the output guard', async () => {
49
+ const source = `
50
+ {% if product.metafields.details.title %}
51
+ {{ product.metafields.details.subtitle.value }}
52
+ {% endif %}
53
+ `;
54
+
55
+ expect(await runLiquidCheck(UnguardedMetafield, source)).toHaveLength(1);
56
+ });
57
+
58
+ it('ignores namespace collections and dynamic accesses', async () => {
59
+ const source = `
60
+ {{ product.metafields }}
61
+ {{ product.metafields.details }}
62
+ {{ product.metafields[namespace][key].value }}
63
+ `;
64
+
65
+ expect(await runLiquidCheck(UnguardedMetafield, source)).toEqual([]);
66
+ });
67
+
68
+ it('only considers variable output tags', async () => {
69
+ const source = `
70
+ {% assign subtitle = product.metafields.details.subtitle.value %}
71
+ {% echo product.metafields.details.subtitle.value %}
72
+ `;
73
+
74
+ expect(await runLiquidCheck(UnguardedMetafield, source)).toEqual([]);
75
+ });
76
+ });
@@ -0,0 +1,49 @@
1
+ import {
2
+ NodeTypes,
3
+ } from '@shopify/liquid-html-parser';
4
+ import type {
5
+ LiquidVariableLookup,
6
+ } from '@shopify/liquid-html-parser';
7
+
8
+ import {
9
+ createNullableOutputCheck,
10
+ } from '../utils/nullable-output-check';
11
+ import type {
12
+ NullableObjectReference,
13
+ } from '../utils/nullable-output-check';
14
+
15
+ export const UnguardedMetafield = createNullableOutputCheck({
16
+ code: 'UnguardedMetafield',
17
+ name: 'Require guards for metafields',
18
+ description:
19
+ 'Reports metafield output without a presence guard or default filter.',
20
+ objectName: 'metafield',
21
+ getReference: getMetafieldReference,
22
+ });
23
+
24
+ function getMetafieldReference(
25
+ node: LiquidVariableLookup,
26
+ ): NullableObjectReference | undefined {
27
+ if (!node.name) return undefined;
28
+
29
+ const [metafields, namespace, key] = node.lookups;
30
+ if (
31
+ metafields?.type !== NodeTypes.String ||
32
+ metafields.value !== 'metafields' ||
33
+ namespace?.type !== NodeTypes.String ||
34
+ key?.type !== NodeTypes.String
35
+ ) {
36
+ return undefined;
37
+ }
38
+
39
+ const name = `${node.name}.metafields.${namespace.value}.${key.value}`;
40
+ return {
41
+ key: JSON.stringify([
42
+ 'metafield',
43
+ node.name,
44
+ namespace.value,
45
+ key.value,
46
+ ]),
47
+ name,
48
+ };
49
+ }
@@ -0,0 +1,77 @@
1
+ import {
2
+ runLiquidCheck,
3
+ } from '@shopify/theme-check-common/dist/test/test-helper';
4
+ import { describe, expect, it } from 'vitest';
5
+
6
+ import { UnguardedMetaobject } from './unguarded-metaobject';
7
+
8
+ describe('UnguardedMetaobject', () => {
9
+ it('reports metaobject entries and their descendant fields', async () => {
10
+ const source = `
11
+ {{ metaobjects.testimonials.homepage }}
12
+ {{ metaobjects.testimonials.homepage.title }}
13
+ {{ metaobjects['highlights']['washable'].image.value }}
14
+ `;
15
+
16
+ const offenses = await runLiquidCheck(UnguardedMetaobject, source);
17
+
18
+ expect(offenses).toHaveLength(3);
19
+ expect(offenses[0].message).toBe(
20
+ "The metaobject 'metaobjects.testimonials.homepage' can be nil and is output without a presence guard or default",
21
+ );
22
+ });
23
+
24
+ it.each([
25
+ [
26
+ 'if',
27
+ '{% if metaobjects.testimonials.homepage %}{{ metaobjects.testimonials.homepage.title.value }}{% endif %}',
28
+ ],
29
+ [
30
+ 'unless',
31
+ '{% unless metaobjects.testimonials.homepage == nil %}{{ metaobjects.testimonials.homepage.title.value }}{% endunless %}',
32
+ ],
33
+ [
34
+ 'descendant field',
35
+ '{% if metaobjects.testimonials.homepage.title.value %}{{ metaobjects.testimonials.homepage.title.value }}{% endif %}',
36
+ ],
37
+ ])('accepts a metaobject tested in an %s guard', async (_, source) => {
38
+ expect(await runLiquidCheck(UnguardedMetaobject, source)).toEqual([]);
39
+ });
40
+
41
+ it('accepts a default filter on the output', async () => {
42
+ const source =
43
+ "{{ metaobjects.testimonials.homepage.title.value | default: 'Untitled' }}";
44
+
45
+ expect(await runLiquidCheck(UnguardedMetaobject, source)).toEqual([]);
46
+ });
47
+
48
+ it('does not mistake a different metaobject guard for the output guard', async () => {
49
+ const source = `
50
+ {% if metaobjects.testimonials.featured %}
51
+ {{ metaobjects.testimonials.homepage.title.value }}
52
+ {% endif %}
53
+ `;
54
+
55
+ expect(await runLiquidCheck(UnguardedMetaobject, source)).toHaveLength(1);
56
+ });
57
+
58
+ it('ignores type collections, dynamic accesses, and the current template metaobject', async () => {
59
+ const source = `
60
+ {{ metaobjects }}
61
+ {{ metaobjects.testimonials }}
62
+ {{ metaobjects[type][handle].title.value }}
63
+ {{ metaobject.title.value }}
64
+ `;
65
+
66
+ expect(await runLiquidCheck(UnguardedMetaobject, source)).toEqual([]);
67
+ });
68
+
69
+ it('only considers variable output tags', async () => {
70
+ const source = `
71
+ {% assign testimonial = metaobjects.testimonials.homepage %}
72
+ {% echo metaobjects.testimonials.homepage.title.value %}
73
+ `;
74
+
75
+ expect(await runLiquidCheck(UnguardedMetaobject, source)).toEqual([]);
76
+ });
77
+ });
@@ -0,0 +1,42 @@
1
+ import {
2
+ NodeTypes,
3
+ } from '@shopify/liquid-html-parser';
4
+ import type {
5
+ LiquidVariableLookup,
6
+ } from '@shopify/liquid-html-parser';
7
+
8
+ import {
9
+ createNullableOutputCheck,
10
+ } from '../utils/nullable-output-check';
11
+ import type {
12
+ NullableObjectReference,
13
+ } from '../utils/nullable-output-check';
14
+
15
+ export const UnguardedMetaobject = createNullableOutputCheck({
16
+ code: 'UnguardedMetaobject',
17
+ name: 'Require guards for metaobjects',
18
+ description:
19
+ 'Reports metaobject output without a presence guard or default filter.',
20
+ objectName: 'metaobject',
21
+ getReference: getMetaobjectReference,
22
+ });
23
+
24
+ function getMetaobjectReference(
25
+ node: LiquidVariableLookup,
26
+ ): NullableObjectReference | undefined {
27
+ if (node.name !== 'metaobjects') return undefined;
28
+
29
+ const [type, handle] = node.lookups;
30
+ if (
31
+ type?.type !== NodeTypes.String ||
32
+ handle?.type !== NodeTypes.String
33
+ ) {
34
+ return undefined;
35
+ }
36
+
37
+ const name = `metaobjects.${type.value}.${handle.value}`;
38
+ return {
39
+ key: JSON.stringify(['metaobject', type.value, handle.value]),
40
+ name,
41
+ };
42
+ }