td-ai-tools 1.2.6 → 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.
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.8",
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,26 @@ 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. It ignores technical attributes such
37
+ as classes and IDs, Liquid logic strings, comments, punctuation-only fragments,
38
+ and text made entirely of HTML character references.
35
39
 
36
40
  Content inside `{% stylesheet %}`, `{% javascript %}`, and `{% schema %}` is
37
41
  ignored. A quoted translation key passed through `t` or `translate` is also
38
- accepted.
42
+ accepted. Short alphabetic tokens and copyright years remain reportable because
43
+ the check cannot reliably distinguish user-facing copy from currency codes,
44
+ units, or other intentional literals.
39
45
 
40
46
  ### UnusedSectionSettings
41
47
 
@@ -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,18 @@ describe('HardcodedText', () => {
52
74
  expect(offenses).toHaveLength(2);
53
75
  });
54
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
+
55
89
  it('accepts settings, translation keys, metafields, and metaobjects', async () => {
56
90
  const source = `
57
91
  <h2>{{ section.settings.heading }}</h2>
@@ -59,12 +93,23 @@ describe('HardcodedText', () => {
59
93
  <p>{{ "general.subtitle" | translate }}</p>
60
94
  <p>{{ product.metafields.content.description }}</p>
61
95
  <p>{{ metaobjects.testimonials.homepage.quote }}</p>
96
+ <p>{{ product.title | default: 'products.fallback' | t }}</p>
62
97
  <img alt="{{ section.settings.image_alt }}" src="{{ section.settings.image | image_url }}">
63
98
  `;
64
99
 
65
100
  expect(await runLiquidCheck(HardcodedText, source)).toEqual([]);
66
101
  });
67
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
+
68
113
  it.each([
69
114
  [
70
115
  'stylesheet',
@@ -3,8 +3,10 @@ import {
3
3
  } from '@shopify/liquid-html-parser';
4
4
  import type {
5
5
  AttributeNode,
6
+ HtmlVoidElement,
6
7
  LiquidHtmlNode,
7
8
  LiquidRawTag,
9
+ LiquidString,
8
10
  LiquidVariable,
9
11
  TextNode,
10
12
  } from '@shopify/liquid-html-parser';
@@ -32,6 +34,11 @@ const TEXT_ATTRIBUTES = new Set([
32
34
 
33
35
  const TRANSLATION_FILTERS = new Set(['t', 'translate']);
34
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
+
35
42
  type ValuedAttribute = Extract<AttributeNode, { value: unknown }>;
36
43
 
37
44
  function isValuedAttribute(node: LiquidHtmlNode): node is ValuedAttribute {
@@ -53,6 +60,46 @@ function staticAttributeName(node: ValuedAttribute): string | undefined {
53
60
  return node.name[0].value.toLowerCase();
54
61
  }
55
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
+
56
103
  function rawTagAncestor(
57
104
  ancestors: LiquidHtmlNode[],
58
105
  ): LiquidRawTag | undefined {
@@ -65,7 +112,9 @@ function rawTagAncestor(
65
112
  }
66
113
 
67
114
  function hasMeaningfulText(value: string): boolean {
68
- return /[\p{L}\p{N}]/u.test(value);
115
+ return /[\p{L}\p{N}]/u.test(
116
+ value.replace(HTML_CHARACTER_REFERENCE, ''),
117
+ );
69
118
  }
70
119
 
71
120
  function isHtmlElementName(node: TextNode, parent: LiquidHtmlNode): boolean {
@@ -106,19 +155,38 @@ export const HardcodedText: LiquidCheckDefinition = {
106
155
  });
107
156
  }
108
157
 
158
+ function reportString(string: LiquidString) {
159
+ if (!hasMeaningfulText(string.value)) return;
160
+ report(string.position.start, string.position.end);
161
+ }
162
+
109
163
  function reportLiteralOutput(variable: LiquidVariable) {
110
164
  if (
111
- variable.expression.type !== NodeTypes.String ||
112
- !hasMeaningfulText(variable.expression.value) ||
113
- isTranslation(variable)
165
+ variable.expression.type === NodeTypes.String &&
166
+ !isTranslation(variable)
114
167
  ) {
115
- return;
168
+ reportString(variable.expression);
116
169
  }
117
170
 
118
- report(
119
- variable.expression.position.start,
120
- variable.expression.position.end,
121
- );
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
+ }
122
190
  }
123
191
 
124
192
  return {
@@ -139,7 +207,11 @@ export const HardcodedText: LiquidCheckDefinition = {
139
207
  if (!parent.value.some((part) => part === node)) return;
140
208
 
141
209
  const attributeName = staticAttributeName(parent);
142
- if (attributeName && TEXT_ATTRIBUTES.has(attributeName)) {
210
+ if (
211
+ (attributeName && TEXT_ATTRIBUTES.has(attributeName)) ||
212
+ (attributeName === 'value' &&
213
+ isInputButtonValue(ancestors))
214
+ ) {
143
215
  report(node.position.start, node.position.end);
144
216
  }
145
217
  return;