@gustcss/vite 0.10.1 → 0.11.1

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/README.md CHANGED
@@ -70,12 +70,39 @@ Create `gustcss.config.json` in your project root:
70
70
  }
71
71
  ```
72
72
 
73
+ ### Dev-Server Linting
74
+
75
+ Set `lint` to report class names that generate no CSS while the dev server runs.
76
+ The plugin runs `gustcss lint --json` on startup and after source changes, and
77
+ prints the diagnostics through the Vite logger.
78
+ The lint process runs asynchronously: at most one runs at a time, changes that
79
+ arrive while it runs are coalesced into a single follow-up run, and unchanged
80
+ results are not printed twice. Lint problems never stop the dev server, and the
81
+ child process is killed when the server closes.
82
+
83
+ ```javascript
84
+ export default defineConfig({
85
+ plugins: [gustcss({ lint: true })],
86
+ })
87
+ ```
88
+
89
+ Pass `{ strict: true }` to also report custom-looking classes and low-confidence
90
+ extractions (`gustcss lint --strict`).
91
+
92
+ The same check runs on demand with the CLI (`gustcss lint`), which is what CI
93
+ should use. See the CLI documentation for rules, configuration, and baselines.
94
+
73
95
  ### Production Class-Name Mangling
74
96
 
75
97
  Enable `mangleClassNames` to shorten generated selectors and matching source references during `vite build`.
76
98
 
77
99
  Development mode keeps readable class names.
78
100
 
101
+ The mangler also rewrites class literals passed to the class-composition helpers `cn`, `cx`, `clsx`, `classNames`, `cva`, `tv`, `twMerge`, and `twJoin` (the same default list the CLI scanner extracts class names from).
102
+ Variant helpers (`cva`, `tv`) keep their object keys untouched, because those keys are variant names, not classes. For `clsx`-style helpers the quoted object keys are classes and are rewritten.
103
+
104
+ A class reference the mangler cannot rewrite stops the build: an aliased helper (`import { cn as merge }`), an unquoted `clsx` object key, or a mapped class inside an unknown string. Add the class to `mangleExclude` or move the reference to a static string when the build reports one of those errors.
105
+
79
106
  ```javascript
