td-ai-tools 1.3.2 → 1.3.3

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/bin/cli.js CHANGED
@@ -4,6 +4,7 @@
4
4
  * command dispatch. All filesystem work lives in `../lib/`.
5
5
  */
6
6
  import path from 'node:path';
7
+ import { spawn, spawnSync } from 'node:child_process';
7
8
  import { fileURLToPath } from 'node:url';
8
9
  import * as p from '@clack/prompts';
9
10
  import pc from 'picocolors';
@@ -14,6 +15,7 @@ import { readFrontmatterField } from '../lib/frontmatter.js';
14
15
  import {
15
16
  installSkill, installAgent, deleteSkill, deleteAgent, skillHasSetup,
16
17
  } from '../lib/installer.js';
18
+ import { createGutterWriter } from '../lib/gutter.js';
17
19
 
18
20
  const __filename = fileURLToPath(import.meta.url);
19
21
  const __dirname = path.dirname(__filename);
@@ -32,6 +34,14 @@ const { skillsDir: SKILLS_DIR, agentsDir: AGENTS_DIR } = ctx;
32
34
 
33
35
  const IS_TTY = Boolean(process.stdout.isTTY && process.stdin.isTTY);
34
36
 
37
+ /** Matches `@clack/prompts`' own unicode fallback so gutters line up everywhere. */
38
+ const IS_UNICODE = process.platform !== 'win32'
39
+ || Boolean(process.env.WT_SESSION)
40
+ || process.env.TERM_PROGRAM === 'vscode';
41
+
42
+ /** The vertical gutter `@clack/prompts` draws between log lines. */
43
+ const BAR = IS_UNICODE ? '│' : '|';
44
+
35
45
  /**
36
46
  * @typedef {import('../lib/catalog.js').ItemType} ItemType
37
47
  * @typedef {import('../lib/catalog.js').Ctx} Ctx
@@ -66,6 +76,43 @@ function status(kind, msg) {
66
76
  }
67
77
  }
68
78
 
79
+ /**
80
+ * Runs a skill setup command. Under a TTY the child's output is streamed through
81
+ * a gutter writer so multi-stage setups (npm installs, package builds) stay
82
+ * inside the CLI's log flow, with stderr folded into the same stream; elsewhere
83
+ * stdio is inherited unchanged so logs and CI see the raw output.
84
+ *
85
+ * @type {import('../lib/installer.js').SetupRunner}
86
+ */
87
+ function runSetupCommand({ command, args, cwd }) {
88
+ if (!IS_TTY) {
89
+ const result = spawnSync(command, args, { cwd, stdio: 'inherit', shell: false });
90
+ return Promise.resolve({ status: result.status, error: result.error });
91
+ }
92
+
93
+ return new Promise(resolve => {
94
+ // One writer for both streams: a shared line buffer keeps stdout and stderr
95
+ // in the order the child produced them and stops half-written lines from
96
+ // interleaving through the gutter.
97
+ const log = createGutterWriter({ stream: process.stdout, bar: BAR });
98
+ const child = spawn(command, args, {
99
+ cwd,
100
+ stdio: ['inherit', 'pipe', 'pipe'],
101
+ shell: false,
102
+ });
103
+ child.stdout.setEncoding('utf8');
104
+ child.stderr.setEncoding('utf8');
105
+ child.stdout.on('data', chunk => log.write(chunk, 'stdout'));
106
+ child.stderr.on('data', chunk => log.write(chunk, 'stderr'));
107
+ const finish = result => {
108
+ log.end();
109
+ resolve(result);
110
+ };
111
+ child.on('error', error => finish({ status: null, error }));
112
+ child.on('close', status => finish({ status }));
113
+ });
114
+ }
115
+
69
116
  /**
70
117
  * Display string for an installed item's version state, e.g.
71
118
  * `"v1.0.0 → v1.2.0 ⬆ outdated"` or `"v1.2.0 (up to date)"`.
@@ -139,14 +186,16 @@ function hintFor(type, name) {
139
186
  *
140
187
  * @param {MenuItem[]} items - Items to install.
141
188
  * @param {{replaceExisting?: boolean}} [options] - Forwarded to the installer.
142
- * @returns {boolean} Whether every installer reported success.
189
+ * @returns {Promise<boolean>} Whether every installer reported success.
143
190
  */
