td-ai-tools 1.2.6 → 1.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "td-ai-tools",
3
- "version": "1.2.6",
3
+ "version": "1.2.9",
4
4
  "description": "Install agent skills and packs into your project",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: shopify-lint
3
- version: 1.3.0
3
+ version: 1.3.2
4
4
  description: Run Shopify CLI Theme Check with Theory Digital's bundled custom checks, including warnings for hard-coded storefront text and script or style tags in Liquid, while reporting and failing only on offenses in files modified on the current Git branch. Use when Codex needs to lint a Shopify theme, validate branch-scoped Liquid or theme changes, enforce Theory theme rules, or avoid surfacing pre-existing Theme Check offenses from untouched files.
5
5
  ---
6
6
 
@@ -17,7 +17,7 @@ bash .agents/skills/shopify-lint/scripts/setup.sh
17
17
  Installing with `td-ai-tools install --setup shopify-lint` runs this automatically. The script:
18
18
 
19
19
  1. Runs `npm install && npm run build` inside `theme-check-theory/`, producing `dist/index.js` — the CommonJS entry point the root `.theme-check.yml` requires.
20
- 2. Writes a `.theme-check.yml` at the project root wiring in the bundled checks. If one already exists it is left untouched; ensure its `require:` list includes `./.agents/skills/shopify-lint/theme-check-theory` and both `DisallowedScriptOrStyleTag` and `HardcodedText` are enabled.
20
+ 2. Writes a `.theme-check.yml` at the project root wiring in the bundled checks. Setup replaces any existing file with the skill's current configuration so installs and updates cannot retain stale or incomplete settings.
21
21
  3. Through the installer's shared project-hook setup, installs matching Claude and Codex Stop hooks and merges their registrations into `.claude/settings.json` and `.codex/hooks.json` without replacing unrelated settings or hooks. Re-running setup updates the Shopify Lint hook in place instead of duplicating it.
22
22
 
23
23
  The Stop hooks run this skill's branch-scoped lint before handoff, return modified-file offenses to the agent, and allow handoff after three unsuccessful fix rounds. They require `jq`, `python3`, and Shopify CLI to be available when the hook runs. Directly running `scripts/setup.sh` performs the package build and Theme Check configuration; use `td-ai-tools install --setup` or `td-ai-tools update --setup` to apply the shared hook manifest as well.
@@ -32,7 +32,7 @@ Run the bundled Python script from the Shopify theme root:
32
32
  python3 .agents/skills/shopify-lint/scripts/shopify_lint.py --path .
