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