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 +1 -1
- package/skills/shopify-lint/SKILL.md +3 -3
- package/skills/shopify-lint/scripts/setup.sh +2 -8
- package/skills/shopify-lint/theme-check-theory/README.md +13 -7
- package/skills/shopify-lint/theme-check-theory/package-lock.json +2 -2
- package/skills/shopify-lint/theme-check-theory/package.json +1 -1
- package/skills/shopify-lint/theme-check-theory/src/checks/disallowed-script-or-style-tag.test.ts +35 -2
- package/skills/shopify-lint/theme-check-theory/src/checks/disallowed-script-or-style-tag.ts +85 -1
- package/skills/shopify-lint/theme-check-theory/src/checks/hardcoded-text.test.ts +45 -0
- package/skills/shopify-lint/theme-check-theory/src/checks/hardcoded-text.ts +82 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: shopify-lint
|
|
3
|
-
version: 1.3.
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
26
|
-
|
|
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 %}`,
|
|
33
|
-
|
|
34
|
-
|
|
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.
|
|
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.
|
|
9
|
+
"version": "0.2.1",
|
|
10
10
|
"license": "UNLICENSED",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@shopify/liquid-html-parser": "2.9.2",
|
package/skills/shopify-lint/theme-check-theory/src/checks/disallowed-script-or-style-tag.test.ts
CHANGED
|
@@ -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
|
|
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="
|
|
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:
|
|
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
|
+
× — … © ©  
|
|
106
|
+
`;
|
|
107
|
+
const copy = ' Welcome ';
|
|
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(
|
|
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
|
|
112
|
-
!
|
|
113
|
-
isTranslation(variable)
|
|
165
|
+
variable.expression.type === NodeTypes.String &&
|
|
166
|
+
!isTranslation(variable)
|
|
114
167
|
) {
|
|
115
|
-
|
|
168
|
+
reportString(variable.expression);
|
|
116
169
|
}
|
|
117
170
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
variable.
|
|
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 (
|
|
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;
|