33
33
  ```
34
34
 
35
- The script runs `shopify theme check --output json`, identifies files changed since the current branch's merge-base, adds staged, unstaged, and untracked files, and emits only reports whose paths are in that set. The root `.theme-check.yml` directly requires the bundled `theme-check-theory` package from this skill directory. `DisallowedScriptOrStyleTag` reports HTML `<script>` and `<style>` tags in modified Liquid files. `HardcodedText` reports rendered hard-coded copy, except content inside Liquid `stylesheet`, `javascript`, and `schema` tags.
35
+ The script runs `shopify theme check --output json`, identifies files changed since the current branch's merge-base, adds staged, unstaged, and untracked files, and emits only reports whose paths are in that set. The root `.theme-check.yml` directly requires the bundled `theme-check-theory` package from this skill directory. `DisallowedScriptOrStyleTag` reports inline executable `<script>` and all `<style>` tags in modified Liquid files while allowing external-source and JSON-data scripts. `HardcodedText` reports rendered hard-coded copy, except content inside Liquid `stylesheet`, `javascript`, and `schema` tags.
36
36
 
37
37
  ## Workflow
38
38
 
@@ -21,7 +21,7 @@ cd "$PKG_DIR"
21
21
  npm install
22
22
  npm run build
23
23
 
24
- # 2. Generate the project-root .theme-check.yml.
24
+ # 2. Write the project-root .theme-check.yml.
25
25
  #
26
26
  # The installed layout is <project>/<.agents|.claude>/skills/shopify-lint, so
27
27
  # the project root is three levels above the skill directory. Guard against
@@ -37,12 +37,6 @@ fi
37
37
  PROJECT_ROOT="$(dirname "$TARGET_DIR")"
38
38
  THEME_CHECK_FILE="$PROJECT_ROOT/.theme-check.yml"
39
39
 
40
- if [[ -f "$THEME_CHECK_FILE" ]]; then
41
- echo "shopify-lint setup: $THEME_CHECK_FILE already exists; leaving it unchanged."
42
- echo " Ensure its 'require:' list includes ./.agents/skills/shopify-lint/theme-check-theory"
43
- exit 0
44
- fi
45
-
46
40
  cat > "$THEME_CHECK_FILE" <<'YAML'
47
41
  extends:
48
42
  - theme-check:recommended
@@ -75,4 +69,4 @@ UnguardedMetaobject:
75
69
  severity: warning
76
70
  YAML
77
71
 
78
- echo "shopify-lint setup: wrote $THEME_CHECK_FILE"
72
+ echo "shopify-lint setup: wrote $THEME_CHECK_FILE (replacing any existing configuration)"
@@ -11,7 +11,7 @@ integrated into Shopify CLI. It is not compatible with the archived Ruby
11
11
 
12
12
  | Code | Severity | What it catches |
13
13
  | --- | --- | --- |
14
- | `DisallowedScriptOrStyleTag` | warning | An HTML `<script>` or `<style>` tag in a Liquid file. |
14
+ | `DisallowedScriptOrStyleTag` | warning | An inline executable `<script>` or any `<style>` tag in a Liquid file. |
15
15
  | `HardcodedText` | warning | Rendered hard-coded storefront copy that should use a dynamic content source. |
16
16
  | `UnusedSectionSettings` | warning | A setting declared in `{% schema %}` that is never referenced in the file. |
17
17
  | `UnguardedNullableSetting` | warning | A potentially blank setting output with `{{ }}` and no presence guard (`{% if %}`/`{% unless %}`) or `\| default`. |
@@ -22,20 +22,28 @@ integrated into Shopify CLI. It is not compatible with the archived Ruby
22
22
 
23
23
  HTML `<script>` and `<style>` tags keep JavaScript and CSS embedded in Liquid
24
24
  files. Move their contents to theme assets or the appropriate Shopify Liquid
25
- tags instead. HTML comments and similarly named elements such as
26
- `<script-template>` are ignored.
25
+ tags instead. Scripts with a non-empty `src` attribute and scripts declared as
26
+ JSON data, including `application/json` and `application/ld+json`, are allowed.
27
+ HTML comments and similarly named elements such as `<script-template>` are
28
+ ignored.
27
29
 
28
30
  ### HardcodedText
29
31
 
30
32
  Rendered storefront copy should come from a section setting, translation key,
31
33
  metafield, or metaobject. The check reports text nodes, direct quoted output
32
- with `{{ }}` or `{% echo %}`, and hard-coded `alt`, `title`, `placeholder`, and
33
- ARIA text attributes. It ignores technical attributes such as classes and IDs,
34
- Liquid logic strings, comments, and punctuation-only fragments.
34
+ with `{{ }}` or `{% echo %}`, quoted `default` filter fallbacks, hard-coded
35
+ `alt`, `title`, `placeholder`, and ARIA text attributes, and labels in the
36
+ `value` attribute of button-like inputs. Text inside inline SVG elements and
37
+ string literals passed as named `render` or `include` arguments are also
38
+ checked. It ignores technical attributes such as classes and IDs, Liquid logic
39
+ strings, comments, punctuation-only fragments, and text made entirely of HTML
40
+ character references.
35
41
 
36
42
  Content inside `{% stylesheet %}`, `{% javascript %}`, and `{% schema %}` is
37
43
  ignored. A quoted translation key passed through `t` or `translate` is also
38
- accepted.
44
+ accepted. Short alphabetic tokens and copyright years remain reportable because
45
+ the check cannot reliably distinguish user-facing copy from currency codes,
46
+ units, or other intentional literals.
39
47
 
40
48
  ### UnusedSectionSettings
41
49
 
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "theme-check-theory",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "theme-check-theory",
9
- "version": "0.2.0",
9
+ "version": "0.2.1",
10
10
  "license": "UNLICENSED",
11
11
  "dependencies": {
12
12
  "@shopify/liquid-html-parser": "2.9.2",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "theme-check-theory",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Theory Digital's custom Shopify Theme Check rules",
5
5
  "type": "commonjs",
6
6
  "main": "dist/index.js",
@@ -8,7 +8,7 @@ import { DisallowedScriptOrStyleTag } from './disallowed-script-or-style-tag';
8
8
  describe('DisallowedScriptOrStyleTag', () => {
9
9
  it('reports script and style tags', async () => {
10
10
  const source = `
