td-ai-tools 1.2.11 → 1.3.2
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/README.md +2 -2
- package/skills/shopify-lint/SKILL.md +26 -18
- package/skills/shopify-lint/eslint-plugin-theory/README.md +25 -8
- package/skills/shopify-lint/eslint-plugin-theory/src/liquid/mask.test.ts +41 -0
- package/skills/shopify-lint/eslint-plugin-theory/src/liquid/mask.ts +41 -2
- package/skills/shopify-lint/hooks/claude-stop.sh +2 -3
- package/skills/shopify-lint/hooks/codex-stop.sh +2 -3
- package/skills/shopify-lint/scripts/__pycache__/shopify_lint.cpython-312.pyc +0 -0
- package/skills/shopify-lint/scripts/setup.sh +21 -2
- package/skills/shopify-lint/scripts/shopify_lint.py +179 -30
- package/skills/shopify-lint/stylelint-config-theory/.stylelintignore +9 -0
- package/skills/shopify-lint/stylelint-config-theory/README.md +144 -0
- package/skills/shopify-lint/stylelint-config-theory/bin/lint-css.mjs +206 -0
- package/skills/shopify-lint/stylelint-config-theory/package-lock.json +3110 -0
- package/skills/shopify-lint/stylelint-config-theory/package.json +47 -0
- package/skills/shopify-lint/stylelint-config-theory/src/build.test.ts +31 -0
- package/skills/shopify-lint/stylelint-config-theory/src/config.test.ts +150 -0
- package/skills/shopify-lint/stylelint-config-theory/src/config.ts +109 -0
- package/skills/shopify-lint/stylelint-config-theory/src/index.ts +15 -0
- package/skills/shopify-lint/stylelint-config-theory/src/integration.test.ts +187 -0
- package/skills/shopify-lint/stylelint-config-theory/src/liquid/mask.test.ts +300 -0
- package/skills/shopify-lint/stylelint-config-theory/src/liquid/mask.ts +203 -0
- package/skills/shopify-lint/stylelint-config-theory/src/types/shared-configs.d.ts +26 -0
- package/skills/shopify-lint/stylelint-config-theory/tsconfig.build.json +9 -0
- package/skills/shopify-lint/stylelint-config-theory/tsconfig.json +14 -0
- package/skills/shopify-lint/stylelint-config-theory/vitest.config.ts +11 -0
- package/skills/shopify-lint/tests/__pycache__/test_shopify_lint.cpython-312.pyc +0 -0
- package/skills/shopify-lint/tests/test_shopify_lint.py +483 -8
- package/skills/shopify-lint/theme-check-theory/.theme-check.example.yml +7 -0
- package/skills/shopify-lint/theme-check-theory/README.md +48 -0
- package/skills/shopify-pre-pr/SKILL.md +129 -0
- package/skills/shopify-pre-pr/agents/openai.yaml +4 -0
- package/skills/shopify-pre-pr/references/td-change-delimiters.md +88 -0
- 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/skills/shopify-pre-pr/scripts/test_vendor_change_audit.py +424 -0
- package/skills/shopify-pre-pr/scripts/vendor_change_audit.py +540 -0
- package/skills/record-changes/SKILL.md +0 -76
- package/skills/record-changes/agents/openai.yaml +0 -4
- /package/skills/{record-changes → shopify-pre-pr}/scripts/branch_diff_context.py +0 -0
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { maskLiquidStylesheet } from './mask.js';
|
|
4
|
+
|
|
5
|
+
/** Realistic Liquid sources, used to assert the position-preserving invariants. */
|
|
6
|
+
const SAMPLES: Array<[string, string]> = [
|
|
7
|
+
[
|
|
8
|
+
'simple block',
|
|
9
|
+
`<div class="td-hero">Hello</div>
|
|
10
|
+
{% stylesheet %}
|
|
11
|
+
.td-hero { color: red; }
|
|
12
|
+
{% endstylesheet %}
|
|
13
|
+
`,
|
|
14
|
+
],
|
|
15
|
+
[
|
|
16
|
+
'output interpolation',
|
|
17
|
+
`{% stylesheet %}
|
|
18
|
+
.td-hero { --td-gap: {{ section.settings.gap }}; }
|
|
19
|
+
{% endstylesheet %}
|
|
20
|
+
`,
|
|
21
|
+
],
|
|
22
|
+
[
|
|
23
|
+
'whitespace control',
|
|
24
|
+
`{%- stylesheet -%}
|
|
25
|
+
.td-hero { color: red; }
|
|
26
|
+
{%- endstylesheet -%}
|
|
27
|
+
`,
|
|
28
|
+
],
|
|
29
|
+
[
|
|
30
|
+
'control flow inside the block',
|
|
31
|
+
`{% stylesheet %}
|
|
32
|
+
{% if section.settings.wide %}
|
|
33
|
+
.td-hero { max-width: none; }
|
|
34
|
+
{% endif %}
|
|
35
|
+
{% endstylesheet %}
|
|
36
|
+
`,
|
|
37
|
+
],
|
|
38
|
+
[
|
|
39
|
+
'comment tag inside the block',
|
|
40
|
+
`{% stylesheet %}
|
|
41
|
+
{% comment %} {{ not.real }} {% endcomment %}
|
|
42
|
+
.td-hero { color: red; }
|
|
43
|
+
{% endstylesheet %}
|
|
44
|
+
`,
|
|
45
|
+
],
|
|
46
|
+
[
|
|
47
|
+
'raw tag inside the block',
|
|
48
|
+
`{% stylesheet %}
|
|
49
|
+
{% raw %}{{ literal }}{% endraw %}
|
|
50
|
+
.td-hero { color: red; }
|
|
51
|
+
{% endstylesheet %}
|
|
52
|
+
`,
|
|
53
|
+
],
|
|
54
|
+
[
|
|
55
|
+
'nested at-rule',
|
|
56
|
+
`{% stylesheet %}
|
|
57
|
+
@media (min-width: 40em) {
|
|
58
|
+
.td-hero { color: red; }
|
|
59
|
+
}
|
|
60
|
+
{% endstylesheet %}
|
|
61
|
+
`,
|
|
62
|
+
],
|
|
63
|
+
[
|
|
64
|
+
'markup around the block',
|
|
65
|
+
`{% schema %}{"name":"Hero"}{% endschema %}
|
|
66
|
+
<div class="td-hero">{{ product.title }}</div>
|
|
67
|
+
{% stylesheet %}
|
|
68
|
+
.td-hero { color: red; }
|
|
69
|
+
{% endstylesheet %}
|
|
70
|
+
{% javascript %}
|
|
71
|
+
console.log('hi');
|
|
72
|
+
{% endjavascript %}
|
|
73
|
+
`,
|
|
74
|
+
],
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
describe('maskLiquidStylesheet position invariants', () => {
|
|
78
|
+
for (const [name, source] of SAMPLES) {
|
|
79
|
+
it(`preserves every character position: ${name}`, () => {
|
|
80
|
+
const masked = maskLiquidStylesheet(source);
|
|
81
|
+
|
|
82
|
+
expect(masked).not.toBeNull();
|
|
83
|
+
expect(masked).toHaveLength(source.length);
|
|
84
|
+
|
|
85
|
+
// Newlines must sit at exactly the same indices, or every reported line
|
|
86
|
+
// number would be wrong.
|
|
87
|
+
const newlineIndices = (text: string): number[] =>
|
|
88
|
+
[...text].flatMap((character, index) => (character === '\n' ? [index] : []));
|
|
89
|
+
expect(newlineIndices(masked!)).toEqual(newlineIndices(source));
|
|
90
|
+
|
|
91
|
+
// Nothing may be introduced that was not there: every non-space output
|
|
92
|
+
// character is either the source character, a blank, or the `0` that
|
|
93
|
+
// stands in for a Liquid output tag.
|
|
94
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
95
|
+
const character = masked![index];
|
|
96
|
+
if (character !== ' ' && character !== '0') {
|
|
97
|
+
expect(character).toBe(source[index]);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe('maskLiquidStylesheet', () => {
|
|
105
|
+
it('keeps the stylesheet body and blanks the surrounding markup', () => {
|
|
106
|
+
const source = `<div class="td-hero">Hello</div>
|
|
107
|
+
{% stylesheet %}
|
|
108
|
+
.td-hero { color: red; }
|
|
109
|
+
{% endstylesheet %}
|
|
110
|
+
`;
|
|
111
|
+
|
|
112
|
+
const masked = maskLiquidStylesheet(source)!;
|
|
113
|
+
const lines = masked.split('\n');
|
|
114
|
+
|
|
115
|
+
expect(lines[0]!.trim()).toBe('');
|
|
116
|
+
expect(lines[1]!.trim()).toBe('');
|
|
117
|
+
expect(lines[2]).toBe(' .td-hero { color: red; }');
|
|
118
|
+
expect(lines[3]!.trim()).toBe('');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('reports the body at its real line in the liquid file', () => {
|
|
122
|
+
const lines = [
|
|
123
|
+
'<div class="td-hero"></div>',
|
|
124
|
+
'{% stylesheet %}',
|
|
125
|
+
' .td-hero { color: red; }',
|
|
126
|
+
'{% endstylesheet %}',
|
|
127
|
+
'',
|
|
128
|
+
];
|
|
129
|
+
|
|
130
|
+
const masked = maskLiquidStylesheet(lines.join('\n'))!;
|
|
131
|
+
|
|
132
|
+
expect(masked.split('\n')[lines.indexOf(' .td-hero { color: red; }')]).toBe(
|
|
133
|
+
' .td-hero { color: red; }',
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('masks Liquid output inside the body with a value placeholder', () => {
|
|
138
|
+
const output = '{{ settings.w }}';
|
|
139
|
+
const masked = maskLiquidStylesheet(
|
|
140
|
+
`{% stylesheet %}\n.a { width: ${output}; }\n{% endstylesheet %}\n`,
|
|
141
|
+
)!;
|
|
142
|
+
|
|
143
|
+
// `0` followed by blanks for the rest of the tag: a valid value token that
|
|
144
|
+
// occupies exactly the width of the Liquid it replaced.
|
|
145
|
+
expect(masked.split('\n')[1]).toBe(`.a { width: 0${' '.repeat(output.length - 1)}; }`);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('masks Liquid tags inside the body', () => {
|
|
149
|
+
const masked = maskLiquidStylesheet(
|
|
150
|
+
'{% stylesheet %}\n{% if a %}.a { color: red; }{% endif %}\n{% endstylesheet %}\n',
|
|
151
|
+
)!;
|
|
152
|
+
|
|
153
|
+
expect(masked.split('\n')[1]!.trim()).toBe('.a { color: red; }');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('leaves nested CSS blocks alone', () => {
|
|
157
|
+
const source = '{% stylesheet %}\n@media screen {{ .a { color: red; } }}\n{% endstylesheet %}\n';
|
|
158
|
+
|
|
159
|
+
// `{{ .a {` is not a Liquid output tag: its contents carry `{` and `;`.
|
|
160
|
+
expect(maskLiquidStylesheet(source)!.split('\n')[1]).toBe(
|
|
161
|
+
'@media screen {{ .a { color: red; } }}',
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('handles multiple blocks', () => {
|
|
166
|
+
const masked = maskLiquidStylesheet(
|
|
167
|
+
[
|
|
168
|
+
'{% stylesheet %}',
|
|
169
|
+
'.a { color: red; }',
|
|
170
|
+
'{% endstylesheet %}',
|
|
171
|
+
'<p>{{ product.title }}</p>',
|
|
172
|
+
'{% stylesheet %}',
|
|
173
|
+
'.b { color: blue; }',
|
|
174
|
+
'{% endstylesheet %}',
|
|
175
|
+
'',
|
|
176
|
+
].join('\n'),
|
|
177
|
+
)!;
|
|
178
|
+
const lines = masked.split('\n');
|
|
179
|
+
|
|
180
|
+
expect(lines[1]).toBe('.a { color: red; }');
|
|
181
|
+
expect(lines[3]!.trim()).toBe('');
|
|
182
|
+
expect(lines[5]).toBe('.b { color: blue; }');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('returns null when there is no stylesheet block', () => {
|
|
186
|
+
expect(maskLiquidStylesheet('<div class="td-hero"></div>\n')).toBeNull();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('returns null when the block is never closed', () => {
|
|
190
|
+
expect(maskLiquidStylesheet('{% stylesheet %}\n.a { color: red; }\n')).toBeNull();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('returns null for an empty block rather than reporting an empty source', () => {
|
|
194
|
+
expect(maskLiquidStylesheet('{% stylesheet %}\n\n{% endstylesheet %}\n')).toBeNull();
|
|
195
|
+
expect(maskLiquidStylesheet('{% stylesheet %}{% endstylesheet %}\n')).toBeNull();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('returns null when the block only contains Liquid', () => {
|
|
199
|
+
// Everything maskable is masked away, so there is no CSS to report on.
|
|
200
|
+
expect(
|
|
201
|
+
maskLiquidStylesheet('{% stylesheet %}\n{% assign a = 1 %}\n{% endstylesheet %}\n'),
|
|
202
|
+
).toBeNull();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('skips a legacy scss block', () => {
|
|
206
|
+
expect(
|
|
207
|
+
maskLiquidStylesheet("{% stylesheet 'scss' %}\n.a { .b { color: red; } }\n{% endstylesheet %}\n"),
|
|
208
|
+
).toBeNull();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('lints the plain blocks of a file that also has an scss block', () => {
|
|
212
|
+
const masked = maskLiquidStylesheet(
|
|
213
|
+
[
|
|
214
|
+
"{% stylesheet 'scss' %}",
|
|
215
|
+
'.a { .b { color: red; } }',
|
|
216
|
+
'{% endstylesheet %}',
|
|
217
|
+
'{% stylesheet %}',
|
|
218
|
+
'.c { color: blue; }',
|
|
219
|
+
'{% endstylesheet %}',
|
|
220
|
+
'',
|
|
221
|
+
].join('\n'),
|
|
222
|
+
)!;
|
|
223
|
+
const lines = masked.split('\n');
|
|
224
|
+
|
|
225
|
+
expect(lines[1]!.trim()).toBe('');
|
|
226
|
+
expect(lines[4]).toBe('.c { color: blue; }');
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('skips a block that is commented out', () => {
|
|
230
|
+
expect(
|
|
231
|
+
maskLiquidStylesheet(
|
|
232
|
+
[
|
|
233
|
+
'{% comment %}',
|
|
234
|
+
'{% stylesheet %}',
|
|
235
|
+
'.a { color: red; }',
|
|
236
|
+
'{% endstylesheet %}',
|
|
237
|
+
'{% endcomment %}',
|
|
238
|
+
'',
|
|
239
|
+
].join('\n'),
|
|
240
|
+
),
|
|
241
|
+
).toBeNull();
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('skips a block inside a raw tag', () => {
|
|
245
|
+
expect(
|
|
246
|
+
maskLiquidStylesheet(
|
|
247
|
+
['{% raw %}', '{% stylesheet %}', '.a { color: red; }', '{% endstylesheet %}', '{% endraw %}', ''].join(
|
|
248
|
+
'\n',
|
|
249
|
+
),
|
|
250
|
+
),
|
|
251
|
+
).toBeNull();
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('still lints a live block alongside a commented-out one', () => {
|
|
255
|
+
const masked = maskLiquidStylesheet(
|
|
256
|
+
[
|
|
257
|
+
'{% comment %}',
|
|
258
|
+
'{% stylesheet %}',
|
|
259
|
+
'.old { color: red; }',
|
|
260
|
+
'{% endstylesheet %}',
|
|
261
|
+
'{% endcomment %}',
|
|
262
|
+
'{% stylesheet %}',
|
|
263
|
+
'.new { color: blue; }',
|
|
264
|
+
'{% endstylesheet %}',
|
|
265
|
+
'',
|
|
266
|
+
].join('\n'),
|
|
267
|
+
)!;
|
|
268
|
+
const lines = masked.split('\n');
|
|
269
|
+
|
|
270
|
+
expect(lines[2]!.trim()).toBe('');
|
|
271
|
+
expect(lines[6]).toBe('.new { color: blue; }');
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('does not discard a block that contains a comment tag', () => {
|
|
275
|
+
const masked = maskLiquidStylesheet(
|
|
276
|
+
[
|
|
277
|
+
'{% stylesheet %}',
|
|
278
|
+
'{% comment %}note{% endcomment %}',
|
|
279
|
+
'.a { color: red; }',
|
|
280
|
+
'{% endstylesheet %}',
|
|
281
|
+
'',
|
|
282
|
+
].join('\n'),
|
|
283
|
+
)!;
|
|
284
|
+
const lines = masked.split('\n');
|
|
285
|
+
|
|
286
|
+
expect(lines[1]!.trim()).toBe('');
|
|
287
|
+
expect(lines[2]).toBe('.a { color: red; }');
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('handles CRLF line endings without shifting columns', () => {
|
|
291
|
+
const source = '<div></div>\r\n{% stylesheet %}\r\n.a { color: red; }\r\n{% endstylesheet %}\r\n';
|
|
292
|
+
const masked = maskLiquidStylesheet(source)!;
|
|
293
|
+
|
|
294
|
+
expect(masked).toHaveLength(source.length);
|
|
295
|
+
// Only `\n` is preserved verbatim outside the body — a masked `\r` becomes
|
|
296
|
+
// a blank. That is enough: PostCSS counts lines by `\n`, and blanking
|
|
297
|
+
// cannot shift a column because the length is unchanged.
|
|
298
|
+
expect(masked.split('\n')[2]!.trimEnd()).toBe('.a { color: red; }');
|
|
299
|
+
});
|
|
300
|
+
});
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Length-preserving masking of a Liquid file down to just its
|
|
3
|
+
* `{% stylesheet %}` content.
|
|
4
|
+
*
|
|
5
|
+
* The output is the same length as the input, with newlines at the same
|
|
6
|
+
* indices, so line and column numbers reported against the masked source are
|
|
7
|
+
* already correct for the original `.liquid` file. That identity mapping is
|
|
8
|
+
* the whole point: `bin/lint-css.mjs` hands the masked text to Stylelint as
|
|
9
|
+
* `code` with the real `.liquid` path as `codeFilename` and needs no offset
|
|
10
|
+
* arithmetic at all.
|
|
11
|
+
*
|
|
12
|
+
* This is the CSS twin of `eslint-plugin-theory/src/liquid/mask.ts`. The two
|
|
13
|
+
* are deliberately separate files: each bundled package installs and builds on
|
|
14
|
+
* its own, and neither depends on the other. Three behaviours differ, all
|
|
15
|
+
* because CSS blocks differ from JavaScript ones — see `maskLiquid` below.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Matches a `{% stylesheet %}` … `{% endstylesheet %}` pair, capturing the
|
|
20
|
+
* opening tag's argument (if any) and the body.
|
|
21
|
+
*
|
|
22
|
+
* Current Shopify docs say the tag accepts no arguments, but the pre-2020
|
|
23
|
+
* `{% stylesheet 'scss' %}` form is still present in real themes, so the
|
|
24
|
+
* argument is matched in order to be recognised and skipped rather than
|
|
25
|
+
* silently mis-parsed as CSS.
|
|
26
|
+
*/
|
|
27
|
+
const STYLESHEET_BLOCK =
|
|
28
|
+
/(\{%-?\s*stylesheet\b([^%]*?)-?%\})([\s\S]*?)(\{%-?\s*endstylesheet\s*-?%\})/g;
|
|
29
|
+
|
|
30
|
+
/** Liquid tags whose bodies are not Liquid and must be masked wholesale. */
|
|
31
|
+
const RAW_CONTENT_TAG = /\{%-?\s*(comment|raw)\s*-?%\}[\s\S]*?\{%-?\s*end\1\s*-?%\}/g;
|
|
32
|
+
|
|
33
|
+
/** A Liquid output tag, e.g. `{{ section.settings.accent | default: '#000' }}`. */
|
|
34
|
+
const OUTPUT_TAG = /\{\{[\s\S]*?\}\}/g;
|
|
35
|
+
|
|
36
|
+
/** Any remaining Liquid tag, e.g. `{% if … %}`, `{% assign … %}`, `{% liquid … %}`. */
|
|
37
|
+
const LIQUID_TAG = /\{%[\s\S]*?%\}/g;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Characters that never appear inside a Liquid output tag but do appear in the
|
|
41
|
+
* CSS constructs that can be mistaken for one — most importantly a nested
|
|
42
|
+
* block, `@media (width > 0) {{ … }}` is not valid CSS, but a rule whose last
|
|
43
|
+
* declaration is followed immediately by two closing braces is, and the
|
|
44
|
+
* mirrored `{{` case shows up in `@supports`/`@media` nesting.
|
|
45
|
+
*
|
|
46
|
+
* Theme Check's `StaticStylesheetAndJavascriptTags` applies no such guard, so it
|
|
47
|
+
* does report that construct as Liquid. Keeping the guard here means the block
|
|
48
|
+
* is still linted as the CSS it is.
|
|
49
|
+
*/
|
|
50
|
+
const NOT_LIQUID_OUTPUT = /[{};]/;
|
|
51
|
+
|
|
52
|
+
interface Interval {
|
|
53
|
+
start: number;
|
|
54
|
+
end: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function blank(out: string[], start: number, end: number): void {
|
|
58
|
+
for (let index = start; index < end; index += 1) {
|
|
59
|
+
if (out[index] !== '\n') {
|
|
60
|
+
out[index] = ' ';
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function copy(source: string, out: string[], start: number, end: number): void {
|
|
66
|
+
for (let index = start; index < end; index += 1) {
|
|
67
|
+
out[index] = source[index]!;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function overlaps(intervals: Interval[], start: number, end: number): boolean {
|
|
72
|
+
return intervals.some((interval) => start < interval.end && end > interval.start);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Whether an offset falls inside one of the intervals.
|
|
77
|
+
*
|
|
78
|
+
* Used to decide whether a `{% stylesheet %}` tag is itself commented out.
|
|
79
|
+
* Containment, not overlap, is the question: a block that *contains* a
|
|
80
|
+
* `{% comment %}` is still live CSS, and testing for overlap would throw it
|
|
81
|
+
* away.
|
|
82
|
+
*/
|
|
83
|
+
function contains(intervals: Interval[], offset: number): boolean {
|
|
84
|
+
return intervals.some((interval) => offset >= interval.start && offset < interval.end);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Every `{% comment %}` / `{% raw %}` region in the source, in order. */
|
|
88
|
+
function rawContentRegions(source: string): Interval[] {
|
|
89
|
+
return [...source.matchAll(RAW_CONTENT_TAG)].map((match) => ({
|
|
90
|
+
start: match.index,
|
|
91
|
+
end: match.index + match[0].length,
|
|
92
|
+
}));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Mask the Liquid constructs inside one `{% stylesheet %}` body, in place.
|
|
97
|
+
*
|
|
98
|
+
* Order matters: `{% comment %}` and `{% raw %}` bodies are masked first so
|
|
99
|
+
* that Liquid-looking text inside them is not substituted a second time.
|
|
100
|
+
*
|
|
101
|
+
* Shopify does not render Liquid inside a `{% stylesheet %}` tag, so any
|
|
102
|
+
* interpolation found here is already broken theme code, and Theme Check's
|
|
103
|
+
* `StaticStylesheetAndJavascriptTags` reports it as an error. It is still masked
|
|
104
|
+
* rather than left in place, because a parse error at the first `{{` would hide
|
|
105
|
+
* every real offense in the rest of the block — the author gets the Liquid
|
|
106
|
+
* offense *and* the CSS offenses in one round.
|
|
107
|
+
*/
|
|
108
|
+
function maskLiquidInBody(source: string, out: string[], start: number, end: number): void {
|
|
109
|
+
const body = source.slice(start, end);
|
|
110
|
+
const rawRegions: Interval[] = [];
|
|
111
|
+
|
|
112
|
+
for (const match of body.matchAll(RAW_CONTENT_TAG)) {
|
|
113
|
+
const regionStart = start + match.index;
|
|
114
|
+
const regionEnd = regionStart + match[0].length;
|
|
115
|
+
blank(out, regionStart, regionEnd);
|
|
116
|
+
rawRegions.push({ start: regionStart, end: regionEnd });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
for (const match of body.matchAll(OUTPUT_TAG)) {
|
|
120
|
+
const inner = match[0].slice(2, -2);
|
|
121
|
+
if (NOT_LIQUID_OUTPUT.test(inner)) {
|
|
122
|
+
// Almost certainly nested CSS blocks rather than Liquid output.
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const regionStart = start + match.index;
|
|
126
|
+
const regionEnd = regionStart + match[0].length;
|
|
127
|
+
if (overlaps(rawRegions, regionStart, regionEnd)) {
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
blank(out, regionStart, regionEnd);
|
|
131
|
+
// A Liquid output can appear anywhere a CSS value component can, so stand
|
|
132
|
+
// it in with `0`: valid as a length, a number, or a bare value token. The
|
|
133
|
+
// substitution cannot be made correct for every property — `margin: 0 px`
|
|
134
|
+
// is what `margin: {{ s.gap }}px` becomes — so the value-shape rules may
|
|
135
|
+
// report on it. That is acceptable: the Liquid is itself an error, so the
|
|
136
|
+
// block has to be rewritten either way.
|
|
137
|
+
out[regionStart] = '0';
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
for (const match of body.matchAll(LIQUID_TAG)) {
|
|
141
|
+
const regionStart = start + match.index;
|
|
142
|
+
const regionEnd = regionStart + match[0].length;
|
|
143
|
+
if (overlaps(rawRegions, regionStart, regionEnd)) {
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
blank(out, regionStart, regionEnd);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Reduce a Liquid source to its `{% stylesheet %}` content, preserving every
|
|
152
|
+
* character position.
|
|
153
|
+
*
|
|
154
|
+
* Returns `null` when the file has nothing to lint, which lets the runner pass
|
|
155
|
+
* every modified `.liquid` file without reading it first. That is the case
|
|
156
|
+
* when:
|
|
157
|
+
*
|
|
158
|
+
* - there is no complete `{% stylesheet %}` … `{% endstylesheet %}` pair;
|
|
159
|
+
* - every such block sits inside a `{% comment %}` or `{% raw %}` region —
|
|
160
|
+
* commented-out blocks are common mid-migration;
|
|
161
|
+
* - every block carries an argument (`{% stylesheet 'scss' %}`), whose body is
|
|
162
|
+
* Sass rather than CSS and would only produce parse errors; or
|
|
163
|
+
* - every block's body is blank once masked, which would otherwise be reported
|
|
164
|
+
* as `no-empty-source`.
|
|
165
|
+
*/
|
|
166
|
+
export function maskLiquidStylesheet(source: string): string | null {
|
|
167
|
+
// Indexed per UTF-16 code unit, not per code point, so that offsets stay
|
|
168
|
+
// aligned with `String.prototype.slice` and with the offsets Stylelint
|
|
169
|
+
// reports.
|
|
170
|
+
const out = new Array<string>(source.length);
|
|
171
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
172
|
+
out[index] = source[index] === '\n' ? '\n' : ' ';
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const rawRegions = rawContentRegions(source);
|
|
176
|
+
let found = false;
|
|
177
|
+
|
|
178
|
+
for (const match of source.matchAll(STYLESHEET_BLOCK)) {
|
|
179
|
+
const blockStart = match.index;
|
|
180
|
+
if (contains(rawRegions, blockStart)) {
|
|
181
|
+
// A commented-out or raw-quoted block is not live CSS.
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (match[2]!.trim() !== '') {
|
|
185
|
+
// `{% stylesheet 'scss' %}` — the body is Sass, not CSS.
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const bodyStart = blockStart + match[1]!.length;
|
|
190
|
+
const bodyEnd = bodyStart + match[3]!.length;
|
|
191
|
+
copy(source, out, bodyStart, bodyEnd);
|
|
192
|
+
maskLiquidInBody(source, out, bodyStart, bodyEnd);
|
|
193
|
+
found = true;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (!found) {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const masked = out.join('');
|
|
201
|
+
// An empty block is not an offense; `no-empty-source` would call it one.
|
|
202
|
+
return masked.trim() === '' ? null : masked;
|
|
203
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type declarations for the two shared Stylelint configs.
|
|
3
|
+
*
|
|
4
|
+
* Neither package ships types, and no `@types/…` package exists for either, so
|
|
5
|
+
* `src/config.ts` would otherwise import them as `any` — which under `strict`
|
|
6
|
+
* is an error, and which would silently hide a change to their export shape.
|
|
7
|
+
*
|
|
8
|
+
* Both are ESM modules whose default export is a plain `Config`. `standard`
|
|
9
|
+
* additionally carries `extends: 'stylelint-config-recommended'` (a bare
|
|
10
|
+
* string, not an array); `createConfig` reads only `rules` from each, so that
|
|
11
|
+
* key is declared for accuracy rather than use.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
declare module 'stylelint-config-recommended' {
|
|
15
|
+
import type { Config } from 'stylelint';
|
|
16
|
+
|
|
17
|
+
const config: Config & { rules: NonNullable<Config['rules']> };
|
|
18
|
+
export default config;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
declare module 'stylelint-config-standard' {
|
|
22
|
+
import type { Config } from 'stylelint';
|
|
23
|
+
|
|
24
|
+
const config: Config & { extends: string; rules: NonNullable<Config['rules']> };
|
|
25
|
+
export default config;
|
|
26
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"declaration": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"forceConsistentCasingInFileNames": true,
|
|
9
|
+
"strict": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"types": ["node", "vitest/globals"]
|
|
12
|
+
},
|
|
13
|
+
"include": ["src/**/*.ts"]
|
|
14
|
+
}
|
|
Binary file
|