@gustcss/vite 0.10.0 → 0.11.0

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/dist/index.mjs CHANGED
@@ -3,6 +3,7 @@ import { spawn, spawnSync } from "child_process";
3
3
  import path from "path";
4
4
  import fs from "fs";
5
5
  import { randomBytes } from "crypto";
6
+ import { pathToFileURL } from "node:url";
6
7
  //#region \0rolldown/runtime.js
7
8
  var __create = Object.create;
8
9
  var __defProp = Object.defineProperty;
@@ -164,8 +165,107 @@ var import_runner = /* @__PURE__ */ __toESM((/* @__PURE__ */ __commonJSMin(((exp
164
165
  })))(), 1);
165
166
  const SUPPORTED_SOURCE = /\.(?:[cm]?[jt]sx?|html)(?:$|\?)/;
166
167
  const HTML_SOURCE = /\.html(?:$|\?)/;
167
- const UNSUPPORTED_FRAMEWORK_SOURCE = /\.(?:vue|astro|svelte)(?:$|\?)/;
168
+ const UNSUPPORTED_FRAMEWORK_SOURCE = /\.(?:vue|svelte)(?:$|\?)/;
168
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
+ }
169
269
  function hasOwn(classes, token) {
170
270
  return Object.hasOwn(classes, token);
171
271
  }