11
- <script src="{{ 'theme.js' | asset_url }}" defer></script>
11
+ <script>window.example = true;</script>
12
12
  <style>.example { color: red; }</style>
13
13
  `;
14
14
 
@@ -26,7 +26,7 @@ describe('DisallowedScriptOrStyleTag', () => {
26
26
 
27
27
  it('reports case variants and self-closing tags', async () => {
28
28
  const source = `
29
- <SCRIPT type="application/json">{}</SCRIPT>
29
+ <SCRIPT type="module">window.example = true;</SCRIPT>
30
30
  <STYLE />
31
31
  `;
32
32
 
@@ -39,6 +39,39 @@ describe('DisallowedScriptOrStyleTag', () => {
39
39
  ]);
40
40
  });
41
41
 
42
+ it('ignores scripts with an external source', async () => {
43
+ const source = `
44
+ <script src="{{ 'theme.js' | asset_url }}" defer></script>
45
+ <SCRIPT SRC="https://cdn.example.com/widget.js"></SCRIPT>
46
+ `;
47
+
48
+ expect(
49
+ await runLiquidCheck(DisallowedScriptOrStyleTag, source),
50
+ ).toEqual([]);
51
+ });
52
+
53
+ it('ignores scripts containing JSON data', async () => {
54
+ const source = `
55
+ <script type="application/ld+json">{"@type":"Organization"}</script>
56
+ <script type="application/json">{"enabled":true}</script>
57
+ `;
58
+
59
+ expect(
60
+ await runLiquidCheck(DisallowedScriptOrStyleTag, source),
61
+ ).toEqual([]);
62
+ });
63
+
64
+ it('reports executable scripts with an empty src', async () => {
65
+ const source = `
66
+ <script src="">window.example = true;</script>
67
+ <script type="text/javascript">window.example = true;</script>
68
+ `;
69
+
70
+ expect(
71
+ await runLiquidCheck(DisallowedScriptOrStyleTag, source),
72
+ ).toHaveLength(2);
73
+ });
74
+
42
75
  it('ignores tags in comments and similarly named elements', async () => {
43
76
  const source = `
44
77
  <!-- <script></script> -->
@@ -1,5 +1,7 @@
1
1
  import {
2
+ AttributeNode,
2
3
  HtmlElement,
4
+ HtmlRawNode,
3
5
  HtmlSelfClosingElement,
4
6
  NodeTypes,
5
7
  } from '@shopify/liquid-html-parser';