80
107
  export default defineConfig({
81
108
  plugins: [
package/dist/index.cjs CHANGED
@@ -173,6 +173,105 @@ const SUPPORTED_SOURCE = /\.(?:[cm]?[jt]sx?|html)(?:$|\?)/;
173
173
  const HTML_SOURCE = /\.html(?:$|\?)/;
174
174
  const UNSUPPORTED_FRAMEWORK_SOURCE = /\.(?:vue|svelte)(?:$|\?)/;
175
175
  const DEPENDENCY_SOURCE = /(?:^|[\\/])node_modules[\\/]/;
176
+ const CLASS_FUNCTION_NAMES = [
177
+ "cn",
178
+ "cx",
179
+ "clsx",
180
+ "classNames",
181
+ "cva",
182
+ "tv",
183
+ "twMerge",
184
+ "twJoin"
185
+ ];
186
+ const CLASS_FUNCTION_CALL = new RegExp(`(?<![\\w$])(${CLASS_FUNCTION_NAMES.join("|")})\\s*\\(`, "g");
187
+ const VARIANT_FUNCTION_NAMES = /* @__PURE__ */ new Set(["cva", "tv"]);
188
+ const NESTED_CALL = /(?:^|[^\w$])([A-Za-z_$][\w$]*)\s*\(/g;
189
+ function nestedCallRanges(args) {
190
+ const ranges = [];
191
+ NESTED_CALL.lastIndex = 0;
192
+ let match;
193
+ while ((match = NESTED_CALL.exec(args)) !== null) {
194
+ const open = args.indexOf("(", match.index + match[0].length - 1);
195
+ if (open < 0) continue;
196
+ const end = findBalancedEnd(args, open, "(", ")");
197
+ if (end < 0) continue;
198
+ ranges.push({
199
+ start: match.index,
200
+ end: end + 1,
201
+ isHelper: CLASS_FUNCTION_NAMES.includes(match[1])
202
+ });
203
+ NESTED_CALL.lastIndex = open + 1;
204
+ }
205
+ return ranges;
206
+ }
207
+ function innermostCallAt(ranges, index) {
208
+ let found = null;
209
+ for (const range of ranges) if (index >= range.start && index < range.end) {
210
+ if (found === null || range.start > found.start) found = range;
211
+ }
212
+ return found;
213
+ }
214
+ function parenDepthAt(value, index) {
215
+ let depth = 0;
216
+ for (let i = 0; i < index; i += 1) if (value[i] === "(") depth += 1;
217
+ else if (value[i] === ")") depth -= 1;
218
+ return depth;
219
+ }
220
+ function isComparisonOperand(args, segment) {
221
+ let before = segment.start - 1;
222
+ while (before >= 0 && /\s/.test(args[before])) before -= 1;
223
+ const lead = before >= 1 ? args.slice(before - 1, before + 1) : "";
224
+ if (lead === "==" || lead === "!=" || before >= 1 && args[before] === "=") return true;
225
+ let after = segment.end;
226
+ while (after < args.length && /\s/.test(args[after])) after += 1;
227
+ const tail = args.slice(after, after + 2);
228
+ return tail === "==" || tail === "!=";
229
+ }
230
+ function defaultVariantsRanges(args) {
231
+ const ranges = [];
232
+ const pattern = /defaultVariants\s*:\s*\{/g;
233
+ let match;
234
+ while ((match = pattern.exec(args)) !== null) {
235
+ const open = args.indexOf("{", match.index);
236
+ const end = findBalancedEnd(args, open, "{", "}");
237
+ if (end < 0) break;
238
+ ranges.push([open, end + 1]);
239
+ pattern.lastIndex = end;
240
+ }
241
+ return ranges;
242
+ }
243
+ function inRanges(ranges, index) {
244
+ return ranges.some(([start, end]) => index >= start && index < end);
245
+ }
246
+ function maskIgnoredRanges(text, origins, ranges) {
247
+ if (ranges.length === 0) return text;
248
+ const chars = text.split("");
249
+ let sorted = [...ranges].sort((a, b) => a[0] - b[0]);
250
+ let cursor = 0;
251
+ for (let i = 0; i < chars.length && cursor < sorted.length; i += 1) {
252
+ const origin = origins[i];
253
+ while (cursor < sorted.length && origin >= sorted[cursor][1]) cursor += 1;
254
+ if (cursor >= sorted.length) break;
255
+ if (origin >= sorted[cursor][0] && origin < sorted[cursor][1]) {
256
+ if (chars[i] !== "\n") chars[i] = " ";
257
+ }
258
+ }
259
+ return chars.join("");
260
+ }
261
+ function stripObjectKeys(value) {
262
+ return value.replace(/([{,]\s*)([A-Za-z_$][\w$]*)(\s*:)/g, (_match, lead, name, tail) => {
263
+ return lead + " ".repeat(name.length) + tail;
264
+ });
265
+ }
266
+ function isObjectKey(args, segment) {
267
+ let before = segment.start - 1;
268
+ while (before >= 0 && /\s/.test(args[before])) before -= 1;
269
+ const lead = before >= 0 ? args[before] : "";
270
+ if (lead !== "{" && lead !== ",") return false;
271
+ let after = segment.end;
272
+ while (after < args.length && /\s/.test(args[after])) after += 1;
273
+ return args[after] === ":";
274
+ }
176
275
  function hasOwn(classes, token) {
177
276
  return Object.hasOwn(classes, token);
178
277
  }
@@ -522,6 +621,44 @@ function collectClassListCallEdits(code, structure, classes, id, edits) {
522
621
  pattern.lastIndex = end;
523
622
  }
524
623
  }
624
+ function collectClassFunctionCallEdits(code, structure, classes, id, edits, ignores) {
625
+ CLASS_FUNCTION_CALL.lastIndex = 0;
626
+ let match;
627
+ while ((match = CLASS_FUNCTION_CALL.exec(structure)) !== null) {
628
+ const name = match[1];
629
+ const open = code.indexOf("(", match.index + name.length);
630
+ if (open < 0) continue;
631
+ const end = findBalancedEnd(code, open, "(", ")");
632
+ if (end < 0) throw new Error(`[gustcss] incomplete ${name}(...) call in ${id}. Ensure the expression is properly closed.`);
633
+ const args = code.slice(open + 1, end - 1);
634
+ const argsStart = open + 1;
635
+ const skipKeys = VARIANT_FUNCTION_NAMES.has(name);
636
+ const skipRanges = skipKeys ? defaultVariantsRanges(args) : [];
637
+ const nestedCalls = nestedCallRanges(args);
638
+ const depthSource = stripQuotedSegments(args);
639
+ scanQuotedSegments(args, (segment) => {
640
+ const { start, quote, contents } = segment;
641
+ const nested = innermostCallAt(nestedCalls, start);
642
+ if (!(nested !== null && nested.isHelper) && parenDepthAt(depthSource, start) > 0) return;
643
+ if (skipKeys && quote !== "`" && isObjectKey(args, segment) || inRanges(skipRanges, segment.start) || isComparisonOperand(args, segment)) {
644
+ ignores.push([argsStart + segment.start, argsStart + segment.end]);
645
+ return;
646
+ }
647
+ const offset = argsStart + start + 1;
648
+ if (quote === "`") {
649
+ collectTemplateBodyEdits(contents, classes, offset, edits);
650
+ return;
651
+ }
652
+ collectClassListEdits(contents, classes, offset, edits);
653
+ });
654
+ let withoutStrings = stripQuotedSegments(args);
655
+ if (skipKeys) withoutStrings = stripObjectKeys(withoutStrings);
656
+ for (const [rangeStart, rangeEnd] of skipRanges) withoutStrings = withoutStrings.slice(0, rangeStart) + " ".repeat(rangeEnd - rangeStart) + withoutStrings.slice(rangeEnd);
657
+ const dynamic = findMappedToken(withoutStrings, classes);
658
+ if (dynamic) throw new Error(`[gustcss] mapped class "${dynamic}" remains in a dynamic argument of ${name}(...) in ${id}. Pass it as a direct string literal, or add it to mangleExclude.`);
659
+ CLASS_FUNCTION_CALL.lastIndex = end;
660
+ }
661
+ }
525
662
  function rejectDynamicSetAttributeCalls(code, structure, classes, id) {
526
663
  const pattern = /\.setAttribute\s*\(/g;
527
664
  let match;
@@ -604,6 +741,7 @@ function sourcesForFile(code, id) {
604
741
  }
605
742
  function collectEdits(code, classes, id) {
606
743
  const edits = [];
744
+ const ignores = [];
607
745
  const sources = sourcesForFile(code, id);
608
746
  const commentRanges = [];
609
747
  let commentIndex = 0;
@@ -640,15 +778,23 @@ function collectEdits(code, classes, id) {
640
778
  collectTemplateBodyEdits(match[2], classes, match.index + match[1].length, edits);
641
779
  }
642
780
  collectClassListCallEdits(code, sources.structure, classes, id, edits);
781
+ collectClassFunctionCallEdits(code, sources.structure, classes, id, edits, ignores);
643
782
  const setAttributePattern = /(\.setAttribute\(\s*["']class["']\s*,\s*)(["'])([\s\S]*?)(\2)(\s*\))/g;
644
783
  while ((match = setAttributePattern.exec(code)) !== null) {
645
784
  if (sources.structure[match.index] === " ") continue;
646
785
  collectClassListEdits(match[3], classes, match.index + match[1].length + 1, edits);
647
786
  }
648
- return edits;
787
+ return {
788
+ edits,
789
+ ignores
790
+ };
649
791
  }
650
792
  function collectRecognizedContextEdits(code, classes, id) {
651
- return applyEdits(code, collectEdits(code, classes, id));
793
+ const collected = collectEdits(code, classes, id);
794
+ return {
795
+ ...applyEdits(code, collected.edits),
796
+ ignores: collected.ignores
797
+ };
652
798
  }
653
799
  /**
654
800
  * Collect the rewrites for a source fragment (TypeScript frontmatter, an inline
@@ -657,15 +803,17 @@ function collectRecognizedContextEdits(code, classes, id) {
657
803
  * them inside a larger document.
658
804
  */
659
805
  function collectFragmentEdits(code, classes, id) {
660
- const edits = collectEdits(code, classes, id);
661
- const rewritten = applyEdits(code, edits).code;
806
+ const collected = collectEdits(code, classes, id);
807
+ const applied = applyEdits(code, collected.edits);
808
+ const rewritten = applied.code;
662
809
  const sources = sourcesForFile(rewritten, id);
810
+ const inspection = maskIgnoredRanges(sources.inspection, applied.origins, collected.ignores);
663
811
  rejectDynamicSetAttributeCalls(rewritten, sources.structure, classes, id);
664
812
  rejectDynamicClassNameAssignments(rewritten, sources.structure, classes, id);
665
813
  rejectAmbiguousClassExpressions(rewritten, sources.structure, classes, id);
666
- const ambiguous = findMappedInQuotedSegments(sources.inspection, classes);
814
+ const ambiguous = findMappedInQuotedSegments(inspection, classes);
667
815
  if (ambiguous) throw new Error(`[gustcss] mapped class "${ambiguous}" remains in an ambiguous string in ${id}. Move it to a static class/className or classList context, or add it to mangleExclude.`);
668
- return edits;
816
+ return collected.edits;
669
817
  }
670
818
  const BASE64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
671
819
  function encodeVLQ(value) {
@@ -733,13 +881,14 @@ function transformSource(code, classes, id) {
733
881
  const transformed = collectRecognizedContextEdits(code, classes, id);
734
882
  const rewritten = transformed.code;
735
883
  const sources = sourcesForFile(rewritten, id);
884
+ const inspection = maskIgnoredRanges(sources.inspection, transformed.origins, transformed.ignores);
736
885
  rejectDynamicSetAttributeCalls(rewritten, sources.structure, classes, id);
737
886
  rejectDynamicClassNameAssignments(rewritten, sources.structure, classes, id);
738
887
  rejectAmbiguousClassExpressions(rewritten, sources.structure, classes, id);
739
888
  return {
740
889
  code: rewritten,
741
890
  map: createSourceMap(code, rewritten, transformed.origins, id),
742
- inspection: sources.inspection
891
+ inspection
743
892
  };
744
893
  }
745
894
  function findAmbiguousMappedClass(code, classes) {
@@ -1174,6 +1323,7 @@ function createAstroTransformer({ parse, classes }) {
1174
1323
  * designed for build-time use only and should not process untrusted input.
1175
1324
  */
1176
1325
  const ASTRO_SOURCE = /\.astro(?:$|\?)/;
1326
+ const lintDebounceMs = 300;
1177
1327
  const ASTRO_MODULE = /^[^\0?]*\.astro$/;
1178
1328
  /**
1179
1329
  * Resolve @astrojs/compiler parse function from the project.
@@ -1191,12 +1341,156 @@ async function resolveAstroCompiler(root) {
1191
1341
  } catch {}
1192
1342
  return null;
1193
1343
  }
1344
+ /**
1345
+ * Vite logger へ出力する。テストなど logger が無い環境では console を使う。
1346
+ */
1347
+ function lintLogger(server) {
1348
+ const logger = server?.config?.logger;
1349
+ return {
1350
+ warn: (message) => logger?.warn ? logger.warn(message) : console.warn(message),
1351
+ error: (message) => logger?.error ? logger.error(message) : console.error(message)
1352
+ };
1353
+ }
1354
+ /**
1355
+ * Dev-server lint runner.
1356
+ *
1357
+ * Runs `gustcss lint --json` asynchronously and reports the diagnostics through
1358
+ * the logger. At most one lint process runs at a time; changes that arrive while
1359
+ * it runs are coalesced into one follow-up run. This keeps the dev server's
1360
+ * event loop free even on large projects.
1361
+ */
1362
+ function createLintRunner({ cwd, binaryPath, configPath, logger, strict }) {
1363
+ const maxOutput = 4194304;
1364
+ const timeoutMs = 15e3;
1365
+ let child = null;
1366
+ let running = false;
1367
+ let dirty = false;
1368
+ let closed = false;
1369
+ let lastSignature = null;
1370
+ function report(code, stdout, stderr, aborted) {
1371
+ if (aborted) return;
1372
+ if (code !== 0 && code !== 1) {
1373
+ logger.warn(`[gustcss] lint could not run: ${stderr.trim()}`);
1374
+ return;
1375
+ }
1376
+ let parsed;
1377
+ try {
1378
+ parsed = JSON.parse(stdout);
1379
+ } catch {
1380
+ logger.warn("[gustcss] lint output was not JSON");
1381
+ return;
1382
+ }
1383
+ const diagnostics = Array.isArray(parsed?.diagnostics) ? parsed.diagnostics : [];
1384
+ const signature = JSON.stringify(diagnostics.map((d) => [
1385
+ d.file,
1386
+ d.line,
1387
+ d.column,
1388
+ d.ruleId,
1389
+ d.severity,
1390
+ d.class,
1391
+ d.message
1392
+ ]));
1393
+ if (signature === lastSignature) return;
1394
+ lastSignature = signature;
1395
+ for (const d of diagnostics) {
1396
+ const text = `[gustcss] ${d.line ? `${d.file}:${d.line}:${d.column}` : d.file} ${d.ruleId}: ${d.message}`;
1397
+ if (d.severity === "error") logger.error(text);
1398
+ else logger.warn(text);
1399
+ }
1400
+ const suppressed = parsed?.summary?.suppressed ?? 0;
1401
+ if (diagnostics.length > 0 && suppressed > 0) logger.warn(`[gustcss] lint: ${suppressed} problem(s) suppressed by baseline or comments`);
1402
+ }
1403
+ let closing = false;
1404
+ function runOnce() {
1405
+ return new Promise((resolve) => {
1406
+ const args = ["lint", "--json"];
1407
+ if (configPath) args.push("--config", configPath);
1408
+ if (strict) args.push("--strict");
1409
+ let proc;
1410
+ try {
1411
+ proc = (0, child_process.spawn)(binaryPath, args, {
1412
+ cwd,
1413
+ stdio: [
1414
+ "ignore",
1415
+ "pipe",
1416
+ "pipe"
1417
+ ]
1418
+ });
1419
+ } catch (error) {
1420
+ logger.warn(`[gustcss] lint failed to run: ${error.message}`);
1421
+ resolve();
1422
+ return;
1423
+ }
1424
+ child = proc;
1425
+ let stdout = "";
1426
+ let stderr = "";
1427
+ let settled = false;
1428
+ let aborted = false;
1429
+ const timer = setTimeout(() => {
1430
+ aborted = true;
1431
+ logger.warn("[gustcss] lint timed out; skipping this run");
1432
+ proc.kill();
1433
+ }, timeoutMs);
1434
+ if (closing) {
1435
+ aborted = true;
1436
+ proc.kill();
1437
+ }
1438
+ const finish = (code) => {
1439
+ if (settled) return;
1440
+ settled = true;
1441
+ clearTimeout(timer);
1442
+ child = null;
1443
+ report(code, stdout, stderr, aborted || closing);
1444
+ resolve();
1445
+ };
1446
+ proc.stdout?.on("data", (chunk) => {
1447
+ if (stdout.length < maxOutput) stdout += chunk;
1448
+ });
1449
+ proc.stderr?.on("data", (chunk) => {
1450
+ if (stderr.length < maxOutput) stderr += chunk;
1451
+ });
1452
+ proc.on("error", (error) => {
1453
+ aborted = true;
1454
+ logger.warn(`[gustcss] lint failed to run: ${error.message}`);
1455
+ finish(-1);
1456
+ });
1457
+ proc.on("close", (code) => finish(code ?? -1));
1458
+ });
1459
+ }
1460
+ return {
1461
+ async request() {
1462
+ if (closed) return;
1463
+ if (running) {
1464
+ dirty = true;
1465
+ return;
1466
+ }
1467
+ running = true;
1468
+ do {
1469
+ dirty = false;
1470
+ await runOnce();
1471
+ } while (dirty && !closed);
1472
+ running = false;
1473
+ },
1474
+ close() {
1475
+ closed = true;
1476
+ dirty = false;
1477
+ closing = true;
1478
+ if (child) {
1479
+ child.kill();
1480
+ child = null;
1481
+ }
1482
+ }
1483
+ };
1484
+ }
1194
1485
  function cssUtility(opts = {}) {
1195
1486
  const output = opts.output || "src/styles/utility.css";
1196
1487
  const content = opts.content || ["./src/**/*.{js,ts,jsx,tsx,astro,vue}"];
1197
1488
  const configPath = opts.config;
1198
1489
  const outputToCssLayers = opts.outputToCssLayers;
1199
1490
  const mangleClassNames = opts.mangleClassNames === true;
1491
+ const lintEnabled = opts.lint === true || typeof opts.lint === "object" && opts.lint !== null;
1492
+ const lintOptions = typeof opts.lint === "object" && opts.lint !== null ? opts.lint : {};
1493
+ const lintSource = /\.[cm]?[jt]sx?$|\.(?:astro|html|vue|svelte|mdx?)$/;
1200
1494
  const mangleMap = opts.mangleMap || `${output}.classes.json`;
1201
1495
  const mangleExclude = opts.mangleExclude || [];
1202
1496
  let classNameTransformer = null;
@@ -1381,7 +1675,37 @@ function cssUtility(opts = {}) {
1381
1675
  }]
1382
1676
  });
1383
1677
  });
1678
+ let lintTimer = null;
1679
+ const lintRunner = lintEnabled ? createLintRunner({
1680
+ cwd,
1681
+ binaryPath,
1682
+ configPath: watchConfigPath,
1683
+ logger: lintLogger(server),
1684
+ strict: lintOptions.strict === true
1685
+ }) : null;
1686
+ if (lintRunner) {
1687
+ lintRunner.request();
1688
+ const scheduleLint = (file) => {
1689
+ if (typeof file === "string") {
1690
+ if (file.includes("node_modules")) return;
1691
+ if (!lintSource.test(file)) return;
1692
+ }
1693
+ if (lintTimer) clearTimeout(lintTimer);
1694
+ lintTimer = setTimeout(() => {
1695
+ lintTimer = null;
1696
+ lintRunner.request();
1697
+ }, lintDebounceMs);
1698
+ };
1699
+ server.watcher.on("change", scheduleLint);
1700
+ server.watcher.on("add", scheduleLint);
1701
+ server.watcher.on("unlink", scheduleLint);
1702
+ }
1384
1703
  const cleanup = () => {
1704
+ if (lintTimer) {
1705
+ clearTimeout(lintTimer);
1706
+ lintTimer = null;
1707
+ }
1708
+ lintRunner?.close();
1385
1709
  if (watchProcess) {
1386
1710
  watchProcess.kill();
1387
1711
  watchProcess = null;
package/dist/index.d.ts CHANGED
@@ -49,6 +49,28 @@ export interface GustcssPluginOptions {
49
49
  * Class names that must remain readable for external or ambiguous references.
50
50
  */
51
51
  mangleExclude?: string[]
52
+
53
+ /**
54
+ * Run `gustcss lint` while the dev server is running and report the
55
+ * diagnostics through the Vite logger. Enabled by `true` or an options
56
+ * object. Diagnostics never fail the dev server.
57
+ *
58
+ * @default false
59
+ */
60
+ lint?: boolean | GustcssLintOptions
61
+ }
62
+
63
+ /**
64
+ * Options for the dev-server lint integration.
65
+ */
66
+ export interface GustcssLintOptions {
67
+ /**
68
+ * Also report custom-looking classes and low-confidence extractions
69
+ * (`gustcss lint --strict`).
70
+ *
71
+ * @default false
72
+ */
73
+ strict?: boolean
52
74
  }
53
75
 
54
76
  /**
package/dist/index.mjs CHANGED
@@ -167,6 +167,105 @@ const SUPPORTED_SOURCE = /\.(?:[cm]?[jt]sx?|html)(?:$|\?)/;
167
167
  const HTML_SOURCE = /\.html(?:$|\?)/;
168
168
  const UNSUPPORTED_FRAMEWORK_SOURCE = /\.(?:vue|svelte)(?:$|\?)/;
169
169
  const DEPENDENCY_SOURCE = /(?:^|[\\/])node_modules[\\/]/;
170
+ const CLASS_FUNCTION_NAMES = [
171
+ "cn",
172
+ "cx",
173
+ "clsx",
174
+ "classNames",
175
+ "cva",
176
+ "tv",
177
+ "twMerge",
178
+ "twJoin"
179
+ ];
180
+ const CLASS_FUNCTION_CALL = new RegExp(`(?<![\\w$])(${CLASS_FUNCTION_NAMES.join("|")})\\s*\\(`, "g");
181
+ const VARIANT_FUNCTION_NAMES = /* @__PURE__ */ new Set(["cva", "tv"]);
182
+ const NESTED_CALL = /(?:^|[^\w$])([A-Za-z_$][\w$]*)\s*\(/g;
183
+ function nestedCallRanges(args) {
184
+ const ranges = [];
185
+ NESTED_CALL.lastIndex = 0;
186
+ let match;
187
+ while ((match = NESTED_CALL.exec(args)) !== null) {
188
+ const open = args.indexOf("(", match.index + match[0].length - 1);
189
+ if (open < 0) continue;
190
+ const end = findBalancedEnd(args, open, "(", ")");
191
+ if (end < 0) continue;
192
+ ranges.push({
193
+ start: match.index,
194
+ end: end + 1,
195
+ isHelper: CLASS_FUNCTION_NAMES.includes(match[1])
196
+ });
197
+ NESTED_CALL.lastIndex = open + 1;
198
+ }
199
+ return ranges;
200
+ }
201
+ function innermostCallAt(ranges, index) {
202
+ let found = null;
203
+ for (const range of ranges) if (index >= range.start && index < range.end) {
204
+ if (found === null || range.start > found.start) found = range;
205
+ }
206
+ return found;
207
+ }
208
+ function parenDepthAt(value, index) {
209
+ let depth = 0;
210
+ for (let i = 0; i < index; i += 1) if (value[i] === "(") depth += 1;
211
+ else if (value[i] === ")") depth -= 1;
212
+ return depth;
213
+ }
214
+ function isComparisonOperand(args, segment) {
215
+ let before = segment.start - 1;
216
+ while (before >= 0 && /\s/.test(args[before])) before -= 1;
217
+ const lead = before >= 1 ? args.slice(before - 1, before + 1) : "";
218
+ if (lead === "==" || lead === "!=" || before >= 1 && args[before] === "=") return true;
219
+ let after = segment.end;
220
+ while (after < args.length && /\s/.test(args[after])) after += 1;
221
+ const tail = args.slice(after, after + 2);
222
+ return tail === "==" || tail === "!=";
223
+ }
224
+ function defaultVariantsRanges(args) {
225
+ const ranges = [];
226
+ const pattern = /defaultVariants\s*:\s*\{/g;
227
+ let match;
228
+ while ((match = pattern.exec(args)) !== null) {
229
+ const open = args.indexOf("{", match.index);
230
+ const end = findBalancedEnd(args, open, "{", "}");
231
+ if (end < 0) break;
232
+ ranges.push([open, end + 1]);
233
+ pattern.lastIndex = end;
234
+ }
235
+ return ranges;
236
+ }
237
+ function inRanges(ranges, index) {
238
+ return ranges.some(([start, end]) => index >= start && index < end);
239
+ }
240
+ function maskIgnoredRanges(text, origins, ranges) {
241
+ if (ranges.length === 0) return text;
242
+ const chars = text.split("");
243
+ let sorted = [...ranges].sort((a, b) => a[0] - b[0]);
244
+ let cursor = 0;
245
+ for (let i = 0; i < chars.length && cursor < sorted.length; i += 1) {
246
+ const origin = origins[i];
247
+ while (cursor < sorted.length && origin >= sorted[cursor][1]) cursor += 1;
248
+ if (cursor >= sorted.length) break;
249
+ if (origin >= sorted[cursor][0] && origin < sorted[cursor][1]) {
250
+ if (chars[i] !== "\n") chars[i] = " ";
251
+ }
252
+ }
253
+ return chars.join("");
254
+ }
255
+ function stripObjectKeys(value) {
256
+ return value.replace(/([{,]\s*)([A-Za-z_$][\w$]*)(\s*:)/g, (_match, lead, name, tail) => {
257
+ return lead + " ".repeat(name.length) + tail;
258
+ });
259
+ }
260
+ function isObjectKey(args, segment) {
261
+ let before = segment.start - 1;
262
+ while (before >= 0 && /\s/.test(args[before])) before -= 1;
263
+ const lead = before >= 0 ? args[before] : "";
264
+ if (lead !== "{" && lead !== ",") return false;
265
+ let after = segment.end;
266
+ while (after < args.length && /\s/.test(args[after])) after += 1;
267
+ return args[after] === ":";
268
+ }
170
269
  function hasOwn(classes, token) {
171
270
  return Object.hasOwn(classes, token);
172
271
  }
@@ -516,6 +615,44 @@ function collectClassListCallEdits(code, structure, classes, id, edits) {
516
615
  pattern.lastIndex = end;
517
616
  }
518
617
  }
618
+ function collectClassFunctionCallEdits(code, structure, classes, id, edits, ignores) {
619
+ CLASS_FUNCTION_CALL.lastIndex = 0;
620
+ let match;
621
+ while ((match = CLASS_FUNCTION_CALL.exec(structure)) !== null) {
622
+ const name = match[1];
623
+ const open = code.indexOf("(", match.index + name.length);
624
+ if (open < 0) continue;
625
+ const end = findBalancedEnd(code, open, "(", ")");
626
+ if (end < 0) throw new Error(`[gustcss] incomplete ${name}(...) call in ${id}. Ensure the expression is properly closed.`);
627
+ const args = code.slice(open + 1, end - 1);
628
+ const argsStart = open + 1;
629
+ const skipKeys = VARIANT_FUNCTION_NAMES.has(name);
630
+ const skipRanges = skipKeys ? defaultVariantsRanges(args) : [];
631
+ const nestedCalls = nestedCallRanges(args);
632
+ const depthSource = stripQuotedSegments(args);
633
+ scanQuotedSegments(args, (segment) => {
634
+ const { start, quote, contents } = segment;
635
+ const nested = innermostCallAt(nestedCalls, start);
636
+ if (!(nested !== null && nested.isHelper) && parenDepthAt(depthSource, start) > 0) return;
637
+ if (skipKeys && quote !== "`" && isObjectKey(args, segment) || inRanges(skipRanges, segment.start) || isComparisonOperand(args, segment)) {
638
+ ignores.push([argsStart + segment.start, argsStart + segment.end]);
639
+ return;
640
+ }
641
+ const offset = argsStart + start + 1;
642
+ if (quote === "`") {
643
+ collectTemplateBodyEdits(contents, classes, offset, edits);
644
+ return;
645
+ }
646
+ collectClassListEdits(contents, classes, offset, edits);
647
+ });
648
+ let withoutStrings = stripQuotedSegments(args);
649
+ if (skipKeys) withoutStrings = stripObjectKeys(withoutStrings);
650
+ for (const [rangeStart, rangeEnd] of skipRanges) withoutStrings = withoutStrings.slice(0, rangeStart) + " ".repeat(rangeEnd - rangeStart) + withoutStrings.slice(rangeEnd);
651
+ const dynamic = findMappedToken(withoutStrings, classes);
652
+ if (dynamic) throw new Error(`[gustcss] mapped class "${dynamic}" remains in a dynamic argument of ${name}(...) in ${id}. Pass it as a direct string literal, or add it to mangleExclude.`);
653
+ CLASS_FUNCTION_CALL.lastIndex = end;
654
+ }
655
+ }
519
656
  function rejectDynamicSetAttributeCalls(code, structure, classes, id) {
520
657
  const pattern = /\.setAttribute\s*\(/g;
521
658
  let match;
@@ -598,6 +735,7 @@ function sourcesForFile(code, id) {
598
735
  }
599
736
  function collectEdits(code, classes, id) {
600
737
  const edits = [];
738
+ const ignores = [];
601
739
  const sources = sourcesForFile(code, id);
602
740
  const commentRanges = [];
603
741
  let commentIndex = 0;
@@ -634,15 +772,23 @@ function collectEdits(code, classes, id) {
634
772
  collectTemplateBodyEdits(match[2], classes, match.index + match[1].length, edits);
635
773
  }
636
774
  collectClassListCallEdits(code, sources.structure, classes, id, edits);
775
+ collectClassFunctionCallEdits(code, sources.structure, classes, id, edits, ignores);
637
776
  const setAttributePattern = /(\.setAttribute\(\s*["']class["']\s*,\s*)(["'])([\s\S]*?)(\2)(\s*\))/g;
638
777
  while ((match = setAttributePattern.exec(code)) !== null) {
639
778
  if (sources.structure[match.index] === " ") continue;
640
779
  collectClassListEdits(match[3], classes, match.index + match[1].length + 1, edits);
641
780
  }
642
- return edits;
781
+ return {
782
+ edits,
783
+ ignores
784
+ };
643
785
  }
644
786
  function collectRecognizedContextEdits(code, classes, id) {
645
- return applyEdits(code, collectEdits(code, classes, id));
787
+ const collected = collectEdits(code, classes, id);
788
+ return {
789
+ ...applyEdits(code, collected.edits),
790
+ ignores: collected.ignores
791
+ };
646
792
  }
647
793
  /**
648
794
  * Collect the rewrites for a source fragment (TypeScript frontmatter, an inline
@@ -651,15 +797,17 @@ function collectRecognizedContextEdits(code, classes, id) {
651
797
  * them inside a larger document.
652
798
  */
653
799
  function collectFragmentEdits(code, classes, id) {
654
- const edits = collectEdits(code, classes, id);
655
- const rewritten = applyEdits(code, edits).code;
800
+ const collected = collectEdits(code, classes, id);
801
+ const applied = applyEdits(code, collected.edits);
802
+ const rewritten = applied.code;
656
803
  const sources = sourcesForFile(rewritten, id);
804
+ const inspection = maskIgnoredRanges(sources.inspection, applied.origins, collected.ignores);
657
805
  rejectDynamicSetAttributeCalls(rewritten, sources.structure, classes, id);
658
806
  rejectDynamicClassNameAssignments(rewritten, sources.structure, classes, id);
659
807
  rejectAmbiguousClassExpressions(rewritten, sources.structure, classes, id);
660
- const ambiguous = findMappedInQuotedSegments(sources.inspection, classes);
808
+ const ambiguous = findMappedInQuotedSegments(inspection, classes);
661
809
  if (ambiguous) throw new Error(`[gustcss] mapped class "${ambiguous}" remains in an ambiguous string in ${id}. Move it to a static class/className or classList context, or add it to mangleExclude.`);
662
- return edits;
810
+ return collected.edits;
663
811
  }
664
812
  const BASE64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
665
813
  function encodeVLQ(value) {
@@ -727,13 +875,14 @@ function transformSource(code, classes, id) {
727
875
  const transformed = collectRecognizedContextEdits(code, classes, id);
728
876
  const rewritten = transformed.code;
729
877
  const sources = sourcesForFile(rewritten, id);
878
+ const inspection = maskIgnoredRanges(sources.inspection, transformed.origins, transformed.ignores);
730
879
  rejectDynamicSetAttributeCalls(rewritten, sources.structure, classes, id);
731
880
  rejectDynamicClassNameAssignments(rewritten, sources.structure, classes, id);
732
881
  rejectAmbiguousClassExpressions(rewritten, sources.structure, classes, id);
733
882
  return {
734
883
  code: rewritten,
735
884
  map: createSourceMap(code, rewritten, transformed.origins, id),
736
- inspection: sources.inspection
885
+ inspection
737
886
  };
738
887
  }
739
888
  function findAmbiguousMappedClass(code, classes) {
@@ -1168,6 +1317,7 @@ function createAstroTransformer({ parse, classes }) {
1168
1317
  * designed for build-time use only and should not process untrusted input.
1169
1318
  */
1170
1319
  const ASTRO_SOURCE = /\.astro(?:$|\?)/;
1320
+ const lintDebounceMs = 300;
1171
1321
  const ASTRO_MODULE = /^[^\0?]*\.astro$/;
1172
1322
  /**
1173
1323
  * Resolve @astrojs/compiler parse function from the project.
@@ -1185,12 +1335,156 @@ async function resolveAstroCompiler(root) {
1185
1335
  } catch {}
1186
1336
  return null;
1187
1337
  }
1338
+ /**
1339
+ * Vite logger へ出力する。テストなど logger が無い環境では console を使う。
1340
+ */
1341
+ function lintLogger(server) {
1342
+ const logger = server?.config?.logger;
1343
+ return {
1344
+ warn: (message) => logger?.warn ? logger.warn(message) : console.warn(message),
1345
+ error: (message) => logger?.error ? logger.error(message) : console.error(message)
1346
+ };
1347
+ }
1348
+ /**
1349
+ * Dev-server lint runner.
1350
+ *
1351
+ * Runs `gustcss lint --json` asynchronously and reports the diagnostics through
1352
+ * the logger. At most one lint process runs at a time; changes that arrive while
1353
+ * it runs are coalesced into one follow-up run. This keeps the dev server's
1354
+ * event loop free even on large projects.
1355
+ */
1356
+ function createLintRunner({ cwd, binaryPath, configPath, logger, strict }) {
1357
+ const maxOutput = 4194304;
1358
+ const timeoutMs = 15e3;
1359
+ let child = null;
1360
+ let running = false;
1361
+ let dirty = false;
1362
+ let closed = false;
1363
+ let lastSignature = null;
1364
+ function report(code, stdout, stderr, aborted) {
1365
+ if (aborted) return;
1366
+ if (code !== 0 && code !== 1) {
1367
+ logger.warn(`[gustcss] lint could not run: ${stderr.trim()}`);
1368
+ return;
1369
+ }
1370
+ let parsed;
1371
+ try {
1372
+ parsed = JSON.parse(stdout);
1373
+ } catch {
1374
+ logger.warn("[gustcss] lint output was not JSON");
1375
+ return;
1376
+ }
1377
+ const diagnostics = Array.isArray(parsed?.diagnostics) ? parsed.diagnostics : [];
1378
+ const signature = JSON.stringify(diagnostics.map((d) => [
1379
+ d.file,
1380
+ d.line,
1381
+ d.column,
1382
+ d.ruleId,
1383
+ d.severity,
1384
+ d.class,
1385
+ d.message
1386
+ ]));
1387
+ if (signature === lastSignature) return;
1388
+ lastSignature = signature;
1389
+ for (const d of diagnostics) {
1390
+ const text = `[gustcss] ${d.line ? `${d.file}:${d.line}:${d.column}` : d.file} ${d.ruleId}: ${d.message}`;
1391
+ if (d.severity === "error") logger.error(text);
1392
+ else logger.warn(text);
1393
+ }
1394
+ const suppressed = parsed?.summary?.suppressed ?? 0;
1395
+ if (diagnostics.length > 0 && suppressed > 0) logger.warn(`[gustcss] lint: ${suppressed} problem(s) suppressed by baseline or comments`);
1396
+ }
1397
+ let closing = false;
1398
+ function runOnce() {
1399
+ return new Promise((resolve) => {
1400
+ const args = ["lint", "--json"];
1401
+ if (configPath) args.push("--config", configPath);
1402
+ if (strict) args.push("--strict");
1403
+ let proc;
1404
+ try {
1405
+ proc = spawn(binaryPath, args, {
1406
+ cwd,
1407
+ stdio: [
1408
+ "ignore",
1409
+ "pipe",
1410
+ "pipe"
1411
+ ]
1412
+ });
1413
+ } catch (error) {
1414
+ logger.warn(`[gustcss] lint failed to run: ${error.message}`);
1415
+ resolve();
1416
+ return;
1417
+ }
1418
+ child = proc;
1419
+ let stdout = "";
1420
+ let stderr = "";
1421
+ let settled = false;
1422
+ let aborted = false;
1423
+ const timer = setTimeout(() => {
1424
+ aborted = true;
1425
+ logger.warn("[gustcss] lint timed out; skipping this run");
1426
+ proc.kill();
1427
+ }, timeoutMs);
1428
+ if (closing) {
1429
+ aborted = true;
1430
+ proc.kill();
1431
+ }
1432
+ const finish = (code) => {
1433
+ if (settled) return;
1434
+ settled = true;
1435
+ clearTimeout(timer);
1436
+ child = null;
1437
+ report(code, stdout, stderr, aborted || closing);
1438
+ resolve();
1439
+ };
1440
+ proc.stdout?.on("data", (chunk) => {
1441
+ if (stdout.length < maxOutput) stdout += chunk;
1442
+ });
1443
+ proc.stderr?.on("data", (chunk) => {
1444
+ if (stderr.length < maxOutput) stderr += chunk;
1445
+ });
1446
+ proc.on("error", (error) => {
1447
+ aborted = true;
1448
+ logger.warn(`[gustcss] lint failed to run: ${error.message}`);
1449
+ finish(-1);
1450
+ });
1451
+ proc.on("close", (code) => finish(code ?? -1));
1452
+ });
1453
+ }
1454
+ return {
1455
+ async request() {
1456
+ if (closed) return;
1457
+ if (running) {
1458
+ dirty = true;
1459
+ return;
1460
+ }
1461
+ running = true;
1462
+ do {
1463
+ dirty = false;
1464
+ await runOnce();
1465
+ } while (dirty && !closed);
1466
+ running = false;
1467
+ },
1468
+ close() {
1469
+ closed = true;
1470
+ dirty = false;
1471
+ closing = true;
1472
+ if (child) {
1473
+ child.kill();
1474
+ child = null;
1475
+ }
1476
+ }
1477
+ };
1478
+ }
1188
1479
  function cssUtility(opts = {}) {
1189
1480
  const output = opts.output || "src/styles/utility.css";
1190
1481
  const content = opts.content || ["./src/**/*.{js,ts,jsx,tsx,astro,vue}"];
1191
1482
  const configPath = opts.config;
1192
1483
  const outputToCssLayers = opts.outputToCssLayers;
1193
1484
  const mangleClassNames = opts.mangleClassNames === true;
1485
+ const lintEnabled = opts.lint === true || typeof opts.lint === "object" && opts.lint !== null;
1486
+ const lintOptions = typeof opts.lint === "object" && opts.lint !== null ? opts.lint : {};
1487
+ const lintSource = /\.[cm]?[jt]sx?$|\.(?:astro|html|vue|svelte|mdx?)$/;
1194
1488
  const mangleMap = opts.mangleMap || `${output}.classes.json`;
1195
1489
  const mangleExclude = opts.mangleExclude || [];
1196
1490
  let classNameTransformer = null;
@@ -1375,7 +1669,37 @@ function cssUtility(opts = {}) {
1375
1669
  }]
1376
1670
  });
1377
1671
  });
1672
+ let lintTimer = null;
1673
+ const lintRunner = lintEnabled ? createLintRunner({
1674
+ cwd,
1675
+ binaryPath,
1676
+ configPath: watchConfigPath,
1677
+ logger: lintLogger(server),
1678
+ strict: lintOptions.strict === true
1679
+ }) : null;
1680
+ if (lintRunner) {
1681
+ lintRunner.request();
1682
+ const scheduleLint = (file) => {
1683
+ if (typeof file === "string") {
1684
+ if (file.includes("node_modules")) return;
1685
+ if (!lintSource.test(file)) return;
1686
+ }
1687
+ if (lintTimer) clearTimeout(lintTimer);
1688
+ lintTimer = setTimeout(() => {
1689
+ lintTimer = null;
1690
+ lintRunner.request();
1691
+ }, lintDebounceMs);
1692
+ };
1693
+ server.watcher.on("change", scheduleLint);
1694
+ server.watcher.on("add", scheduleLint);
1695
+ server.watcher.on("unlink", scheduleLint);
1696
+ }
1378
1697
  const cleanup = () => {
1698
+ if (lintTimer) {
1699
+ clearTimeout(lintTimer);
1700
+ lintTimer = null;
1701
+ }
1702
+ lintRunner?.close();
1379
1703
  if (watchProcess) {
1380
1704
  watchProcess.kill();
1381
1705
  watchProcess = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gustcss/vite",
3
- "version": "0.10.1",
3
+ "version": "0.11.1",
4
4
  "description": "Vite plugin for GustCSS",
5
5
  "main": "dist/index.mjs",
6
6
  "module": "dist/index.mjs",
@@ -37,7 +37,7 @@
37
37
  "access": "public"
38
38
  },
39
39
  "dependencies": {
40
- "gustcss": "^0.10.1"
40
+ "gustcss": "^0.11.1"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@astrojs/compiler": "2.13.0",