@@ -210,6 +310,7 @@ function findRawTextClosing(code, tagName, start) {
210
310
  function analyzeHtml(code, id) {
211
311
  const markup = new Array(code.length).fill(" ");
212
312
  const scriptRanges = [];
313
+ const commentRanges = [];
213
314
  const exampleStack = [];
214
315
  let index = 0;
215
316
  while (index < code.length) {
@@ -217,7 +318,12 @@ function analyzeHtml(code, id) {
217
318
  if (open < 0) break;
218
319
  if (code.startsWith("<!--", open)) {
219
320
  const commentEnd = code.indexOf("-->", open + 4);
220
- index = commentEnd < 0 ? code.length : commentEnd + 3;
321
+ const end = commentEnd < 0 ? code.length : commentEnd + 3;
322
+ commentRanges.push({
323
+ start: open,
324
+ end
325
+ });
326
+ index = end;
221
327
  continue;
222
328
  }
223
329
  const tagEnd = findHtmlTagEnd(code, open);
@@ -253,7 +359,8 @@ function analyzeHtml(code, id) {
253
359
  if (exampleStack.length > 0) throw new Error(`[gustcss] unclosed <${exampleStack.at(-1)}> in ${id}`);
254
360
  return {
255
361
  markup: markup.join(""),
256
- scriptRanges
362
+ scriptRanges,
363
+ commentRanges
257
364
  };
258
365
  }
259
366
  function canStartRegex(code, index, previous) {
@@ -481,7 +588,7 @@ function rejectAmbiguousClassExpressions(code, structure, classes, id) {
481
588
  while ((match = pattern.exec(structure)) !== null) {
482
589
  const open = code.indexOf("{", match.index);
483
590
  const end = findBalancedEnd(code, open, "{", "}");
484
- if (end < 0) break;
591
+ if (end < 0) throw new Error(`[gustcss] incomplete class/className expression in ${id}. Ensure the expression is properly closed.`);
485
592
  const ambiguous = findMappedToken(code.slice(open + 1, end - 1), classes);
486
593
  if (ambiguous) throw new Error(`[gustcss] mapped class "${ambiguous}" remains in an ambiguous class expression in ${id}. Use a static class attribute/template segment, or add it to mangleExclude.`);
487
594
  pattern.lastIndex = end;
@@ -508,6 +615,44 @@ function collectClassListCallEdits(code, structure, classes, id, edits) {
508
615
  pattern.lastIndex = end;
509
616
  }
510
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
+ }
511
656
  function rejectDynamicSetAttributeCalls(code, structure, classes, id) {
512
657
  const pattern = /\.setAttribute\s*\(/g;
513
658
  let match;
@@ -575,40 +720,94 @@ function sourcesForFile(code, id) {
575
720
  const inspection = new Array(code.length).fill(" ");
576
721
  for (const range of html.scriptRanges) {
577
722
  const script = code.slice(range.start, range.end);
578
- const js = createJsMasks(script);
723
+ const jsScript = createJsMasks(script);
579
724
  for (let offset = 0; offset < script.length; offset += 1) {
580
- structure[range.start + offset] = js.structure[offset];
581
- inspection[range.start + offset] = js.inspection[offset];
725
+ structure[range.start + offset] = jsScript.structure[offset];
726
+ inspection[range.start + offset] = jsScript.inspection[offset];
582
727
  }
583
728
  }
729
+ for (const range of html.commentRanges) for (let offset = range.start; offset < range.end; offset += 1) inspection[offset] = " ";
584
730
  return {
585
731
  attributes: html.markup,
586
732
  structure: structure.join(""),
587
733
  inspection: inspection.join("")
588
734
  };
589
735
  }
590
- function collectRecognizedContextEdits(code, classes, id) {
736
+ function collectEdits(code, classes, id) {
591
737
  const edits = [];
738
+ const ignores = [];
592
739
  const sources = sourcesForFile(code, id);
740
+ const commentRanges = [];
741
+ let commentIndex = 0;
742
+ while ((commentIndex = code.indexOf("<!--", commentIndex)) >= 0) {
743
+ const commentEnd = code.indexOf("-->", commentIndex + 4);
744
+ if (commentEnd < 0) {
745
+ commentRanges.push({
746
+ start: commentIndex,
747
+ end: code.length
748
+ });
749
+ break;
750
+ }
751
+ commentRanges.push({
752
+ start: commentIndex,
753
+ end: commentEnd + 3
754
+ });
755
+ commentIndex = commentEnd + 3;
756
+ }
757
+ function isInComment(pos) {
758
+ return commentRanges.some((range) => pos >= range.start && pos < range.end);
759
+ }
593
760
  const attributePattern = /((?<![:\w-])class(?:Name)?\s*=\s*)(?:(["'])([\s\S]*?)(\2)|(\{\s*)(["'])([\s\S]*?)(\6)(\s*\}))/g;
594
761
  let match;
595
762
  while ((match = attributePattern.exec(sources.attributes)) !== null) {
763
+ if (isInComment(match.index)) continue;
596
764
  if (!HTML_SOURCE.test(id) && !isJsxAttributeAt(sources.structure, match.index)) continue;
597
765
  if (match[2] !== void 0) collectClassListEdits(match[3], classes, match.index + match[1].length + 1, edits);
598
766
  else collectClassListEdits(match[7], classes, match.index + match[1].length + match[5].length + 1, edits);
599
767
  }
600
768
  const templatePattern = /((?<![:\w-])class(?:Name)?\s*=\s*\{\s*`)([\s\S]*?)(`\s*\})/g;
601
769
  while ((match = templatePattern.exec(sources.attributes)) !== null) {
770
+ if (isInComment(match.index)) continue;
602
771
  if (!HTML_SOURCE.test(id) && !isJsxAttributeAt(sources.structure, match.index)) continue;
603
772
  collectTemplateBodyEdits(match[2], classes, match.index + match[1].length, edits);
604
773
  }
605
774
  collectClassListCallEdits(code, sources.structure, classes, id, edits);
775
+ collectClassFunctionCallEdits(code, sources.structure, classes, id, edits, ignores);
606
776
  const setAttributePattern = /(\.setAttribute\(\s*["']class["']\s*,\s*)(["'])([\s\S]*?)(\2)(\s*\))/g;
607
777
  while ((match = setAttributePattern.exec(code)) !== null) {
608
778
  if (sources.structure[match.index] === " ") continue;
609
779
  collectClassListEdits(match[3], classes, match.index + match[1].length + 1, edits);
610
780
  }
611
- return applyEdits(code, edits);
781
+ return {
782
+ edits,
783
+ ignores
784
+ };
785
+ }
786
+ function collectRecognizedContextEdits(code, classes, id) {
787
+ const collected = collectEdits(code, classes, id);
788
+ return {
789
+ ...applyEdits(code, collected.edits),
790
+ ignores: collected.ignores
791
+ };
792
+ }
793
+ /**
794
+ * Collect the rewrites for a source fragment (TypeScript frontmatter, an inline
795
+ * `<script>` body, …) and run the same fail-closed checks as `transformSource`,
796
+ * but return the edits instead of the rewritten code so the caller can place
797
+ * them inside a larger document.
798
+ */
799
+ function collectFragmentEdits(code, classes, id) {
800
+ const collected = collectEdits(code, classes, id);
801
+ const applied = applyEdits(code, collected.edits);
802
+ const rewritten = applied.code;
803
+ const sources = sourcesForFile(rewritten, id);
804
+ const inspection = maskIgnoredRanges(sources.inspection, applied.origins, collected.ignores);
805
+ rejectDynamicSetAttributeCalls(rewritten, sources.structure, classes, id);
806
+ rejectDynamicClassNameAssignments(rewritten, sources.structure, classes, id);
807
+ rejectAmbiguousClassExpressions(rewritten, sources.structure, classes, id);
808
+ const ambiguous = findMappedInQuotedSegments(inspection, classes);
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.`);
810
+ return collected.edits;
612
811
  }
613
812
  const BASE64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
614
813
  function encodeVLQ(value) {
@@ -676,13 +875,14 @@ function transformSource(code, classes, id) {
676
875
  const transformed = collectRecognizedContextEdits(code, classes, id);
677
876
  const rewritten = transformed.code;
678
877
  const sources = sourcesForFile(rewritten, id);
878
+ const inspection = maskIgnoredRanges(sources.inspection, transformed.origins, transformed.ignores);
679
879
  rejectDynamicSetAttributeCalls(rewritten, sources.structure, classes, id);
680
880
  rejectDynamicClassNameAssignments(rewritten, sources.structure, classes, id);
681
881
  rejectAmbiguousClassExpressions(rewritten, sources.structure, classes, id);
682
882
  return {
683
883
  code: rewritten,
684
884
  map: createSourceMap(code, rewritten, transformed.origins, id),
685
- inspection: sources.inspection
885
+ inspection
686
886
  };
687
887
  }
688
888
  function findAmbiguousMappedClass(code, classes) {
@@ -696,7 +896,7 @@ function createClassNameTransformer(classes) {
696
896
  const transformWithSourceMap = (code, id) => {
697
897
  if (UNSUPPORTED_FRAMEWORK_SOURCE.test(id)) {
698
898
  const referenced = findMappedToken(code, classes);
699
- if (referenced) throw new Error(`[gustcss] class-name mangling does not support Vue, Astro, or Svelte templates yet (${id}). Add "${referenced}" to mangleExclude or disable mangleClassNames.`);
899
+ if (referenced) throw new Error(`[gustcss] class-name mangling does not support Vue or Svelte templates yet (${id}). Add "${referenced}" to mangleExclude or disable mangleClassNames.`);
700
900
  return {
701
901
  code,
702
902
  map: null
@@ -716,9 +916,392 @@ function createClassNameTransformer(classes) {
716
916
  };
717
917
  const transform = (code, id) => transformWithSourceMap(code, id).code;
718
918
  transform.withSourceMap = transformWithSourceMap;
919
+ transform.classes = classes;
719
920
  return transform;
720
921
  }
721
922
  //#endregion
923
+ //#region src/astro.js
924
+ /**
925
+ * Class-name mangling for `.astro` sources, driven by the AST that
926
+ * `@astrojs/compiler`'s `parse()` returns.
927
+ *
928
+ * Only unambiguous class contexts are rewritten; any other place where a
929
+ * mapped class name shows up fails the build (fail-closed), because a class
930
+ * that reaches the DOM unrenamed would no longer match the mangled CSS.
931
+ */
932
+ const CLASS_VALUED_BEFORE = /* @__PURE__ */ new Set([
933
+ "[",
934
+ ",",
935
+ "&&",
936
+ "||"
937
+ ]);
938
+ const CLASS_VALUED_AFTER = /* @__PURE__ */ new Set([
939
+ ",",
940
+ "]",
941
+ "&&",
942
+ "||"
943
+ ]);
944
+ const OPERATORS = [
945
+ "===",
946
+ "!==",
947
+ "&&",
948
+ "||",
949
+ "??",
950
+ "=>",
951
+ "==",
952
+ "!=",
953
+ "?.",
954
+ "<=",
955
+ ">="
956
+ ];
957
+ const PUNCTUATION = "[]{}(),:?.!+-*/%<>=&|~^;";
958
+ /** Map UTF-8 byte offsets (what the compiler reports) to string indexes. */
959
+ function byteToIndexMap(code) {
960
+ const map = [];
961
+ let byte = 0;
962
+ for (let index = 0; index < code.length; index += 1) {
963
+ const point = code.codePointAt(index);
964
+ const width = point < 128 ? 1 : point < 2048 ? 2 : point < 65536 ? 3 : 4;
965
+ for (let i = 0; i < width; i += 1) map[byte + i] = index;
966
+ byte += width;
967
+ if (point > 65535) index += 1;
968
+ }
969
+ map[byte] = code.length;
970
+ return map;
971
+ }
972
+ /**
973
+ * Tokenize a JavaScript expression just far enough to know, for every string
974
+ * literal and identifier, what surrounds it. Template literals are reported
975
+ * as strings with quote "`" and are never rewritten. Returns null when the
976
+ * expression cannot be tokenized (unterminated string, unknown character).
977
+ */
978
+ function tokenizeExpression(source) {
979
+ const tokens = [];
980
+ let index = 0;
981
+ while (index < source.length) {
982
+ const char = source[index];
983
+ if (/\s/.test(char)) {
984
+ index += 1;
985
+ continue;
986
+ }
987
+ if (source.startsWith("//", index)) {
988
+ const newline = source.indexOf("\n", index);
989
+ index = newline < 0 ? source.length : newline + 1;
990
+ continue;
991
+ }
992
+ if (source.startsWith("/*", index)) {
993
+ const close = source.indexOf("*/", index + 2);
994
+ if (close < 0) return null;
995
+ index = close + 2;
996
+ continue;
997
+ }
998
+ if (char === "\"" || char === "'" || char === "`") {
999
+ const start = index;
1000
+ index += 1;
1001
+ while (index < source.length && source[index] !== char) {
1002
+ if (source[index] === "\\") index += 1;
1003
+ index += 1;
1004
+ }
1005
+ if (index >= source.length) return null;
1006
+ tokens.push({
1007
+ type: "string",
1008
+ quote: char,
1009
+ start,
1010
+ end: index + 1,
1011
+ value: source.slice(start + 1, index)
1012
+ });
1013
+ index += 1;
1014
+ continue;
1015
+ }
1016
+ if (/[A-Za-z_$]/.test(char)) {
1017
+ const start = index;
1018
+ while (index < source.length && /[\w$]/.test(source[index])) index += 1;
1019
+ tokens.push({
1020
+ type: "identifier",
1021
+ start,
1022
+ end: index,
1023
+ value: source.slice(start, index)
1024
+ });
1025
+ continue;
1026
+ }
1027
+ if (/[0-9]/.test(char)) {
1028
+ const start = index;
1029
+ while (index < source.length && /[\w.]/.test(source[index])) index += 1;
1030
+ tokens.push({
1031
+ type: "number",
1032
+ start,
1033
+ end: index,
1034
+ value: source.slice(start, index)
1035
+ });
1036
+ continue;
1037
+ }
1038
+ const operator = OPERATORS.find((candidate) => source.startsWith(candidate, index));
1039
+ if (operator) {
1040
+ tokens.push({
1041
+ type: "operator",
1042
+ start: index,
1043
+ end: index + operator.length,
1044
+ value: operator
1045
+ });
1046
+ index += operator.length;
1047
+ continue;
1048
+ }
1049
+ if (PUNCTUATION.includes(char)) {
1050
+ tokens.push({
1051
+ type: "operator",
1052
+ start: index,
1053
+ end: index + 1,
1054
+ value: char
1055
+ });
1056
+ index += 1;
1057
+ continue;
1058
+ }
1059
+ return null;
1060
+ }
1061
+ return tokens;
1062
+ }
1063
+ /**
1064
+ * Rewrite the class-valued string literals of a `class:list` expression.
1065
+ * Accepted shapes are array literals, object literals and their nesting.
1066
+ * Inside them only array elements (also as the right operand of `&&` / `||`)
1067
+ * and quoted object keys are class-valued; every other occurrence of a mapped
1068
+ * class name fails closed.
1069
+ */
1070
+ function collectClassListExpressionEdits(source, classes, baseOffset, edits, id) {
1071
+ const fail = (token, reason) => {
1072
+ throw new Error(`[gustcss] mapped class "${token}" ${reason} in class:list of ${id}. Use an array/object literal with quoted entries, or add it to mangleExclude.`);
1073
+ };
1074
+ const tokens = tokenizeExpression(source);
1075
+ const first = tokens && tokens[0];
1076
+ const last = tokens && tokens[tokens.length - 1];
1077
+ if (!(tokens && tokens.length >= 2 && (first.value === "[" && last.value === "]" || first.value === "{" && last.value === "}"))) {
1078
+ const mapped = findMappedToken(source, classes);
1079
+ if (mapped) fail(mapped, "remains in an expression that is not an array or object literal");
1080
+ return;
1081
+ }
1082
+ const stack = [];
1083
+ for (let i = 0; i < tokens.length; i += 1) {
1084
+ const token = tokens[i];
1085
+ const previous = tokens[i - 1];
1086
+ const next = tokens[i + 1];
1087
+ const context = stack[stack.length - 1];
1088
+ if (token.type === "operator") {
1089
+ if (token.value === "[") {
1090
+ const subscript = previous && (previous.type === "identifier" || previous.type === "string" || previous.value === ")" || previous.value === "]");
1091
+ stack.push(subscript ? "subscript" : "array");
1092
+ } else if (token.value === "{") stack.push("object");
1093
+ else if (token.value === "(") stack.push("call");
1094
+ else if (token.value === "]" || token.value === "}" || token.value === ")") {
1095
+ if (stack.length === 0) fail(findMappedToken(source, classes) || "?", "appears in an unbalanced");
1096
+ stack.pop();
1097
+ }
1098
+ continue;
1099
+ }
1100
+ if (token.type === "identifier") {
1101
+ if (!hasOwn(classes, token.value)) continue;
1102
+ if (context === "object" && previous && (previous.value === "{" || previous.value === ",") && next && (next.value === ":" || next.value === "," || next.value === "}")) fail(token.value, `is an unquoted object key (write it as "${token.value}")`);
1103
+ fail(token.value, "is referenced as an identifier");
1104
+ }
1105
+ if (token.type !== "string") continue;
1106
+ const mapped = findMappedToken(token.value, classes);
1107
+ if (token.quote === "`") {
1108
+ if (mapped) fail(mapped, "remains in a template literal");
1109
+ continue;
1110
+ }
1111
+ const arrayElement = context === "array" && previous && CLASS_VALUED_BEFORE.has(previous.value) && next && CLASS_VALUED_AFTER.has(next.value);
1112
+ const objectKey = context === "object" && previous && (previous.value === "{" || previous.value === ",") && next && next.value === ":";
1113
+ if (arrayElement || objectKey) collectClassListEdits(token.value, classes, baseOffset + token.start + 1, edits);
1114
+ else if (mapped) fail(mapped, "remains in a position that is not an array element or object key");
1115
+ }
1116
+ if (stack.length !== 0) fail(findMappedToken(source, classes) || "?", "appears in an unbalanced");
1117
+ }
1118
+ /**
1119
+ * Find a mapped class referenced by a stylesheet selector, including at-rule
1120
+ * preludes (`@scope (.flex)`) and attribute selectors (`[class~="flex"]`).
1121
+ * Declarations and comments are ignored.
1122
+ */
1123
+ function findMappedClassInStylesheet(css, classes) {
1124
+ const source = css.replace(/\/\*[\s\S]*?\*\//g, " ");
1125
+ let prelude = "";
1126
+ let quote = null;
1127
+ for (let i = 0; i < source.length; i += 1) {
1128
+ const char = source[i];
1129
+ if (quote) {
1130
+ prelude += char;
1131
+ if (char === "\\") {
1132
+ prelude += source[i + 1] || "";
1133
+ i += 1;
1134
+ } else if (char === quote) quote = null;
1135
+ continue;
1136
+ }
1137
+ if (char === "\"" || char === "'") {
1138
+ quote = char;
1139
+ prelude += char;
1140
+ continue;
1141
+ }
1142
+ if (char === "{") {
1143
+ const found = findMappedClassInSelector(prelude.trim(), classes);
1144
+ if (found) return found;
1145
+ prelude = "";
1146
+ } else if (char === "}" || char === ";") prelude = "";
1147
+ else prelude += char;
1148
+ }
1149
+ return null;
1150
+ }
1151
+ function findMappedClassInSelector(selector, classes) {
1152
+ const classPattern = /\.((?:\\.|[A-Za-z0-9_-])+)/g;
1153
+ let match;
1154
+ while ((match = classPattern.exec(selector)) !== null) {
1155
+ const name = match[1].replace(/\\(.)/g, "$1");
1156
+ if (hasOwn(classes, name)) return name;
1157
+ }
1158
+ const attributePattern = /\[\s*class\s*[~|^$*]?=\s*(["']?)([^\]"']+)\1\s*[is]?\s*\]/g;
1159
+ while ((match = attributePattern.exec(selector)) !== null) {
1160
+ const mapped = findMappedToken(match[2], classes);
1161
+ if (mapped) return mapped;
1162
+ }
1163
+ return null;
1164
+ }
1165
+ /**
1166
+ * Locate the value of an attribute from its start offset (the attribute
1167
+ * name). Returns the index range of the value and its delimiter.
1168
+ */
1169
+ function locateAttributeValue(code, attribute, start) {
1170
+ let index = start + attribute.name.length;
1171
+ while (index < code.length && /\s/.test(code[index])) index += 1;
1172
+ if (code[index] !== "=") return null;
1173
+ index += 1;
1174
+ while (index < code.length && /\s/.test(code[index])) index += 1;
1175
+ const delimiter = code[index];
1176
+ if (delimiter === "\"" || delimiter === "'" || delimiter === "`") {
1177
+ const end = code.indexOf(delimiter, index + 1);
1178
+ return end < 0 ? null : {
1179
+ start: index + 1,
1180
+ end,
1181
+ delimiter
1182
+ };
1183
+ }
1184
+ if (delimiter === "{") {
1185
+ const end = findBalancedEnd(code, index, "{", "}");
1186
+ return end < 0 ? null : {
1187
+ start: index + 1,
1188
+ end: end - 1,
1189
+ delimiter
1190
+ };
1191
+ }
1192
+ return null;
1193
+ }
1194
+ const STRING_LITERAL = /^\s*(["'])((?:\\.|(?!\1)[^\\])*)\1\s*$/;
1195
+ /**
1196
+ * Create the `.astro` transformer. `parse` is `@astrojs/compiler`'s parse
1197
+ * function, injected so the plugin and the tests can supply their own copy.
1198
+ */
1199
+ function createAstroTransformer({ parse, classes }) {
1200
+ return async function transformAstro(code, id) {
1201
+ const { ast } = await parse(code, { position: true });
1202
+ const toIndex = byteToIndexMap(code);
1203
+ const edits = [];
1204
+ const offsetOf = (node) => toIndex[node.position.start.offset];
1205
+ const stop = (mapped, where) => {
1206
+ throw new Error(`[gustcss] mapped class "${mapped}" remains in ${where} in ${id}. Move it to a static class attribute or class:list literal, or add it to mangleExclude.`);
1207
+ };
1208
+ const fragmentEdits = (text, start, kind) => {
1209
+ for (const edit of collectFragmentEdits(text, classes, `${id}#${kind}.ts`)) edits.push({
1210
+ start: start + edit.start,
1211
+ end: start + edit.end,
1212
+ replacement: edit.replacement
1213
+ });
1214
+ };
1215
+ const visitAttribute = (attribute, node) => {
1216
+ const start = offsetOf(attribute);
1217
+ const isComponent = node.type === "component";
1218
+ const value = attribute.value || "";
1219
+ if (attribute.name === "class") {
1220
+ const location = locateAttributeValue(code, attribute, start);
1221
+ if (location && attribute.kind === "quoted") {
1222
+ collectClassListEdits(code.slice(location.start, location.end), classes, location.start, edits);
1223
+ return;
1224
+ }
1225
+ if (location && attribute.kind === "expression") {
1226
+ const expression = code.slice(location.start, location.end);
1227
+ const literal = STRING_LITERAL.exec(expression);
1228
+ if (literal) {
1229
+ const inner = expression.indexOf(literal[1]) + 1;
1230
+ collectClassListEdits(literal[2], classes, location.start + inner, edits);
1231
+ return;
1232
+ }
1233
+ }
1234
+ const mapped = findMappedToken(value, classes);
1235
+ if (mapped) stop(mapped, `a dynamic class attribute (${attribute.kind})`);
1236
+ return;
1237
+ }
1238
+ if (attribute.name === "class:list") {
1239
+ const location = locateAttributeValue(code, attribute, start);
1240
+ if (!location || attribute.kind !== "expression") {
1241
+ const mapped = findMappedToken(value, classes);
1242
+ if (mapped) stop(mapped, `a class:list attribute of kind ${attribute.kind}`);
1243
+ return;
1244
+ }
1245
+ collectClassListExpressionEdits(code.slice(location.start, location.end), classes, location.start, edits, id);
1246
+ return;
1247
+ }
1248
+ if (attribute.kind === "spread") {
1249
+ const mapped = findMappedToken(`${attribute.name} ${value}`, classes);
1250
+ if (mapped) stop(mapped, `a spread attribute on <${node.name}>`);
1251
+ return;
1252
+ }
1253
+ if (attribute.name === "set:html" || attribute.name === "set:text" || isComponent) {
1254
+ const mapped = findMappedToken(value, classes);
1255
+ if (mapped) stop(mapped, isComponent ? `the "${attribute.name}" prop of <${node.name}>` : `a ${attribute.name} value`);
1256
+ }
1257
+ };
1258
+ const visit = (node) => {
1259
+ switch (node.type) {
1260
+ case "frontmatter": {
1261
+ const start = code.indexOf(node.value, offsetOf(node));
1262
+ if (start >= 0) fragmentEdits(node.value, start, "frontmatter");
1263
+ return;
1264
+ }
1265
+ case "comment":
1266
+ case "text":
1267
+ case "doctype": return;
1268
+ case "expression":
1269
+ for (const child of node.children) if (child.type === "text") {
1270
+ const mapped = findMappedToken(child.value, classes);
1271
+ if (mapped && /["'`]/.test(child.value)) stop(mapped, "a template expression");
1272
+ } else visit(child);
1273
+ return;
1274
+ case "element":
1275
+ case "component":
1276
+ case "custom-element":
1277
+ case "fragment":
1278
+ for (const attribute of node.attributes) visitAttribute(attribute, node);
1279
+ if (node.type === "element" && node.name === "style") {
1280
+ for (const child of node.children) {
1281
+ if (child.type !== "text") continue;
1282
+ const mapped = findMappedClassInStylesheet(child.value, classes);
1283
+ if (mapped) stop(mapped, "a <style> selector");
1284
+ }
1285
+ return;
1286
+ }
1287
+ if (node.type === "element" && node.name === "script") {
1288
+ for (const child of node.children) if (child.type === "text") fragmentEdits(child.value, offsetOf(child), "script");
1289
+ return;
1290
+ }
1291
+ for (const child of node.children) visit(child);
1292
+ return;
1293
+ default: for (const child of node.children || []) visit(child);
1294
+ }
1295
+ };
1296
+ visit(ast);
1297
+ const applied = applyEdits(code, edits);
1298
+ return {
1299
+ code: applied.code,
1300
+ map: createSourceMap(code, applied.code, applied.origins, id)
1301
+ };
1302
+ };
1303
+ }
1304
+ //#endregion
722
1305
  //#region src/index.js
723
1306
  /**
724
1307
  * @gustcss/vite - Vite plugin for CSS Utility Generator
@@ -733,18 +1316,183 @@ function createClassNameTransformer(classes) {
733
1316
  * are provided by the developer in their build configuration. This plugin is
734
1317
  * designed for build-time use only and should not process untrusted input.
735
1318
  */
1319
+ const ASTRO_SOURCE = /\.astro(?:$|\?)/;
1320
+ const lintDebounceMs = 300;
1321
+ const ASTRO_MODULE = /^[^\0?]*\.astro$/;
1322
+ /**
1323
+ * Resolve @astrojs/compiler parse function from the project.
1324
+ */
1325
+ async function resolveAstroCompiler(root) {
1326
+ const projectRequire = createRequire(path.join(root, "package.json"));
1327
+ const attempts = [
1328
+ () => projectRequire.resolve("@astrojs/compiler"),
1329
+ () => createRequire(projectRequire.resolve("astro/package.json")).resolve("@astrojs/compiler"),
1330
+ () => createRequire(import.meta.url).resolve("@astrojs/compiler")
1331
+ ];
1332
+ for (const attempt of attempts) try {
1333
+ const mod = await import(pathToFileURL(attempt()).href);
1334
+ if (typeof mod.parse === "function") return mod.parse;
1335
+ } catch {}
1336
+ return null;
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
+ }
736
1479
  function cssUtility(opts = {}) {
737
1480
  const output = opts.output || "src/styles/utility.css";
738
1481
  const content = opts.content || ["./src/**/*.{js,ts,jsx,tsx,astro,vue}"];
739
1482
  const configPath = opts.config;
740
1483
  const outputToCssLayers = opts.outputToCssLayers;
741
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?)$/;
742
1488
  const mangleMap = opts.mangleMap || `${output}.classes.json`;
743
1489
  const mangleExclude = opts.mangleExclude || [];
744
1490
  let classNameTransformer = null;
745
1491
  let rollbackContext = null;
746
1492
  let manifestSnapshot = null;
747
1493
  let watchProcess = null;
1494
+ const loadedAstroModules = /* @__PURE__ */ new Set();
1495
+ let astroTransformer = null;
748
1496
  function findBinary(cwd) {
749
1497
  return import_runner.default.resolveBinary({
750
1498
  cwd,
@@ -816,15 +1564,19 @@ function cssUtility(opts = {}) {
816
1564
  fs
817
1565
  }, (tempConfigPath) => run(tempConfigPath));
818
1566
  }
1567
+ let viteConfig = null;
819
1568
  return {
820
1569
  name: "gustcss",
821
1570
  ...mangleClassNames ? { enforce: "pre" } : {},
822
1571
  configResolved(config) {
1572
+ viteConfig = config;
823
1573
  const cwd = config.root || process.cwd();
824
1574
  const binaryPath = findBinary(cwd);
825
1575
  try {
826
1576
  const enableMangle = mangleClassNames && config.command === "build";
827
1577
  buildCSS(cwd, binaryPath, enableMangle);
1578
+ loadedAstroModules.clear();
1579
+ astroTransformer = null;
828
1580
  rollbackContext = enableMangle ? {
829
1581
  cwd,
830
1582
  binaryPath
@@ -834,8 +1586,30 @@ function cssUtility(opts = {}) {
834
1586
  throw error;
835
1587
  }
836
1588
  },
1589
+ async load(id) {
1590
+ if (!classNameTransformer || !ASTRO_MODULE.test(id)) return null;
1591
+ const filePath = id.startsWith("/@fs/") ? id.slice(4) : id;
1592
+ if (!fs.existsSync(filePath)) throw new Error(`[gustcss] cannot read ${id} to rewrite its class names for mangling. Disable mangleClassNames or exclude its classes with mangleExclude.`);
1593
+ const code = fs.readFileSync(filePath, "utf-8");
1594
+ loadedAstroModules.add(id);
1595
+ if (!astroTransformer) {
1596
+ const parse = await resolveAstroCompiler(viteConfig?.root || process.cwd());
1597
+ if (!parse) throw new Error(`[gustcss] @astrojs/compiler could not be resolved from the project, so ${id} cannot be mangled. Install astro, or disable mangleClassNames.`);
1598
+ astroTransformer = createAstroTransformer({
1599
+ parse,
1600
+ classes: classNameTransformer.classes
1601
+ });
1602
+ }
1603
+ const result = await astroTransformer(code, filePath);
1604
+ return result.code === code ? null : result;
1605
+ },
837
1606
  transform(code, id) {
838
1607
  if (!classNameTransformer) return null;
1608
+ if (ASTRO_MODULE.test(id)) {
1609
+ if (!loadedAstroModules.has(id)) throw new Error(`[gustcss] ${id} was compiled without passing through the gustcss load hook, so its class names cannot be mangled safely. Disable mangleClassNames or move the plugin so it loads .astro sources first.`);
1610
+ return null;
1611
+ }
1612
+ if (ASTRO_SOURCE.test(id)) return null;
839
1613
  const transformed = classNameTransformer.withSourceMap(code, id);
840
1614
  return transformed.code === code ? null : transformed;
841
1615
  },
@@ -895,16 +1669,52 @@ function cssUtility(opts = {}) {
895
1669
  }]
896
1670
  });
897
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
+ }
898
1697
  const cleanup = () => {
1698
+ if (lintTimer) {
1699
+ clearTimeout(lintTimer);
1700
+ lintTimer = null;
1701
+ }
1702
+ lintRunner?.close();
899
1703
  if (watchProcess) {
900
1704
  watchProcess.kill();
901
1705
  watchProcess = null;
902
1706
  }
903
1707
  if (tempConfigPath && fs.existsSync(tempConfigPath)) fs.unlinkSync(tempConfigPath);
904
1708
  };
1709
+ const close = server.close.bind(server);
1710
+ server.close = async () => {
1711
+ cleanup();
1712
+ return close();
1713
+ };
905
1714
  server.httpServer?.on("close", cleanup);
906
1715
  process.once("SIGINT", cleanup);
907
1716
  process.once("SIGTERM", cleanup);
1717
+ process.once("exit", cleanup);
908
1718
  },
909
1719
  writeBundle() {
910
1720
  if (!manifestSnapshot || fs.existsSync(manifestSnapshot.path)) return;