td-ai-tools 1.3.3 → 1.3.5
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/shopify-lint/SKILL.md +2 -2
- package/skills/shopify-lint/scripts/__pycache__/shopify_lint.cpython-312.pyc +0 -0
- package/skills/shopify-lint/scripts/shopify_lint.py +39 -8
- package/skills/shopify-lint/tests/__pycache__/test_shopify_lint.cpython-312.pyc +0 -0
- package/skills/shopify-lint/tests/test_shopify_lint.py +68 -13
- package/skills/shopify-lint/theme-check-theory/.theme-check.example.yml +4 -0
- package/skills/shopify-lint/theme-check-theory/README.md +50 -2
- package/skills/shopify-lint/theme-check-theory/configs/recommended.yml +4 -0
- package/skills/shopify-lint/theme-check-theory/src/checks/hardcoded-text.test.ts +38 -10
- package/skills/shopify-lint/theme-check-theory/src/checks/hardcoded-text.ts +8 -11
- package/skills/shopify-lint/theme-check-theory/src/checks/required-liquid-doc.test.ts +46 -0
- package/skills/shopify-lint/theme-check-theory/src/checks/required-liquid-doc.ts +111 -0
- package/skills/shopify-lint/theme-check-theory/src/index.test.ts +2 -0
- package/skills/shopify-lint/theme-check-theory/src/index.ts +3 -0
- package/skills/visual-regression/scripts/__pycache__/visual_regression.cpython-312.pyc +0 -0
- package/skills/shopify-lint/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
- package/skills/shopify-pre-pr/scripts/__pycache__/test_vendor_change_audit.cpython-312.pyc +0 -0
- package/skills/shopify-pre-pr/scripts/__pycache__/vendor_change_audit.cpython-312.pyc +0 -0
package/package.json
CHANGED
|
@@ -40,7 +40,7 @@ python3 .agents/skills/shopify-lint/scripts/shopify_lint.py --path .
|
|
|
40
40
|
|
|
41
41
|
The script identifies files changed since the current branch's merge-base, adds staged, unstaged, and untracked files, then runs three linters over that set and merges their results into one report:
|
|
42
42
|
|
|
43
|
-
- **Theme Check** — `shopify theme check --output json`. 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. The generated `.theme-check.yml` also pins the upstream `StaticStylesheetAndJavascriptTags` to `error`, so Liquid written inside a `{% stylesheet %}` or `{% javascript %}` block always fails the lint.
|
|
43
|
+
- **Theme Check** — `shopify theme check --output json`. 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, except text beginning with `--`, which is a CSS custom property name rather than copy, and except string arguments passed to `{% render %}` and `{% include %}`, which are as often class names or size tokens as copy. The generated `.theme-check.yml` also pins the upstream `StaticStylesheetAndJavascriptTags` to `error`, so Liquid written inside a `{% stylesheet %}` or `{% javascript %}` block always fails the lint.
|
|
44
44
|
- **JavaScript** — ESLint's recommended rules plus the bundled `eslint-plugin-theory`, over modified `.js` and `.mjs` files *and* the JavaScript inside `{% javascript %}` blocks of modified `.liquid` files.
|
|
45
45
|
- **CSS** — `stylelint-config-standard` through the bundled `stylelint-config-theory`, over modified `.css` files *and* the CSS inside `{% stylesheet %}` blocks of modified `.liquid` files.
|
|
46
46
|
|
|
@@ -48,7 +48,7 @@ Standalone JavaScript and CSS are linted only when the **filename** contains `td
|
|
|
48
48
|
|
|
49
49
|
The filter does **not** apply to Liquid: `{% javascript %}` and `{% stylesheet %}` blocks are linted in every modified `.liquid` file regardless of its name, because that code is Theory's either way. A `{% stylesheet 'scss' %}` block is skipped, because its body is Sass rather than CSS, as is any block commented out with `{% comment %}` or `{% raw %}`.
|
|
50
50
|
|
|
51
|
-
Offenses in a Liquid block are reported at their real line and column in the `.liquid` file. Check codes say where they came from: `PascalCase` is Theme Check, `theory/…` is a Theory JavaScript rule, `eslint/…` is an upstream ESLint rule, `stylelint/…` is an upstream Stylelint rule.
|
|
51
|
+
Offenses in a Liquid block are reported at their real line and column in the `.liquid` file. Positions are one-indexed in both the JSON and the text report, matching the `file:line:column` convention editors and compilers use — Theme Check's LSP-style zero-indexed rows and columns are rebased on the way in, and ESLint's and Stylelint's pass through as reported. Check codes say where they came from: `PascalCase` is Theme Check, `theory/…` is a Theory JavaScript rule, `eslint/…` is an upstream ESLint rule, `stylelint/…` is an upstream Stylelint rule.
|
|
52
52
|
|
|
53
53
|
`theory/guarded-custom-element-define` and `theory/no-unapproved-imports` are errors, as are genuine ESLint correctness failures; the convention rules (`theory/disconnected-callback-cleanup`, `theory/no-class-selectors`, `theory/centralize-selectors`, `theory/require-jsdoc`) are warnings. `theory/require-jsdoc` requires named classes and functions to have JSDoc comments, including typed `@param` and `@returns` tags. The severity distinction only affects `--fail-level`: the Stop hooks run at `--fail-level warning`, so warnings block handoff too. See `eslint-plugin-theory/README.md` for every rule and its blind spots.
|
|
54
54
|
|
|
Binary file
|
|
@@ -41,9 +41,21 @@ STYLELINT_SEVERITIES = ("error", "warning")
|
|
|
41
41
|
STYLELINT_PARSE_ERROR_RULE = "CssSyntaxError"
|
|
42
42
|
|
|
43
43
|
# Theme Check reports LSP-style zero-indexed positions; ESLint and Stylelint are
|
|
44
|
-
# both one-indexed.
|
|
44
|
+
# both one-indexed. Positions are reported one-indexed throughout — the
|
|
45
|
+
# `file:line:column` convention every editor and compiler uses — so Theme
|
|
46
|
+
# Check's rows and columns are each shifted up by one and the other two runners'
|
|
47
|
+
# pass through untouched.
|
|
45
48
|
LINE_BASE_OFFSET = 1
|
|
46
49
|
|
|
50
|
+
# Position fields carried on a Theme Check offense, rebased together so a single
|
|
51
|
+
# offense never mixes indexing bases.
|
|
52
|
+
THEME_CHECK_POSITION_KEYS = (
|
|
53
|
+
"start_row",
|
|
54
|
+
"start_column",
|
|
55
|
+
"end_row",
|
|
56
|
+
"end_column",
|
|
57
|
+
)
|
|
58
|
+
|
|
47
59
|
|
|
48
60
|
class ShopifyLintError(RuntimeError):
|
|
49
61
|
"""Raised when Git or Shopify CLI cannot produce filterable results."""
|
|
@@ -169,7 +181,26 @@ def run_theme_check(
|
|
|
169
181
|
|
|
170
182
|
if not isinstance(reports, list):
|
|
171
183
|
raise ShopifyLintError("Shopify Theme Check returned an unexpected JSON shape.")
|
|
172
|
-
return reports
|
|
184
|
+
return normalize_theme_check_reports(reports)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def normalize_theme_check_reports(
|
|
188
|
+
reports: list[dict[str, Any]],
|
|
189
|
+
) -> list[dict[str, Any]]:
|
|
190
|
+
"""Rebase Theme Check's zero-indexed positions onto the report's one-indexed ones."""
|
|
191
|
+
normalized = []
|
|
192
|
+
for report in reports:
|
|
193
|
+
offenses = []
|
|
194
|
+
for offense in report.get("offenses", []):
|
|
195
|
+
item = dict(offense)
|
|
196
|
+
for key in THEME_CHECK_POSITION_KEYS:
|
|
197
|
+
if key in item:
|
|
198
|
+
item[key] = int(item[key]) + LINE_BASE_OFFSET
|
|
199
|
+
offenses.append(item)
|
|
200
|
+
entry = dict(report)
|
|
201
|
+
entry["offenses"] = offenses
|
|
202
|
+
normalized.append(entry)
|
|
203
|
+
return normalized
|
|
173
204
|
|
|
174
205
|
|
|
175
206
|
def resolve_lint_target(
|
|
@@ -373,9 +404,9 @@ def normalize_stylelint_results(results: list[dict[str, Any]]) -> list[dict[str,
|
|
|
373
404
|
"check": stylelint_check_code(warning),
|
|
374
405
|
"severity": stylelint_severity(warning),
|
|
375
406
|
"message": str(warning.get("text", "")),
|
|
376
|
-
"start_row": max(int(warning.get("line", 1))
|
|
407
|
+
"start_row": max(int(warning.get("line", 1)), LINE_BASE_OFFSET),
|
|
377
408
|
"start_column": max(
|
|
378
|
-
int(warning.get("column", 1))
|
|
409
|
+
int(warning.get("column", 1)), LINE_BASE_OFFSET
|
|
379
410
|
),
|
|
380
411
|
}
|
|
381
412
|
)
|
|
@@ -420,9 +451,9 @@ def normalize_eslint_results(results: list[dict[str, Any]]) -> list[dict[str, An
|
|
|
420
451
|
"check": eslint_check_code(message),
|
|
421
452
|
"severity": eslint_severity(message),
|
|
422
453
|
"message": str(message.get("message", "")),
|
|
423
|
-
"start_row": max(int(message.get("line", 1))
|
|
454
|
+
"start_row": max(int(message.get("line", 1)), LINE_BASE_OFFSET),
|
|
424
455
|
"start_column": max(
|
|
425
|
-
int(message.get("column", 1))
|
|
456
|
+
int(message.get("column", 1)), LINE_BASE_OFFSET
|
|
426
457
|
),
|
|
427
458
|
}
|
|
428
459
|
)
|
|
@@ -510,8 +541,8 @@ def render_text(reports: list[dict[str, Any]], repo_root: Path) -> str:
|
|
|
510
541
|
offense_count += 1
|
|
511
542
|
severity = str(offense.get("severity", "unknown")).upper()
|
|
512
543
|
check = offense.get("check", "UnknownCheck")
|
|
513
|
-
row = offense.get("start_row",
|
|
514
|
-
column = offense.get("start_column",
|
|
544
|
+
row = offense.get("start_row", LINE_BASE_OFFSET)
|
|
545
|
+
column = offense.get("start_column", LINE_BASE_OFFSET)
|
|
515
546
|
message = offense.get("message", "")
|
|
516
547
|
lines.append(f" {severity} {check} {row}:{column} {message}".rstrip())
|
|
517
548
|
lines.append("")
|
|
Binary file
|
|
@@ -80,6 +80,35 @@ class ThemeCheckTests(unittest.TestCase):
|
|
|
80
80
|
check=False,
|
|
81
81
|
)
|
|
82
82
|
|
|
83
|
+
def test_rebases_theme_check_positions_onto_one_indexed_lines(self) -> None:
|
|
84
|
+
"""Theme Check emits LSP-style zero-indexed positions; the report is one-indexed."""
|
|
85
|
+
reports = [
|
|
86
|
+
{
|
|
87
|
+
"path": "/repo/sections/td-hero.liquid",
|
|
88
|
+
"offenses": [
|
|
89
|
+
{
|
|
90
|
+
"check": "HardcodedText",
|
|
91
|
+
"severity": "warning",
|
|
92
|
+
"start_row": 0,
|
|
93
|
+
"start_column": 4,
|
|
94
|
+
"end_row": 2,
|
|
95
|
+
"end_column": 9,
|
|
96
|
+
}
|
|
97
|
+
],
|
|
98
|
+
}
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
[report] = shopify_lint.normalize_theme_check_reports(reports)
|
|
102
|
+
offense = report["offenses"][0]
|
|
103
|
+
|
|
104
|
+
self.assertEqual(offense["start_row"], 1)
|
|
105
|
+
self.assertEqual(offense["end_row"], 3)
|
|
106
|
+
self.assertEqual(offense["start_column"], 5)
|
|
107
|
+
self.assertEqual(offense["end_column"], 10)
|
|
108
|
+
# The input is not mutated in place.
|
|
109
|
+
self.assertEqual(reports[0]["offenses"][0]["start_row"], 0)
|
|
110
|
+
self.assertEqual(reports[0]["offenses"][0]["start_column"], 4)
|
|
111
|
+
|
|
83
112
|
def test_filters_reports_to_changed_repo_relative_paths(self) -> None:
|
|
84
113
|
root = Path("/repo")
|
|
85
114
|
reports = [
|
|
@@ -360,7 +389,7 @@ class JsLintCommandTests(unittest.TestCase):
|
|
|
360
389
|
|
|
361
390
|
|
|
362
391
|
class EslintNormalizationTests(unittest.TestCase):
|
|
363
|
-
def
|
|
392
|
+
def test_maps_severity_rule_and_position(self) -> None:
|
|
364
393
|
results = [
|
|
365
394
|
{
|
|
366
395
|
"filePath": "/repo/assets/td-app.js",
|
|
@@ -395,15 +424,15 @@ class EslintNormalizationTests(unittest.TestCase):
|
|
|
395
424
|
"check": "eslint/no-undef",
|
|
396
425
|
"severity": "error",
|
|
397
426
|
"message": "'x' is not defined.",
|
|
398
|
-
"start_row":
|
|
399
|
-
"start_column":
|
|
427
|
+
"start_row": 4,
|
|
428
|
+
"start_column": 3,
|
|
400
429
|
},
|
|
401
430
|
{
|
|
402
431
|
"check": "theory/no-class-selectors",
|
|
403
432
|
"severity": "warning",
|
|
404
433
|
"message": "Use a data-td-* hook.",
|
|
405
|
-
"start_row":
|
|
406
|
-
"start_column":
|
|
434
|
+
"start_row": 9,
|
|
435
|
+
"start_column": 1,
|
|
407
436
|
},
|
|
408
437
|
],
|
|
409
438
|
}
|
|
@@ -684,7 +713,7 @@ class CssLintCommandTests(unittest.TestCase):
|
|
|
684
713
|
|
|
685
714
|
|
|
686
715
|
class StylelintNormalizationTests(unittest.TestCase):
|
|
687
|
-
def
|
|
716
|
+
def test_namespaces_rules_and_maps_positions(self) -> None:
|
|
688
717
|
results = [
|
|
689
718
|
{
|
|
690
719
|
"path": "/theme/assets/component-td-card.css",
|
|
@@ -705,8 +734,8 @@ class StylelintNormalizationTests(unittest.TestCase):
|
|
|
705
734
|
|
|
706
735
|
self.assertEqual(offense["check"], "stylelint/length-zero-no-unit")
|
|
707
736
|
self.assertEqual(offense["severity"], "warning")
|
|
708
|
-
self.assertEqual(offense["start_row"],
|
|
709
|
-
self.assertEqual(offense["start_column"],
|
|
737
|
+
self.assertEqual(offense["start_row"], 4)
|
|
738
|
+
self.assertEqual(offense["start_column"], 3)
|
|
710
739
|
|
|
711
740
|
def test_keeps_a_namespaced_plugin_rule_as_is(self) -> None:
|
|
712
741
|
results = [
|
|
@@ -932,6 +961,32 @@ class MergeReportTests(unittest.TestCase):
|
|
|
932
961
|
(SKILL_ROOT / "eslint-plugin-theory" / "dist").is_dir(),
|
|
933
962
|
"run scripts/setup.sh to build eslint-plugin-theory",
|
|
934
963
|
)
|
|
964
|
+
class RenderTextTests(unittest.TestCase):
|
|
965
|
+
"""The Stop hooks feed this text straight back to the agent."""
|
|
966
|
+
|
|
967
|
+
def test_prints_the_one_indexed_row_the_offense_carries(self) -> None:
|
|
968
|
+
reports = [
|
|
969
|
+
{
|
|
970
|
+
"path": "/repo/sections/td-hero.liquid",
|
|
971
|
+
"offenses": [
|
|
972
|
+
{
|
|
973
|
+
"check": "HardcodedText",
|
|
974
|
+
"severity": "warning",
|
|
975
|
+
"start_row": 12,
|
|
976
|
+
"start_column": 4,
|
|
977
|
+
"message": "Replace hard-coded text",
|
|
978
|
+
}
|
|
979
|
+
],
|
|
980
|
+
}
|
|
981
|
+
]
|
|
982
|
+
|
|
983
|
+
rendered = shopify_lint.render_text(reports, Path("/repo"))
|
|
984
|
+
|
|
985
|
+
self.assertIn(
|
|
986
|
+
" WARNING HardcodedText 12:4 Replace hard-coded text", rendered
|
|
987
|
+
)
|
|
988
|
+
|
|
989
|
+
|
|
935
990
|
class EndToEndJsTests(unittest.TestCase):
|
|
936
991
|
"""Proves the Liquid line-mapping contract against the real runner."""
|
|
937
992
|
|
|
@@ -951,7 +1006,7 @@ class EndToEndJsTests(unittest.TestCase):
|
|
|
951
1006
|
" {% endif %}",
|
|
952
1007
|
"{% endjavascript %}",
|
|
953
1008
|
]
|
|
954
|
-
expected_row = lines.index(" const dupe = { a: 1, a: 2 };")
|
|
1009
|
+
expected_row = lines.index(" const dupe = { a: 1, a: 2 };") + 1
|
|
955
1010
|
|
|
956
1011
|
with tempfile.TemporaryDirectory() as directory:
|
|
957
1012
|
root = Path(directory)
|
|
@@ -969,7 +1024,7 @@ class EndToEndJsTests(unittest.TestCase):
|
|
|
969
1024
|
]
|
|
970
1025
|
|
|
971
1026
|
self.assertEqual(len(dupe), 1)
|
|
972
|
-
# start_row is
|
|
1027
|
+
# start_row is one-indexed, matching the line numbers an editor shows.
|
|
973
1028
|
self.assertEqual(dupe[0]["start_row"], expected_row)
|
|
974
1029
|
self.assertEqual(dupe[0]["severity"], "error")
|
|
975
1030
|
|
|
@@ -994,7 +1049,7 @@ class EndToEndCssTests(unittest.TestCase):
|
|
|
994
1049
|
" .td-broken {}",
|
|
995
1050
|
"{% endstylesheet %}",
|
|
996
1051
|
]
|
|
997
|
-
expected_row = lines.index(" .td-broken {}")
|
|
1052
|
+
expected_row = lines.index(" .td-broken {}") + 1
|
|
998
1053
|
|
|
999
1054
|
with tempfile.TemporaryDirectory() as directory:
|
|
1000
1055
|
root = Path(directory)
|
|
@@ -1012,13 +1067,13 @@ class EndToEndCssTests(unittest.TestCase):
|
|
|
1012
1067
|
]
|
|
1013
1068
|
|
|
1014
1069
|
self.assertEqual(len(empty), 1)
|
|
1015
|
-
# start_row is
|
|
1070
|
+
# start_row is one-indexed, matching the line numbers an editor shows.
|
|
1016
1071
|
self.assertEqual(empty[0]["start_row"], expected_row)
|
|
1017
1072
|
self.assertEqual(empty[0]["severity"], "error")
|
|
1018
1073
|
# The masked interpolation is graded like any other value now: Liquid
|
|
1019
1074
|
# does not belong in a `{% stylesheet %}` block, and Theme Check reports
|
|
1020
1075
|
# it separately through `StaticStylesheetAndJavascriptTags`.
|
|
1021
|
-
interpolated_row = lines.index(" margin: {{ section.settings.gap }}px;")
|
|
1076
|
+
interpolated_row = lines.index(" margin: {{ section.settings.gap }}px;") + 1
|
|
1022
1077
|
self.assertEqual(
|
|
1023
1078
|
[
|
|
1024
1079
|
offense["check"]
|
|
@@ -12,6 +12,10 @@ HardcodedText:
|
|
|
12
12
|
enabled: true
|
|
13
13
|
severity: warning
|
|
14
14
|
|
|
15
|
+
RequiredLiquidDoc:
|
|
16
|
+
enabled: true
|
|
17
|
+
severity: warning
|
|
18
|
+
|
|
15
19
|
# Upstream check, pinned here so the branch lint always fails on it: Shopify
|
|
16
20
|
# serves `{% stylesheet %}` and `{% javascript %}` bodies verbatim, so Liquid
|
|
17
21
|
# written inside one ships to the browser as literal text.
|
|
@@ -13,6 +13,7 @@ integrated into Shopify CLI. It is not compatible with the archived Ruby
|
|
|
13
13
|
| --- | --- | --- |
|
|
14
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
|
+
| `RequiredLiquidDoc` | warning | A `td-` snippet or theme block without a complete Liquid doc contract. |
|
|
16
17
|
| `UnusedSectionSettings` | warning | A setting declared in `{% schema %}` that is never referenced in the file. |
|
|
17
18
|
| `UnguardedNullableSetting` | warning | A potentially blank setting output with `{{ }}` and no presence guard (`{% if %}`/`{% unless %}`) or `\| default`. |
|
|
18
19
|
| `UnguardedMetafield` | warning | A metafield output with no presence guard or `\| default`. |
|
|
@@ -33,18 +34,61 @@ Rendered storefront copy should come from a section setting, translation key,
|
|
|
33
34
|
metafield, or metaobject. The check reports text nodes, direct quoted output
|
|
34
35
|
with `{{ }}` or `{% echo %}`, quoted `default` filter fallbacks, hard-coded
|
|
35
36
|
`alt`, `title`, `placeholder`, and ARIA text attributes, and labels in the
|
|
36
|
-
`value` attribute of button-like inputs. Text inside inline SVG elements
|
|
37
|
-
string literals passed as named `render` or `include` arguments are also
|
|
37
|
+
`value` attribute of button-like inputs. Text inside inline SVG elements is also
|
|
38
38
|
checked. It ignores technical attributes such as classes and IDs, Liquid logic
|
|
39
39
|
strings, comments, punctuation-only fragments, and text made entirely of HTML
|
|
40
40
|
character references.
|
|
41
41
|
|
|
42
|
+
String literals passed as named `{% render %}` and `{% include %}` arguments are
|
|
43
|
+
ignored. A snippet argument is as often a class name, icon key, size token, or
|
|
44
|
+
heading level as it is storefront copy, and nothing at the call site separates
|
|
45
|
+
the two, so checking them produced mostly false positives. Copy that a snippet
|
|
46
|
+
renders is still reported inside the snippet itself, where it appears as a text
|
|
47
|
+
node or a quoted output.
|
|
48
|
+
|
|
49
|
+
Text beginning with `--` is ignored as a CSS custom property name: Liquid
|
|
50
|
+
routinely assembles a custom property declaration before handing it to a
|
|
51
|
+
`style` attribute or a `{% style %}` block, neither of which this check reads.
|
|
52
|
+
Only the fragment carrying the `--` prefix is exempt, so the tail of an
|
|
53
|
+
interpolated declaration — the `px;` in
|
|
54
|
+
`{% capture s %}--td-gap: {{ gap }}px;{% endcapture %}` — is still reported.
|
|
55
|
+
|
|
42
56
|
Content inside `{% stylesheet %}`, `{% javascript %}`, and `{% schema %}` is
|
|
43
57
|
ignored. A quoted translation key passed through `t` or `translate` is also
|
|
44
58
|
accepted. Short alphabetic tokens and copyright years remain reportable because
|
|
45
59
|
the check cannot reliably distinguish user-facing copy from currency codes,
|
|
46
60
|
units, or other intentional literals.
|
|
47
61
|
|
|
62
|
+
### RequiredLiquidDoc
|
|
63
|
+
|
|
64
|
+
Files whose name contains `td-` under `snippets/` or `blocks/` must contain a
|
|
65
|
+
`{% doc %}` tag with a prose description and a non-empty `@example`. Every
|
|
66
|
+
`@param` that is present must use LiquidDoc's `{type} name - description`
|
|
67
|
+
shape; optional names such as `[heading]` are supported. LiquidDoc types are
|
|
68
|
+
descriptive rather than a closed type system, so Shopify Liquid object names,
|
|
69
|
+
primitive names, and project-specific type expressions are all accepted.
|
|
70
|
+
|
|
71
|
+
```liquid
|
|
72
|
+
{% doc %}
|
|
73
|
+
Renders a product card.
|
|
74
|
+
|
|
75
|
+
@param {product} product - The product to display.
|
|
76
|
+
@param {string} [heading] - An optional card heading.
|
|
77
|
+
|
|
78
|
+
@example
|
|
79
|
+
{% render 'td-product-card', product: product, heading: 'Featured' %}
|
|
80
|
+
{% enddoc %}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The check does not infer missing parameters from variable lookups inside the
|
|
84
|
+
file. Liquid exposes many global objects, and a snippet parameter is introduced
|
|
85
|
+
at its call site, so a single-file lookup cannot reliably distinguish the two.
|
|
86
|
+
To require complete parameter coverage, add a separate project-level analysis
|
|
87
|
+
that indexes named arguments at static `{% render %}` call sites and compares
|
|
88
|
+
their union with each target file's documented parameters. Dynamic snippet
|
|
89
|
+
names and values passed with `with`/`for` require an explicit project policy or
|
|
90
|
+
an allowlist.
|
|
91
|
+
|
|
48
92
|
### UnusedSectionSettings
|
|
49
93
|
|
|
50
94
|
This single-file check collects every `id` under `settings` and
|
|
@@ -178,6 +222,10 @@ HardcodedText:
|
|
|
178
222
|
enabled: true
|
|
179
223
|
severity: warning
|
|
180
224
|
|
|
225
|
+
RequiredLiquidDoc:
|
|
226
|
+
enabled: true
|
|
227
|
+
severity: warning
|
|
228
|
+
|
|
181
229
|
# Upstream check, pinned here so the branch lint always fails on it: Shopify
|
|
182
230
|
# serves `{% stylesheet %}` and `{% javascript %}` bodies verbatim, so Liquid
|
|
183
231
|
# written inside one ships to the browser as literal text.
|
|
@@ -98,22 +98,18 @@ describe('HardcodedText', () => {
|
|
|
98
98
|
]);
|
|
99
99
|
});
|
|
100
100
|
|
|
101
|
-
it('
|
|
101
|
+
it('ignores string literals passed as render and include arguments', async () => {
|
|
102
|
+
// A snippet argument is as often a class name, icon key, size token, or
|
|
103
|
+
// heading level as it is storefront copy, and the call site carries nothing
|
|
104
|
+
// that separates the two. The snippet body is still checked on its own.
|
|
102
105
|
const source = `
|
|
103
106
|
{% render 'button', label: 'Add to cart' %}
|
|
104
107
|
{% include 'icon', accessible_name: "Search icon" %}
|
|
105
|
-
{% render 'button',
|
|
108
|
+
{% render 'button', variant: 'primary', size: 'large' %}
|
|
106
109
|
{% render 'icon' %}
|
|
107
110
|
`;
|
|
108
111
|
|
|
109
|
-
|
|
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"']);
|
|
112
|
+
expect(await runLiquidCheck(HardcodedText, source)).toEqual([]);
|
|
117
113
|
});
|
|
118
114
|
|
|
119
115
|
it('reports hard-coded default filter fallbacks', async () => {
|
|
@@ -209,6 +205,38 @@ describe('HardcodedText', () => {
|
|
|
209
205
|
expect(await runLiquidCheck(HardcodedText, source)).toEqual([]);
|
|
210
206
|
});
|
|
211
207
|
|
|
208
|
+
it('accepts CSS custom properties built in Liquid', async () => {
|
|
209
|
+
const source = `
|
|
210
|
+
{% capture card_style %}--td-card-gap: 12px;{% endcapture %}
|
|
211
|
+
{{ '--td-card-columns' }}
|
|
212
|
+
{% echo "--td-card-radius: 4px;" %}
|
|
213
|
+
{% render 'card', style_key: '--td-card-accent' %}
|
|
214
|
+
<div style="{{ card_style }}">{{ section.settings.heading }}</div>
|
|
215
|
+
`;
|
|
216
|
+
|
|
217
|
+
expect(await runLiquidCheck(HardcodedText, source)).toEqual([]);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('still reports the tail of an interpolated custom property', async () => {
|
|
221
|
+
// The parser splits the declaration around the output tag, and only the
|
|
222
|
+
// fragment that carries the `--` prefix is exempt. The trailing unit is a
|
|
223
|
+
// text node of its own with nothing marking it as CSS.
|
|
224
|
+
const source =
|
|
225
|
+
'{% capture style %}--td-gap: {{ section.settings.gap }}px;{% endcapture %}';
|
|
226
|
+
|
|
227
|
+
const offenses = await runLiquidCheck(HardcodedText, source);
|
|
228
|
+
|
|
229
|
+
expect(
|
|
230
|
+
offenses.map(({ start, end }) => source.slice(start.index, end.index)),
|
|
231
|
+
).toEqual(['px;']);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('still reports copy that only mentions a custom property later', async () => {
|
|
235
|
+
const source = '{% capture note %}Set --td-gap to widen the card{% endcapture %}';
|
|
236
|
+
|
|
237
|
+
expect(await runLiquidCheck(HardcodedText, source)).toHaveLength(1);
|
|
238
|
+
});
|
|
239
|
+
|
|
212
240
|
it('still reports rendered text inside other raw tags', async () => {
|
|
213
241
|
const source = '{% raw %}Hard coded raw output{% endraw %}';
|
|
214
242
|
|
|
@@ -43,6 +43,12 @@ const BUTTON_INPUT_TYPES = new Set(['button', 'reset', 'submit']);
|
|
|
43
43
|
const HTML_CHARACTER_REFERENCE =
|
|
44
44
|
/&(?:#\d+|#x[\da-f]+|[a-z][\da-z]+);/giu;
|
|
45
45
|
|
|
46
|
+
// A leading `--` is a CSS custom property name, never storefront copy. Liquid
|
|
47
|
+
// routinely assembles custom property declarations before handing them to a
|
|
48
|
+
// `style` attribute or a `{% style %}` block — places this check overlooks —
|
|
49
|
+
// so the fragment that builds them is exempt too.
|
|
50
|
+
const CSS_CUSTOM_PROPERTY = /^\s*--/u;
|
|
51
|
+
|
|
46
52
|
type ValuedAttribute = Extract<AttributeNode, { value: unknown }>;
|
|
47
53
|
|
|
48
54
|
function isValuedAttribute(node: LiquidHtmlNode): node is ValuedAttribute {
|
|
@@ -124,6 +130,8 @@ function isInlineSvgMarkup(ancestors: LiquidHtmlNode[]): boolean {
|
|
|
124
130
|
}
|
|
125
131
|
|
|
126
132
|
function hasMeaningfulText(value: string): boolean {
|
|
133
|
+
if (CSS_CUSTOM_PROPERTY.test(value)) return false;
|
|
134
|
+
|
|
127
135
|
return /[\p{L}\p{N}]/u.test(
|
|
128
136
|
value.replace(HTML_CHARACTER_REFERENCE, ''),
|
|
129
137
|
);
|
|
@@ -300,17 +308,6 @@ export const HardcodedText: LiquidCheckDefinition = {
|
|
|
300
308
|
|
|
301
309
|
reportInlineSvgText(node);
|
|
302
310
|
},
|
|
303
|
-
|
|
304
|
-
async RenderMarkup(node) {
|
|
305
|
-
for (const argument of node.args) {
|
|
306
|
-
if (
|
|
307
|
-
argument.type === NodeTypes.NamedArgument &&
|
|
308
|
-
argument.value.type === NodeTypes.String
|
|
309
|
-
) {
|
|
310
|
-
reportString(argument.value);
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
},
|
|
314
311
|
};
|
|
315
312
|
},
|
|
316
313
|
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { liquidDocProblems, requiresLiquidDoc } from './required-liquid-doc';
|
|
4
|
+
|
|
5
|
+
describe('RequiredLiquidDoc', () => {
|
|
6
|
+
it('targets td- Liquid snippets and blocks only', () => {
|
|
7
|
+
expect(requiresLiquidDoc('file:///theme/snippets/td-card.liquid')).toBe(true);
|
|
8
|
+
expect(requiresLiquidDoc('/theme/blocks/product-td-card.liquid')).toBe(true);
|
|
9
|
+
expect(requiresLiquidDoc('/theme/sections/td-card.liquid')).toBe(false);
|
|
10
|
+
expect(requiresLiquidDoc('/theme/snippets/card.liquid')).toBe(false);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('accepts a description, typed parameters, and an example', () => {
|
|
14
|
+
const body = `
|
|
15
|
+
Renders a card for a product.
|
|
16
|
+
@param {product} product - The product displayed by the card.
|
|
17
|
+
@param {string} [heading] - An optional heading.
|
|
18
|
+
@example
|
|
19
|
+
{% render 'td-card', product: product, heading: 'Featured' %}
|
|
20
|
+
`;
|
|
21
|
+
expect(liquidDocProblems(body)).toEqual([]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('requires both a description and non-empty example', () => {
|
|
25
|
+
expect(liquidDocProblems('@example\n').map(({ message }) => message)).toEqual([
|
|
26
|
+
'Liquid doc must include a description before its annotations',
|
|
27
|
+
'Liquid doc must include an @example with example markup',
|
|
28
|
+
]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('requires types and descriptions on documented parameters', () => {
|
|
32
|
+
const body = `
|
|
33
|
+
Renders a card.
|
|
34
|
+
@param product - The product.
|
|
35
|
+
@param {string} heading
|
|
36
|
+
@example
|
|
37
|
+
{% render 'td-card', product: product %}
|
|
38
|
+
`;
|
|
39
|
+
expect(liquidDocProblems(body).map(({ message }) => message)).toEqual([
|
|
40
|
+
'Liquid doc @param must include a type in braces',
|
|
41
|
+
'Liquid doc @param must include a description after " - "',
|
|
42
|
+
'Liquid doc @param must include a type in braces',
|
|
43
|
+
'Liquid doc @param must include a description after " - "',
|
|
44
|
+
]);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { LiquidRawTag } from '@shopify/liquid-html-parser';
|
|
2
|
+
import {
|
|
3
|
+
LiquidCheckDefinition,
|
|
4
|
+
Severity,
|
|
5
|
+
SourceCodeType,
|
|
6
|
+
} from '@shopify/theme-check-common';
|
|
7
|
+
|
|
8
|
+
type DocumentationProblem = { message: string; offset: number; length: number };
|
|
9
|
+
|
|
10
|
+
const PARAM_LINE = /^[ \t]*@param[ \t]+(.*)$/gm;
|
|
11
|
+
const COMPLETE_PARAM = /^\{([^}\n]+)\}\s+(\[[^\]\n]+\]|[^\s-]+)\s+-\s+(.+)$/;
|
|
12
|
+
|
|
13
|
+
/** Returns true for td-named snippets and theme blocks. */
|
|
14
|
+
export function requiresLiquidDoc(uri: string): boolean {
|
|
15
|
+
const path = decodeURIComponent(uri).replace(/\\/g, '/').split(/[?#]/, 1)[0];
|
|
16
|
+
const match = path.match(/(?:^|\/)(snippets|blocks)\/([^/]+)\.liquid$/i);
|
|
17
|
+
return Boolean(match?.[2].toLowerCase().includes('td-'));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Validates the documented contract without mistaking Shopify globals for parameters. */
|
|
21
|
+
export function liquidDocProblems(body: string): DocumentationProblem[] {
|
|
22
|
+
const problems: DocumentationProblem[] = [];
|
|
23
|
+
const firstAnnotation = body.search(/^[ \t]*@\w+/m);
|
|
24
|
+
const description = body.slice(0, firstAnnotation < 0 ? body.length : firstAnnotation).trim();
|
|
25
|
+
|
|
26
|
+
if (!description) {
|
|
27
|
+
problems.push({
|
|
28
|
+
message: 'Liquid doc must include a description before its annotations',
|
|
29
|
+
offset: 0,
|
|
30
|
+
length: Math.max(body.length, 1),
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const example = body.match(
|
|
35
|
+
/^[ \t]*@example[^\n]*\n([\s\S]*?)(?=^[ \t]*@\w+|(?![\s\S]))/m,
|
|
36
|
+
);
|
|
37
|
+
if (!example || !example[1].trim()) {
|
|
38
|
+
problems.push({
|
|
39
|
+
message: 'Liquid doc must include an @example with example markup',
|
|
40
|
+
offset: 0,
|
|
41
|
+
length: Math.max(body.length, 1),
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
for (const match of body.matchAll(PARAM_LINE)) {
|
|
46
|
+
const complete = match[1].trim().match(COMPLETE_PARAM);
|
|
47
|
+
const offset = match.index ?? 0;
|
|
48
|
+
if (!complete?.[1].trim()) {
|
|
49
|
+
problems.push({
|
|
50
|
+
message: 'Liquid doc @param must include a type in braces',
|
|
51
|
+
offset,
|
|
52
|
+
length: match[0].length,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
if (!complete?.[3].trim()) {
|
|
56
|
+
problems.push({
|
|
57
|
+
message: 'Liquid doc @param must include a description after " - "',
|
|
58
|
+
offset,
|
|
59
|
+
length: match[0].length,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return problems;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const RequiredLiquidDoc: LiquidCheckDefinition = {
|
|
68
|
+
meta: {
|
|
69
|
+
code: 'RequiredLiquidDoc',
|
|
70
|
+
name: 'Require documentation for td- snippets and theme blocks',
|
|
71
|
+
docs: {
|
|
72
|
+
description:
|
|
73
|
+
'Requires td- snippets and blocks to have a Liquid doc description and example, and validates documented parameters.',
|
|
74
|
+
recommended: true,
|
|
75
|
+
},
|
|
76
|
+
type: SourceCodeType.LiquidHtml,
|
|
77
|
+
severity: Severity.WARNING,
|
|
78
|
+
schema: {},
|
|
79
|
+
},
|
|
80
|
+
create(context) {
|
|
81
|
+
const uri = (context as unknown as { file?: { uri?: string } }).file?.uri ?? '';
|
|
82
|
+
if (!requiresLiquidDoc(uri)) return {};
|
|
83
|
+
|
|
84
|
+
const docs: LiquidRawTag[] = [];
|
|
85
|
+
return {
|
|
86
|
+
async LiquidRawTag(node) {
|
|
87
|
+
if (node.name === 'doc') docs.push(node);
|
|
88
|
+
},
|
|
89
|
+
async onCodePathEnd() {
|
|
90
|
+
if (docs.length === 0) {
|
|
91
|
+
context.report({
|
|
92
|
+
message: 'td- snippets and blocks must include a {% doc %} tag',
|
|
93
|
+
startIndex: 0,
|
|
94
|
+
endIndex: 1,
|
|
95
|
+
});
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const doc = docs[0];
|
|
100
|
+
for (const problem of liquidDocProblems(doc.body.value)) {
|
|
101
|
+
const startIndex = doc.body.position.start + problem.offset;
|
|
102
|
+
context.report({
|
|
103
|
+
message: problem.message,
|
|
104
|
+
startIndex,
|
|
105
|
+
endIndex: startIndex + problem.length,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
},
|
|
111
|
+
};
|
|
@@ -8,6 +8,7 @@ describe('theme-check-theory module', () => {
|
|
|
8
8
|
expect(checks.map(({ meta }) => meta.code)).toEqual([
|
|
9
9
|
'DisallowedScriptOrStyleTag',
|
|
10
10
|
'HardcodedText',
|
|
11
|
+
'RequiredLiquidDoc',
|
|
11
12
|
'UnusedSectionSettings',
|
|
12
13
|
'UnguardedNullableSetting',
|
|
13
14
|
'UnguardedMetafield',
|
|
@@ -24,6 +25,7 @@ describe('theme-check-theory module', () => {
|
|
|
24
25
|
).toEqual({
|
|
25
26
|
DisallowedScriptOrStyleTag: true,
|
|
26
27
|
HardcodedText: true,
|
|
28
|
+
RequiredLiquidDoc: true,
|
|
27
29
|
UnusedSectionSettings: false,
|
|
28
30
|
UnguardedNullableSetting: true,
|
|
29
31
|
UnguardedMetafield: true,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { DisallowedScriptOrStyleTag } from './checks/disallowed-script-or-style-tag';
|
|
2
2
|
import { HardcodedText } from './checks/hardcoded-text';
|
|
3
|
+
import { RequiredLiquidDoc } from './checks/required-liquid-doc';
|
|
3
4
|
import { UnusedSectionSettings } from './checks/unused-section-settings';
|
|
4
5
|
import { UnguardedMetafield } from './checks/unguarded-metafield';
|
|
5
6
|
import { UnguardedMetaobject } from './checks/unguarded-metaobject';
|
|
@@ -7,6 +8,7 @@ import { UnguardedNullableSetting } from './checks/unguarded-nullable-setting';
|
|
|
7
8
|
|
|
8
9
|
export { DisallowedScriptOrStyleTag } from './checks/disallowed-script-or-style-tag';
|
|
9
10
|
export { HardcodedText } from './checks/hardcoded-text';
|
|
11
|
+
export { RequiredLiquidDoc } from './checks/required-liquid-doc';
|
|
10
12
|
export { UnusedSectionSettings } from './checks/unused-section-settings';
|
|
11
13
|
export { UnguardedMetafield } from './checks/unguarded-metafield';
|
|
12
14
|
export { UnguardedMetaobject } from './checks/unguarded-metaobject';
|
|
@@ -18,6 +20,7 @@ export {
|
|
|
18
20
|
export const checks = [
|
|
19
21
|
DisallowedScriptOrStyleTag,
|
|
20
22
|
HardcodedText,
|
|
23
|
+
RequiredLiquidDoc,
|
|
21
24
|
UnusedSectionSettings,
|
|
22
25
|
UnguardedNullableSetting,
|
|
23
26
|
UnguardedMetafield,
|
|
Binary file
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":"4.1.10","results":[[":eslint-plugin-theory/src/rules/guarded-custom-element-define.test.ts",{"duration":0,"failed":true}]]}
|
|
Binary file
|