144
- function installItems(items, options = {}) {
191
+ async function installItems(items, options = {}) {
145
192
  const { runSetup = false, ...installOptions } = options;
146
193
  let ok = true;
147
194
  for (const item of items) {
148
195
  const installed = item.type === 'skill'
149
- ? installSkill(ctx, item.name, { ...installOptions, runSetup, report: status })
196
+ ? await installSkill(ctx, item.name, {
197
+ ...installOptions, runSetup, runner: runSetupCommand, report: status,
198
+ })
150
199
  : installAgent(ctx, item.name, { ...installOptions, report: status });
151
200
  if (!installed) ok = false;
152
201
  }
@@ -447,7 +496,7 @@ async function interactiveInstall(mode = 'install') {
447
496
  }
448
497
 
449
498
  const runSetup = await confirmSetup(items);
450
- const ok = installItems(items, { replaceExisting: mode === 'update', runSetup });
499
+ const ok = await installItems(items, { replaceExisting: mode === 'update', runSetup });
451
500
  if (!ok) {
452
501
  p.outro(pc.red(`${mode === 'update' ? 'Update' : 'Install'} completed with errors.`));
453
502
  process.exitCode = 1;
@@ -644,9 +693,9 @@ async function main() {
644
693
  if (cmd === 'install') {
645
694
  const { rest, runSetup } = parseInstallFlags(args.slice(1));
646
695
  if (rest[0] === '--all') {
647
- if (!installItems(buildMenu(), { runSetup })) process.exitCode = 1;
696
+ if (!await installItems(buildMenu(), { runSetup })) process.exitCode = 1;
648
697
  } else {
649
- if (!installItems(resolveNames(rest), { runSetup })) process.exitCode = 1;
698
+ if (!await installItems(resolveNames(rest), { runSetup })) process.exitCode = 1;
650
699
  }
651
700
  return;
652
701
  }
@@ -663,9 +712,11 @@ async function main() {
663
712
  status('info', 'No installed skills or agent packs match the catalog.');
664
713
  return;
665
714
  }
666
- if (!installItems(menu, { replaceExisting: true, runSetup })) process.exitCode = 1;
715
+ if (!await installItems(menu, { replaceExisting: true, runSetup })) process.exitCode = 1;
667
716
  } else {
668
- if (!installItems(resolveUpdateNames(rest), { replaceExisting: true, runSetup })) process.exitCode = 1;
717
+ if (!await installItems(resolveUpdateNames(rest), { replaceExisting: true, runSetup })) {
718
+ process.exitCode = 1;
719
+ }
669
720
  }
670
721
  return;
671
722
  }
package/lib/gutter.js ADDED
@@ -0,0 +1,89 @@
1
+ /**
2
+ * @file Renders streamed child-process output inside the `@clack/prompts` log
3
+ * gutter.
4
+ *
5
+ * Setup scripts (npm installs, package builds) emit long, unindented output that
6
+ * otherwise interrupts the CLI's vertical bar flow. This module reframes that
7
+ * output as subordinate detail: every line is prefixed with the gutter bar,
8
+ * dimmed, wrapped so long paths cannot escape the gutter, and runs of blank
9
+ * lines collapse to a single bar.
10
+ */
11
+ import pc from 'picocolors';
12
+
13
+ /** Matches ANSI escape sequences, which would fight the dim styling. */
14
+ const ANSI = /\u001B\[[0-9;?]*[ -\/]*[@-~]/g;
15
+
16
+ /**
17
+ * Wraps a line to a width, preferring a space break and hard-wrapping otherwise.
18
+ *
19
+ * @param {string} text - Single line of text (no newlines).
20
+ * @param {number} width - Maximum characters per returned line.
21
+ * @returns {string[]} One or more lines, none longer than `width`.
22
+ */
23
+ export function wrapLine(text, width) {
24
+ const lines = [];
25
+ let rest = text;
26
+ while (rest.length > width) {
27
+ const space = rest.lastIndexOf(' ', width);
28
+ const cut = space > width / 2 ? space : width;
29
+ lines.push(rest.slice(0, cut).trimEnd());
30
+ rest = rest.slice(cut).trimStart();
31
+ }
32
+ lines.push(rest);
33
+ return lines;
34
+ }
35
+
36
+ /**
37
+ * Creates a line-buffered writer that emits guttered output.
38
+ *
39
+ * @param {object} options
40
+ * @param {import('node:stream').Writable} options.stream - Destination stream.
41
+ * @param {string} options.bar - The gutter character (`'│'`, or `'|'` without unicode).
42
+ * @param {() => number} [options.columns] - Current terminal width; read per line so a mid-run resize is respected.
43
+ * @returns {{write: (chunk: string, channel?: string) => void, end: () => void}} `write` takes arbitrary chunks tagged with an optional source channel (so a partial line from one stream never absorbs the next line of another); `end` flushes a trailing partial line.
44
+ */
45
+ export function createGutterWriter({ stream, bar, columns = () => process.stdout.columns || 80 }) {
46
+ const gutter = pc.gray(bar);
47
+ let pending = '';
48
+ let blank = false;
49
+ let lastChannel = '';
50
+
51
+ const emit = raw => {
52
+ // Keep only the last carriage-return segment so redrawn progress lines
53
+ // (npm, tsc) land as one line rather than stacking up.
54
+ const carriage = raw.lastIndexOf('\r');
55
+ const line = (carriage === -1 ? raw : raw.slice(carriage + 1))
56
+ .replace(ANSI, '')
57
+ .replace(/\s+$/, '');
58
+
59
+ if (!line) {
60
+ if (!blank) stream.write(`${gutter}\n`);
61
+ blank = true;
62
+ return;
63
+ }
64
+ blank = false;
65
+
66
+ const width = Math.max(24, columns() - 6);
67
+ const [first, ...continued] = wrapLine(line, width);
68
+ stream.write(`${gutter} ${pc.dim(first)}\n`);
69
+ for (const cont of continued) stream.write(`${gutter} ${pc.dim(cont)}\n`);
70
+ };
71
+
72
+ return {
73
+ write(chunk, channel = '') {
74
+ if (channel !== lastChannel && pending) {
75
+ emit(pending);
76
+ pending = '';
77
+ }
78
+ lastChannel = channel;
79
+ pending += chunk;
80
+ const parts = pending.split('\n');
81
+ pending = parts.pop() ?? '';
82
+ for (const part of parts) emit(part);
83
+ },
84
+ end() {
85
+ if (pending) emit(pending);
86
+ pending = '';
87
+ },
88
+ };
89
+ }
package/lib/installer.js CHANGED
@@ -30,6 +30,21 @@ import {
30
30
  /** @type {ReportFn} */
31
31
  const noop = () => {};
32
32
 
33
+ /**
34
+ * Runs a skill's setup command and resolves with its exit result. The CLI passes
35
+ * a presentation-aware runner that keeps child output inside its log gutter;
36
+ * the default simply inherits this process's stdio.
37
+ * @callback SetupRunner
38
+ * @param {{command: string, args: string[], cwd: string}} spec - Command to run and the directory to run it in.
39
+ * @returns {Promise<{status: (number|null), error?: Error}>} Exit status, or a spawn `error`.
40
+ */
41
+
42
+ /** @type {SetupRunner} */
43
+ const inheritStdioRunner = async ({ command, args, cwd }) => {
44
+ const result = spawnSync(command, args, { cwd, stdio: 'inherit', shell: false });
45
+ return { status: result.status, error: result.error };
46
+ };
47
+
33
48
  /**
34
49
  * Recognized setup commands, in priority order, relative to an installed skill.
35
50
  * @type {{file: string, command: string, args: string[]}[]}
@@ -83,16 +98,17 @@ export function skillHasSetup(ctx, name) {
83
98
  * @param {string} dest - Installed skill directory.
84
99
  * @param {string} projectRoot - Root of the project receiving the skill.
85
100
  * @param {ReportFn} report
86
- * @returns {boolean} Whether setup succeeded or no setup existed.
101
+ * @param {SetupRunner} runner - Executes the setup command.
102
+ * @returns {Promise<boolean>} Whether setup succeeded or no setup existed.
87
103
  */
88
- function runSkillSetup(name, target, dest, projectRoot, report) {
104
+ async function runSkillSetup(name, target, dest, projectRoot, report, runner) {
89
105
  const setup = findSkillSetup(dest);
90
106
  if (setup) {
91
107
  report('step', `setup: ${name} running ${setup.command} ${setup.args.join(' ')} in ${target.root}/skills/${name}/`);
92
- const result = spawnSync(setup.command, setup.args, {
108
+ const result = await runner({
109
+ command: setup.command,
110
+ args: setup.args,
93
111
  cwd: dest,
94
- stdio: 'inherit',
95
- shell: false,
96
112
  });
97
113
  if (result.error) {
98
114
  report('error', `setup: ${name} failed in ${target.root}/skills/${name}/: ${result.error.message}`);
@@ -144,10 +160,16 @@ function registerBundledAgents(ctx, target, srcDir, report) {
144
160
  * @param {object} [options]
145
161
  * @param {boolean} [options.replaceExisting=false] - Overwrite an existing install (used by `update`).
146
162
  * @param {boolean} [options.runSetup=false] - Run recognized setup scripts after each target copy.
163
+ * @param {SetupRunner} [options.runner] - Executes setup commands; defaults to inheriting stdio.
147
164
  * @param {ReportFn} [options.report] - Progress sink.
148
- * @returns {boolean} `false` if the skill is not in the catalog or setup fails, otherwise `true`.
165
+ * @returns {Promise<boolean>} `false` if the skill is not in the catalog or setup fails, otherwise `true`.
149
166
  */
150
- export function installSkill(ctx, name, { replaceExisting = false, runSetup = false, report = noop } = {}) {
167
+ export async function installSkill(ctx, name, {
168
+ replaceExisting = false,
169
+ runSetup = false,
170
+ runner = inheritStdioRunner,
171
+ report = noop,
172
+ } = {}) {
151
173
  let ok = true;
152
174
  const src = path.join(ctx.skillsDir, name);
153
175
  if (!fs.existsSync(src)) {
@@ -164,7 +186,7 @@ export function installSkill(ctx, name, { replaceExisting = false, runSetup = fa
164
186
  copyDir(src, dest);
165
187
  report('success', `skill: ${name} ${replaceExisting ? 'updated' : 'installed'} → ${target.root}/skills/${name}/`);
166
188
  registerBundledAgents(ctx, target, src, report);
167
- if (runSetup && !runSkillSetup(name, target, dest, ctx.targetRoot, report)) ok = false;
189
+ if (runSetup && !(await runSkillSetup(name, target, dest, ctx.targetRoot, report, runner))) ok = false;
168
190
  }
169
191
  return ok;
170
192
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "td-ai-tools",
3
- "version": "1.3.2",
3
+ "version": "1.3.3",
4
4
  "description": "Install agent skills and packs into your project",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -1297,9 +1297,9 @@
1297
1297
  "license": "MIT"
1298
1298
  },
1299
1299
  "node_modules/brace-expansion": {
1300
- "version": "1.1.17",
1301
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.17.tgz",
1302
- "integrity": "sha512-w+aeW/mkgM4PyRMOJCgi3fOrTm5Q8QY1OSfn2TO2iuDj3ezIHqejmuxbjfPrqUkgqRew1iqkyAn0tr0ZwHD9+w==",
1300
+ "version": "1.1.18",
1301
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
1302
+ "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
1303
1303
  "license": "MIT",
1304
1304
  "dependencies": {
1305
1305
  "balanced-match": "^1.0.0",
@@ -199,6 +199,42 @@ describe('maskLiquid', () => {
199
199
  expect(maskLiquid(source)).toBeNull();
200
200
  });
201
201
 
202
+ it('skips a block inside a doc tag', () => {
203
+ // An `@example` shows the block a snippet expects its caller to provide; it
204
+ // is documentation, not code the storefront runs.
205
+ const source = [
206
+ '{% doc %}',
207
+ ' @example',
208
+ ' {% javascript %}',
209
+ ' run();',
210
+ ' {% endjavascript %}',
211
+ '{% enddoc %}',
212
+ '',
213
+ ].join('\n');
214
+ expect(maskLiquid(source)).toBeNull();
215
+ });
216
+
217
+ it('still lints a live block alongside a documented one', () => {
218
+ const masked =
219
+ maskLiquid(
220
+ [
221
+ '{% doc %}',
222
+ ' @example',
223
+ ' {% javascript %}',
224
+ ' example();',
225
+ ' {% endjavascript %}',
226
+ '{% enddoc %}',
227
+ '{% javascript %}',
228
+ 'live();',
229
+ '{% endjavascript %}',
230
+ '',
231
+ ].join('\n'),
232
+ ) ?? '';
233
+
234
+ expect(masked).toContain('live();');
235
+ expect(masked).not.toContain('example();');
236
+ });
237
+
202
238
  it('still lints a live block alongside a commented-out one', () => {
203
239
  const masked =
204
240
  maskLiquid(
@@ -19,8 +19,14 @@
19
19
  /** Matches a `{% javascript %}` … `{% endjavascript %}` pair, capturing the body. */
20
20
  const JAVASCRIPT_BLOCK = /(\{%-?\s*javascript\s*-?%\})([\s\S]*?)(\{%-?\s*endjavascript\s*-?%\})/g;
21
21
 
22
- /** Liquid tags whose bodies are not Liquid and must be masked wholesale. */
23
- const RAW_CONTENT_TAG = /\{%-?\s*(comment|raw)\s*-?%\}[\s\S]*?\{%-?\s*end\1\s*-?%\}/g;
22
+ /**
23
+ * Liquid tags whose bodies are not Liquid and must be masked wholesale.
24
+ *
25
+ * `{% doc %}` is included because a documented snippet's `@example` routinely
26
+ * shows the very `{% javascript %}` block the snippet expects — sample code
27
+ * that is never executed and must not be linted.
28
+ */
29
+ const RAW_CONTENT_TAG = /\{%-?\s*(comment|doc|raw)\s*-?%\}[\s\S]*?\{%-?\s*end\1\s*-?%\}/g;
24
30
 
25
31
  /** A Liquid output tag, e.g. `{{ section.settings.foo | json }}`. */
26
32
  const OUTPUT_TAG = /\{\{[\s\S]*?\}\}/g;
@@ -74,7 +80,7 @@ function contains(intervals: Interval[], offset: number): boolean {
74
80
  return intervals.some((interval) => offset >= interval.start && offset < interval.end);
75
81
  }
76
82
 
77
- /** Every `{% comment %}` / `{% raw %}` region in the source, in order. */
83
+ /** Every raw-content region in the source, in order. */
78
84
  function rawContentRegions(source: string): Interval[] {
79
85
  return [...source.matchAll(RAW_CONTENT_TAG)].map((match) => ({
80
86
  start: match.index,
@@ -85,8 +91,8 @@ function rawContentRegions(source: string): Interval[] {
85
91
  /**
86
92
  * Mask the Liquid constructs inside one `{% javascript %}` body, in place.
87
93
  *
88
- * Order matters: `{% comment %}` and `{% raw %}` bodies are masked first so
89
- * that Liquid-looking text inside them is not substituted a second time.
94
+ * Order matters: raw-content bodies are masked first so that Liquid-looking
95
+ * text inside them is not substituted a second time.
90
96
  */
91
97
  function maskLiquidInBody(source: string, out: string[], start: number, end: number): void {
92
98
  const body = source.slice(start, end);
@@ -134,9 +140,10 @@ function maskLiquidInBody(source: string, out: string[], start: number, end: num
134
140
  *
135
141
  * Returns `null` when the file contains no complete `{% javascript %}` block
136
142
  * that is live code, which lets the processor skip the file entirely. A block
137
- * that sits inside a `{% comment %}` or `{% raw %}` region does not count:
138
- * commented-out blocks are common mid-migration, and linting one would report
139
- * offenses in code the storefront never runs.
143
+ * that sits inside a `{% comment %}`, `{% doc %}`, or `{% raw %}` region does
144
+ * not count: commented-out blocks are common mid-migration and documented ones
145
+ * are illustrations, so linting either would report offenses in code the
146
+ * storefront never runs.
140
147
  */
141
148
  export function maskLiquid(source: string): string | null {
142
149
  // Indexed per UTF-16 code unit, not per code point, so that offsets stay
@@ -151,7 +158,7 @@ export function maskLiquid(source: string): string | null {
151
158
 
152
159
  for (const match of source.matchAll(JAVASCRIPT_BLOCK)) {
153
160
  if (contains(rawRegions, match.index)) {
154
- // A commented-out or raw-quoted block is not live JavaScript.
161
+ // A commented-out, documented, or raw-quoted block is not live JavaScript.
155
162
  continue;
156
163
  }
157
164
  found = true;
@@ -251,6 +251,45 @@ describe('maskLiquidStylesheet', () => {
251
251
  ).toBeNull();
252
252
  });
253
253
 
254
+ it('skips a block inside a doc tag', () => {
255
+ // An `@example` shows the block a snippet expects its caller to provide; it
256
+ // is documentation, not CSS the storefront renders.
257
+ expect(
258
+ maskLiquidStylesheet(
259
+ [
260
+ '{% doc %}',
261
+ ' @example',
262
+ ' {% stylesheet %}',
263
+ ' .a { color: red; }',
264
+ ' {% endstylesheet %}',
265
+ '{% enddoc %}',
266
+ '',
267
+ ].join('\n'),
268
+ ),
269
+ ).toBeNull();
270
+ });
271
+
272
+ it('still lints a live block alongside a documented one', () => {
273
+ const masked = maskLiquidStylesheet(
274
+ [
275
+ '{% doc %}',
276
+ ' @example',
277
+ ' {% stylesheet %}',
278
+ ' .old { color: red; }',
279
+ ' {% endstylesheet %}',
280
+ '{% enddoc %}',
281
+ '{% stylesheet %}',
282
+ '.new { color: blue; }',
283
+ '{% endstylesheet %}',
284
+ '',
285
+ ].join('\n'),
286
+ )!;
287
+ const lines = masked.split('\n');
288
+
289
+ expect(lines[3]!.trim()).toBe('');
290
+ expect(lines[7]).toBe('.new { color: blue; }');
291
+ });
292
+
254
293
  it('still lints a live block alongside a commented-out one', () => {
255
294
  const masked = maskLiquidStylesheet(
256
295
  [
@@ -27,8 +27,14 @@
27
27
  const STYLESHEET_BLOCK =
28
28
  /(\{%-?\s*stylesheet\b([^%]*?)-?%\})([\s\S]*?)(\{%-?\s*endstylesheet\s*-?%\})/g;
29
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;
30
+ /**
31
+ * Liquid tags whose bodies are not Liquid and must be masked wholesale.
32
+ *
33
+ * `{% doc %}` is included because a documented snippet's `@example` routinely
34
+ * shows the very `{% stylesheet %}` block the snippet expects — sample code
35
+ * that is never rendered and must not be linted.
36
+ */
37
+ const RAW_CONTENT_TAG = /\{%-?\s*(comment|doc|raw)\s*-?%\}[\s\S]*?\{%-?\s*end\1\s*-?%\}/g;
32
38
 
33
39
  /** A Liquid output tag, e.g. `{{ section.settings.accent | default: '#000' }}`. */
34
40
  const OUTPUT_TAG = /\{\{[\s\S]*?\}\}/g;
@@ -84,7 +90,7 @@ function contains(intervals: Interval[], offset: number): boolean {
84
90
  return intervals.some((interval) => offset >= interval.start && offset < interval.end);
85
91
  }
86
92
 
87
- /** Every `{% comment %}` / `{% raw %}` region in the source, in order. */
93
+ /** Every raw-content region in the source, in order. */
88
94
  function rawContentRegions(source: string): Interval[] {
89
95
  return [...source.matchAll(RAW_CONTENT_TAG)].map((match) => ({
90
96
  start: match.index,
@@ -95,8 +101,8 @@ function rawContentRegions(source: string): Interval[] {
95
101
  /**
96
102
  * Mask the Liquid constructs inside one `{% stylesheet %}` body, in place.
97
103
  *
98
- * Order matters: `{% comment %}` and `{% raw %}` bodies are masked first so
99
- * that Liquid-looking text inside them is not substituted a second time.
104
+ * Order matters: raw-content bodies are masked first so that Liquid-looking
105
+ * text inside them is not substituted a second time.
100
106
  *
101
107
  * Shopify does not render Liquid inside a `{% stylesheet %}` tag, so any
102
108
  * interpolation found here is already broken theme code, and Theme Check's
@@ -156,8 +162,9 @@ function maskLiquidInBody(source: string, out: string[], start: number, end: num
156
162
  * when:
157
163
  *
158
164
  * - 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;
165
+ * - every such block sits inside a `{% comment %}`, `{% doc %}`, or `{% raw %}`
166
+ * region — commented-out blocks are common mid-migration, and a documented
167
+ * block is an illustration rather than live CSS;
161
168
  * - every block carries an argument (`{% stylesheet 'scss' %}`), whose body is
162
169
  * Sass rather than CSS and would only produce parse errors; or
163
170
  * - every block's body is blank once masked, which would otherwise be reported
@@ -178,7 +185,7 @@ export function maskLiquidStylesheet(source: string): string | null {
178
185
  for (const match of source.matchAll(STYLESHEET_BLOCK)) {
179
186
  const blockStart = match.index;
180
187
  if (contains(rawRegions, blockStart)) {
181
- // A commented-out or raw-quoted block is not live CSS.
188
+ // A commented-out, documented, or raw-quoted block is not live CSS.
182
189
  continue;
183
190
  }
184
191
  if (match[2]!.trim() !== '') {
@@ -1071,16 +1071,16 @@
1071
1071
  }
1072
1072
  },
1073
1073
  "node_modules/brace-expansion": {
1074
- "version": "5.0.7",
1075
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
1076
- "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
1074
+ "version": "5.0.9",
1075
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
1076
+ "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
1077
1077
  "dev": true,
1078
1078
  "license": "MIT",
1079
1079
  "dependencies": {
1080
1080
  "balanced-match": "^4.0.2"
1081
1081
  },
1082
1082
  "engines": {
1083
- "node": "18 || 20 || >=22"
1083
+ "node": "20 || >=22"
1084
1084
  }
1085
1085
  },
1086
1086
  "node_modules/cac": {
@@ -165,10 +165,38 @@ describe('HardcodedText', () => {
165
165
  'schema',
166
166
  '{% schema %}{"name":"Hard coded","settings":[]}{% endschema %}',
167
167
  ],
168
+ [
169
+ 'doc',
170
+ '{% doc %}Hard coded documentation{% enddoc %}',
171
+ ],
168
172
  ])('ignores text inside the %s raw tag', async (_, source) => {
169
173
  expect(await runLiquidCheck(HardcodedText, source)).toEqual([]);
170
174
  });
171
175
 
176
+ it('ignores every part of a documentation block', async () => {
177
+ // The parser breaks a `{% doc %}` body into description, param, and example
178
+ // nodes, each carrying its own text — none of it rendered to the storefront.
179
+ const source = `{% doc %}
180
+ Renders a product card.
181
+
182
+ @param {product} product - The product to render
183
+ @param {string} [heading] - Optional heading text
184
+ @example
185
+ {% render 'card', product: product, heading: 'Featured' %}
186
+ {% enddoc %}`;
187
+
188
+ expect(await runLiquidCheck(HardcodedText, source)).toEqual([]);
189
+ });
190
+
191
+ it('still reports rendered text beside a documentation block', async () => {
192
+ const source = `{% doc %}
193
+ Renders a product card.
194
+ {% enddoc %}
195
+ <h2>Featured products</h2>`;
196
+
197
+ expect(await runLiquidCheck(HardcodedText, source)).toHaveLength(1);
198
+ });
199
+
172
200
  it('ignores comments, technical attributes, punctuation, and Liquid logic strings', async () => {
173
201
  const source = `
174
202
  <!-- Hard coded HTML comment -->
@@ -21,6 +21,7 @@ import {
21
21
 
22
22
  const EXEMPT_RAW_TAGS = new Set([
23
23
  'comment',
24
+ 'doc',
24
25
  'javascript',
25
26
  'schema',
26
27
  'stylesheet',