@@ -12,6 +14,79 @@ import {
12
14
  const DISALLOWED_TAGS = new Set(['script', 'style']);
13
15
 
14
16
  type NamedHtmlNode = HtmlElement | HtmlSelfClosingElement;
17
+ type ScriptNode = HtmlRawNode | NamedHtmlNode;
18
+ type ValuedAttribute = Extract<AttributeNode, { value: unknown }>;
19
+
20
+ function isValuedAttribute(
21
+ attribute: AttributeNode,
22
+ ): attribute is ValuedAttribute {
23
+ return (
24
+ attribute.type === NodeTypes.AttrDoubleQuoted ||
25
+ attribute.type === NodeTypes.AttrSingleQuoted ||
26
+ attribute.type === NodeTypes.AttrUnquoted
27
+ );
28
+ }
29
+
30
+ function staticAttributeName(attribute: ValuedAttribute): string | null {
31
+ if (
32
+ attribute.name.length !== 1 ||
33
+ attribute.name[0].type !== NodeTypes.TextNode
34
+ ) {
35
+ return null;
36
+ }
37
+
38
+ return attribute.name[0].value.toLowerCase();
39
+ }
40
+
41
+ function staticAttributeValue(
42
+ node: ScriptNode,
43
+ attributeName: string,
44
+ ): string | null {
45
+ const attribute = node.attributes.find(
46
+ (candidate) =>
47
+ isValuedAttribute(candidate) &&
48
+ staticAttributeName(candidate) === attributeName,
49
+ );
50
+ if (
51
+ !attribute ||
52
+ !isValuedAttribute(attribute) ||
53
+ !attribute.value.every(({ type }) => type === NodeTypes.TextNode)
54
+ ) {
55
+ return null;
56
+ }
57
+
58
+ return attribute.value
59
+ .map((part) => ('value' in part ? part.value : ''))
60
+ .join('')
61
+ .trim()
62
+ .toLowerCase();
63
+ }
64
+
65
+ function hasExternalSource(node: ScriptNode): boolean {
66
+ return node.attributes.some(
67
+ (attribute) =>
68
+ isValuedAttribute(attribute) &&
69
+ staticAttributeName(attribute) === 'src' &&
70
+ attribute.value.length > 0,
71
+ );
72
+ }
73
+
74
+ function containsJsonData(node: ScriptNode): boolean {
75
+ const type = staticAttributeValue(node, 'type');
76
+ if (!type) return false;
77
+
78
+ const mimeType = type.split(';', 1)[0].trim();
79
+ return (
80
+ mimeType.endsWith('/json') ||
81
+ mimeType.endsWith('+json') ||
82
+ mimeType === 'importmap' ||
83
+ mimeType === 'speculationrules'
84
+ );
85
+ }
86
+
87
+ function isAllowedScript(node: ScriptNode): boolean {
88
+ return hasExternalSource(node) || containsJsonData(node);
89
+ }
15
90
 
16
91
  function staticTagName(node: NamedHtmlNode): string | null {
17
92
  if (
@@ -29,7 +104,8 @@ export const DisallowedScriptOrStyleTag: LiquidCheckDefinition = {
29
104
  code: 'DisallowedScriptOrStyleTag',
30
105
  name: 'Prevent script and style tags in Liquid',
31
106
  docs: {
32
- description: 'Reports HTML script and style tags in Liquid files.',
107
+ description:
108
+ 'Reports inline executable script and style tags in Liquid files.',
33
109
  recommended: true,
34
110
  },
35
111
  type: SourceCodeType.LiquidHtml,
@@ -50,6 +126,7 @@ export const DisallowedScriptOrStyleTag: LiquidCheckDefinition = {
50
126
  function checkNamedNode(node: NamedHtmlNode) {
51
127
  const tagName = staticTagName(node);
52
128
  if (!tagName) return;
129
+ if (tagName === 'script' && isAllowedScript(node)) return;
53
130
 
54
131
  report(
55
132
  tagName,
@@ -60,6 +137,13 @@ export const DisallowedScriptOrStyleTag: LiquidCheckDefinition = {
60
137
 
61
138
  return {
62
139
  async HtmlRawNode(node) {
140
+ if (
141
+ node.name.toLowerCase() === 'script' &&
142
+ isAllowedScript(node)
143
+ ) {
144
+ return;
145
+ }
146
+
63
147
  report(
64
148
  node.name.toLowerCase(),
65
149
  node.blockStartPosition.start,
@@ -41,6 +41,28 @@ describe('HardcodedText', () => {
41
41
  expect(offenses).toHaveLength(4);
42
42
  });
43
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
+
44
66
  it('reports static fragments in otherwise dynamic rendered text', async () => {
45
67
  const source = `
46
68
  <p>Welcome, {{ customer.name }}!</p>
@@ -52,6 +74,60 @@ describe('HardcodedText', () => {
52
74
  expect(offenses).toHaveLength(2);
53
75
  });
54
76
 
77
+ it('reports hard-coded text inside inline SVG elements', async () => {
78
+ const source = `
79
+ <svg aria-hidden="true">
80
+ <title>Search icon</title>
81
+ <desc>Opens the search dialog</desc>
82
+ <text x="10" y="20">Search</text>
83
+ <path d="M10 10 H 90 V 90 H 10 Z" />
84
+ </svg>
85
+ `;
86
+
87
+ const offenses = await runLiquidCheck(HardcodedText, source);
88
+
89
+ expect(offenses).toHaveLength(3);
90
+ expect(
91
+ offenses.map(({ start, end }) =>
92
+ source.slice(start.index, end.index),
93
+ ),
94
+ ).toEqual([
95
+ 'Search icon',
96
+ 'Opens the search dialog',
97
+ 'Search',
98
+ ]);
99
+ });
100
+
101
+ it('reports string literals passed as render and include arguments', async () => {
102
+ const source = `
103
+ {% render 'button', label: 'Add to cart' %}
104
+ {% include 'icon', accessible_name: "Search icon" %}
105
+ {% render 'button', label: section.settings.button_label %}
106
+ {% render 'icon' %}
107
+ `;
108
+
109
+ const offenses = await runLiquidCheck(HardcodedText, source);
110
+
111
+ expect(offenses).toHaveLength(2);
112
+ expect(
113
+ offenses.map(({ start, end }) =>
114
+ source.slice(start.index, end.index),
115
+ ),
116
+ ).toEqual(["'Add to cart'", '"Search icon"']);
117
+ });
118
+
119
+ it('reports hard-coded default filter fallbacks', async () => {
120
+ const source = `
121
+ {{ product.title | default: 'Free shipping on all orders' }}
122
+ {% echo section.settings.label | default: "Shop now" %}
123
+ {{ translated_title | t | default: 'Fallback after translation' }}
124
+ `;
125
+
126
+ const offenses = await runLiquidCheck(HardcodedText, source);
127
+
128
+ expect(offenses).toHaveLength(3);
129
+ });
130
+
55
131
  it('accepts settings, translation keys, metafields, and metaobjects', async () => {
56
132
  const source = `
57
133
  <h2>{{ section.settings.heading }}</h2>
@@ -59,12 +135,23 @@ describe('HardcodedText', () => {
59
135
  <p>{{ "general.subtitle" | translate }}</p>
60
136
  <p>{{ product.metafields.content.description }}</p>
61
137
  <p>{{ metaobjects.testimonials.homepage.quote }}</p>
138
+ <p>{{ product.title | default: 'products.fallback' | t }}</p>
62
139
  <img alt="{{ section.settings.image_alt }}" src="{{ section.settings.image | image_url }}">
63
140
  `;
64
141
 
65
142
  expect(await runLiquidCheck(HardcodedText, source)).toEqual([]);
66
143
  });
67
144
 
145
+ it('ignores entity-only text while still reporting surrounding copy', async () => {
146
+ const entities = `
147
+ &nbsp; &times; &mdash; &hellip; &copy; &#169; &#x00a0;
148
+ `;
149
+ const copy = '&nbsp;Welcome&nbsp;';
150
+
151
+ expect(await runLiquidCheck(HardcodedText, entities)).toEqual([]);
152
+ expect(await runLiquidCheck(HardcodedText, copy)).toHaveLength(1);
153
+ });
154
+
68
155
  it.each([
69
156
  [
70
157
  'stylesheet',
@@ -1,17 +1,22 @@
1
1
  import {
2
2
  NodeTypes,
3
+ toLiquidHtmlAST,
3
4
  } from '@shopify/liquid-html-parser';
4
5
  import type {
5
6
  AttributeNode,
7
+ HtmlVoidElement,
6
8
  LiquidHtmlNode,
7
9
  LiquidRawTag,
10
+ LiquidString,
8
11
  LiquidVariable,
12
+ RawMarkup,
9
13
  TextNode,
10
14
  } from '@shopify/liquid-html-parser';
11
15
  import {
12
16
  LiquidCheckDefinition,
13
17
  Severity,
14
18
  SourceCodeType,
19
+ visit,
15
20
  } from '@shopify/theme-check-common';
16
21
 
17
22
  const EXEMPT_RAW_TAGS = new Set([
@@ -32,6 +37,11 @@ const TEXT_ATTRIBUTES = new Set([
32
37
 
33
38
  const TRANSLATION_FILTERS = new Set(['t', 'translate']);
34
39
 
40
+ const BUTTON_INPUT_TYPES = new Set(['button', 'reset', 'submit']);
41
+
42
+ const HTML_CHARACTER_REFERENCE =
43
+ /&(?:#\d+|#x[\da-f]+|[a-z][\da-z]+);/giu;
44
+
35
45
  type ValuedAttribute = Extract<AttributeNode, { value: unknown }>;
36
46
 
37
47
  function isValuedAttribute(node: LiquidHtmlNode): node is ValuedAttribute {
@@ -53,6 +63,46 @@ function staticAttributeName(node: ValuedAttribute): string | undefined {
53
63
  return node.name[0].value.toLowerCase();
54
64
  }
55
65
 
66
+ function staticAttributeValue(node: ValuedAttribute): string | undefined {
67
+ if (
68
+ node.value.length !== 1 ||
69
+ node.value[0].type !== NodeTypes.TextNode
70
+ ) {
71
+ return undefined;
72
+ }
73
+
74
+ return node.value[0].value.toLowerCase();
75
+ }
76
+
77
+ function staticInputType(node: HtmlVoidElement): string | undefined {
78
+ for (const attribute of node.attributes) {
79
+ if (
80
+ isValuedAttribute(attribute) &&
81
+ staticAttributeName(attribute) === 'type'
82
+ ) {
83
+ return staticAttributeValue(attribute);
84
+ }
85
+ }
86
+
87
+ return undefined;
88
+ }
89
+
90
+ function isInputButtonValue(ancestors: LiquidHtmlNode[]): boolean {
91
+ let element: HtmlVoidElement | undefined;
92
+ for (let index = ancestors.length - 1; index >= 0; index -= 1) {
93
+ const ancestor = ancestors[index];
94
+ if (ancestor.type === NodeTypes.HtmlVoidElement) {
95
+ element = ancestor;
96
+ break;
97
+ }
98
+ }
99
+
100
+ return (
101
+ element?.name.toLowerCase() === 'input' &&
102
+ BUTTON_INPUT_TYPES.has(staticInputType(element) ?? '')
103
+ );
104
+ }
105
+
56
106
  function rawTagAncestor(
57
107
  ancestors: LiquidHtmlNode[],
58
108
  ): LiquidRawTag | undefined {
@@ -64,8 +114,18 @@ function rawTagAncestor(
64
114
  return undefined;
65
115
  }
66
116
 
117
+ function isInlineSvgMarkup(ancestors: LiquidHtmlNode[]): boolean {
118
+ const parent = ancestors.at(-1);
119
+ return (
120
+ parent?.type === NodeTypes.HtmlRawNode &&
121
+ parent.name.toLowerCase() === 'svg'
122
+ );
123
+ }
124
+
67
125
  function hasMeaningfulText(value: string): boolean {
68
- return /[\p{L}\p{N}]/u.test(value);
126
+ return /[\p{L}\p{N}]/u.test(
127
+ value.replace(HTML_CHARACTER_REFERENCE, ''),
128
+ );
69
129
  }
70
130
 
71
131
  function isHtmlElementName(node: TextNode, parent: LiquidHtmlNode): boolean {
@@ -106,55 +166,114 @@ export const HardcodedText: LiquidCheckDefinition = {
106
166
  });
107
167
  }
108
168
 
169
+ function reportString(string: LiquidString) {
170
+ if (!hasMeaningfulText(string.value)) return;
171
+ report(string.position.start, string.position.end);
172
+ }
173
+
109
174
  function reportLiteralOutput(variable: LiquidVariable) {
110
175
  if (
111
- variable.expression.type !== NodeTypes.String ||
112
- !hasMeaningfulText(variable.expression.value) ||
113
- isTranslation(variable)
176
+ variable.expression.type === NodeTypes.String &&
177
+ !isTranslation(variable)
114
178
  ) {
115
- return;
179
+ reportString(variable.expression);
116
180
  }
117
181
 
118
- report(
119
- variable.expression.position.start,
120
- variable.expression.position.end,
121
- );
122
- }
182
+ for (
183
+ let filterIndex = 0;
184
+ filterIndex < variable.filters.length;
185
+ filterIndex += 1
186
+ ) {
187
+ const filter = variable.filters[filterIndex];
188
+ if (filter.name !== 'default') continue;
123
189
 
124
- return {
125
- async TextNode(node, ancestors) {
126
- if (!hasMeaningfulText(node.value)) return;
190
+ const fallbackIsTranslated = variable.filters
191
+ .slice(filterIndex + 1)
192
+ .some(({ name }) => TRANSLATION_FILTERS.has(name));
193
+ if (fallbackIsTranslated) continue;
127
194
 
128
- const rawTag = rawTagAncestor(ancestors);
129
- if (rawTag) {
130
- if (EXEMPT_RAW_TAGS.has(rawTag.name.toLowerCase())) return;
131
- report(node.position.start, node.position.end);
132
- return;
195
+ for (const argument of filter.args) {
196
+ if (argument.type === NodeTypes.String) {
197
+ reportString(argument);
198
+ }
133
199
  }
200
+ }
201
+ }
134
202
 
135
- const parent = ancestors.at(-1);
136
- if (!parent || isHtmlElementName(node, parent)) return;
203
+ function reportTextNode(
204
+ node: TextNode,
205
+ ancestors: LiquidHtmlNode[],
206
+ positionOffset = 0,
207
+ ) {
208
+ if (!hasMeaningfulText(node.value)) return;
137
209
 
138
- if (isValuedAttribute(parent)) {
139
- if (!parent.value.some((part) => part === node)) return;
210
+ const rawTag = rawTagAncestor(ancestors);
211
+ if (rawTag) {
212
+ if (EXEMPT_RAW_TAGS.has(rawTag.name.toLowerCase())) return;
213
+ report(
214
+ positionOffset + node.position.start,
215
+ positionOffset + node.position.end,
216
+ );
217
+ return;
218
+ }
140
219
 
141
- const attributeName = staticAttributeName(parent);
142
- if (attributeName && TEXT_ATTRIBUTES.has(attributeName)) {
143
- report(node.position.start, node.position.end);
144
- }
145
- return;
146
- }
220
+ const parent = ancestors.at(-1);
221
+ if (!parent || isHtmlElementName(node, parent)) return;
147
222
 
148
- if (parent.type === NodeTypes.RawMarkup) return;
223
+ if (isValuedAttribute(parent)) {
224
+ if (!parent.value.some((part) => part === node)) return;
149
225
 
226
+ const attributeName = staticAttributeName(parent);
150
227
  if (
151
- parent.type === NodeTypes.Document ||
152
- parent.type === NodeTypes.HtmlElement ||
153
- parent.type === NodeTypes.LiquidBranch ||
154
- parent.type === NodeTypes.LiquidTag
228
+ (attributeName && TEXT_ATTRIBUTES.has(attributeName)) ||
229
+ (attributeName === 'value' &&
230
+ isInputButtonValue(ancestors))
155
231
  ) {
156
- report(node.position.start, node.position.end);
232
+ report(
233
+ positionOffset + node.position.start,
234
+ positionOffset + node.position.end,
235
+ );
157
236
  }
237
+ return;
238
+ }
239
+
240
+ if (parent.type === NodeTypes.RawMarkup) return;
241
+
242
+ if (
243
+ parent.type === NodeTypes.Document ||
244
+ parent.type === NodeTypes.HtmlElement ||
245
+ parent.type === NodeTypes.LiquidBranch ||
246
+ parent.type === NodeTypes.LiquidTag
247
+ ) {
248
+ report(
249
+ positionOffset + node.position.start,
250
+ positionOffset + node.position.end,
251
+ );
252
+ }
253
+ }
254
+
255
+ function reportInlineSvgText(node: RawMarkup) {
256
+ let fragment;
257
+ try {
258
+ fragment = toLiquidHtmlAST(node.value);
259
+ } catch {
260
+ return;
261
+ }
262
+
263
+ visit<SourceCodeType.LiquidHtml, void>(fragment, {
264
+ TextNode(textNode, ancestors) {
265
+ reportTextNode(
266
+ textNode,
267
+ ancestors,
268
+ node.position.start,
269
+ );
270
+ },
271
+ });
272
+ }
273
+
274
+ return {
275
+ async TextNode(node, ancestors) {
276
+ reportTextNode(node, ancestors);
158
277
  },
159
278
 
160
279
  async LiquidVariableOutput(node) {
@@ -166,6 +285,31 @@ export const HardcodedText: LiquidCheckDefinition = {
166
285
  if (node.name !== 'echo' || typeof node.markup === 'string') return;
167
286
  reportLiteralOutput(node.markup);
168
287
  },
288
+
289
+ async RawMarkup(node, ancestors) {
290
+ if (!isInlineSvgMarkup(ancestors)) return;
291
+
292
+ const rawTag = rawTagAncestor(ancestors);
293
+ if (
294
+ rawTag &&
295
+ EXEMPT_RAW_TAGS.has(rawTag.name.toLowerCase())
296
+ ) {
297
+ return;
298
+ }
299
+
300
+ reportInlineSvgText(node);
301
+ },
302
+
303
+ async RenderMarkup(node) {
304
+ for (const argument of node.args) {
305
+ if (
306
+ argument.type === NodeTypes.NamedArgument &&
307
+ argument.value.type === NodeTypes.String
308
+ ) {
309
+ reportString(argument.value);
310
+ }
311
+ }
312
+ },
169
313
  };
170
314
  },
171
315
  };