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.
Files changed (41) hide show
  1. package/package.json +1 -1
  2. package/skills/README.md +2 -2
  3. package/skills/shopify-lint/SKILL.md +26 -18
  4. package/skills/shopify-lint/eslint-plugin-theory/README.md +25 -8
  5. package/skills/shopify-lint/eslint-plugin-theory/src/liquid/mask.test.ts +41 -0
  6. package/skills/shopify-lint/eslint-plugin-theory/src/liquid/mask.ts +41 -2
  7. package/skills/shopify-lint/hooks/claude-stop.sh +2 -3
  8. package/skills/shopify-lint/hooks/codex-stop.sh +2 -3
  9. package/skills/shopify-lint/scripts/__pycache__/shopify_lint.cpython-312.pyc +0 -0
  10. package/skills/shopify-lint/scripts/setup.sh +21 -2
  11. package/skills/shopify-lint/scripts/shopify_lint.py +179 -30
  12. package/skills/shopify-lint/stylelint-config-theory/.stylelintignore +9 -0
  13. package/skills/shopify-lint/stylelint-config-theory/README.md +144 -0
  14. package/skills/shopify-lint/stylelint-config-theory/bin/lint-css.mjs +206 -0
  15. package/skills/shopify-lint/stylelint-config-theory/package-lock.json +3110 -0
  16. package/skills/shopify-lint/stylelint-config-theory/package.json +47 -0
  17. package/skills/shopify-lint/stylelint-config-theory/src/build.test.ts +31 -0
  18. package/skills/shopify-lint/stylelint-config-theory/src/config.test.ts +150 -0
  19. package/skills/shopify-lint/stylelint-config-theory/src/config.ts +109 -0
  20. package/skills/shopify-lint/stylelint-config-theory/src/index.ts +15 -0
  21. package/skills/shopify-lint/stylelint-config-theory/src/integration.test.ts +187 -0
  22. package/skills/shopify-lint/stylelint-config-theory/src/liquid/mask.test.ts +300 -0
  23. package/skills/shopify-lint/stylelint-config-theory/src/liquid/mask.ts +203 -0
  24. package/skills/shopify-lint/stylelint-config-theory/src/types/shared-configs.d.ts +26 -0
  25. package/skills/shopify-lint/stylelint-config-theory/tsconfig.build.json +9 -0
  26. package/skills/shopify-lint/stylelint-config-theory/tsconfig.json +14 -0
  27. package/skills/shopify-lint/stylelint-config-theory/vitest.config.ts +11 -0
  28. package/skills/shopify-lint/tests/__pycache__/test_shopify_lint.cpython-312.pyc +0 -0
  29. package/skills/shopify-lint/tests/test_shopify_lint.py +483 -8
  30. package/skills/shopify-lint/theme-check-theory/.theme-check.example.yml +7 -0
  31. package/skills/shopify-lint/theme-check-theory/README.md +48 -0
  32. package/skills/shopify-pre-pr/SKILL.md +129 -0
  33. package/skills/shopify-pre-pr/agents/openai.yaml +4 -0
  34. package/skills/shopify-pre-pr/references/td-change-delimiters.md +88 -0
  35. package/skills/shopify-pre-pr/scripts/__pycache__/test_vendor_change_audit.cpython-312.pyc +0 -0
  36. package/skills/shopify-pre-pr/scripts/__pycache__/vendor_change_audit.cpython-312.pyc +0 -0
  37. package/skills/shopify-pre-pr/scripts/test_vendor_change_audit.py +424 -0
  38. package/skills/shopify-pre-pr/scripts/vendor_change_audit.py +540 -0
  39. package/skills/record-changes/SKILL.md +0 -76
  40. package/skills/record-changes/agents/openai.yaml +0 -4
  41. /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,9 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "rootDir": "src",
6
+ "sourceMap": true
7
+ },
8
+ "exclude": ["src/**/*.test.ts", "src/test-utils.ts"]
9
+ }
@@ -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
+ }
@@ -0,0 +1,11 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: true,
6
+ coverage: {
7
+ include: ['src/**/*.ts'],
8
+ exclude: ['src/**/*.test.ts', 'src/test-utils.ts'],
9
+ },
10
+ },
11
+ });