@gustcss/vite 0.10.0 → 0.10.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
@@ -92,15 +92,30 @@ The manifest defaults to `<output>.classes.json`; set `mangleMap` to use another
92
92
 
93
93
  Treat the manifest as generated output and ignore it in Git unless your workflow intentionally pins class mappings.
94
94
 
95
- The transformer supports HTML, JavaScript, TypeScript, JSX, and TSX. It rewrites static `class` and `className` attributes, static template segments, direct string arguments to `classList`, and `setAttribute('class', ...)`. Code examples inside HTML `<code>` and `<pre>` bodies are neither rewritten nor inspected; classes on the elements themselves are still rewritten. An unclosed `<code>` or `<pre>` fails the build instead of masking the remainder of the document.
95
+ The transformer supports HTML, JavaScript, TypeScript, JSX, TSX, and Astro. It rewrites static `class` and `className` attributes, static template segments, direct string arguments to `classList`, and `setAttribute('class', ...)`. Code examples inside HTML `<code>` and `<pre>` bodies are neither rewritten nor inspected; classes on the elements themselves are still rewritten. An unclosed `<code>` or `<pre>` fails the build instead of masking the remainder of the document.
96
96
 
97
- JavaScript, TypeScript, JSX, and TSX transforms emit Source Map v3 mappings back to the original source. Comments and ordinary strings containing `class="..."` are not treated as JSX attributes. An ordinary string that still references a mapped class fails closed as ambiguous.
97
+ **Astro templates** (`.astro`) are rewritten before the Astro compiler sees them. Astro's own Vite plugins run ahead of user plugins, so the rewrite happens in the plugin's `load` hook on the raw source, guided by the AST that `parse()` from `@astrojs/compiler` returns (the package ships with astro, so nothing extra has to be installed). Attribute kinds, the frontmatter, comments, `<style>` and `<script>` blocks are taken from that AST.
98
+
99
+ **Rewritten:**
100
+ - Static `class="…"` attributes and `class={"…"}` expressions that consist of a single string literal
101
+ - `class:list` array elements (including the right operand of `cond && "flex"`) and quoted object keys, nested arrays and objects included
102
+ - `classList.add("…")` / `setAttribute("class", "…")` calls in the frontmatter and in `<script>` blocks
103
+
104
+ **Fail-closed** (the build stops when a mapped class appears in one of these places):
105
+ - Any `class` expression other than a single string literal, such as `class={cond ? "flex" : "x"}` or `` class={`flex ${x}`} ``
106
+ - Inside `class:list`: unquoted keys (`{ flex: on }`), shorthand properties (`{ hidden }`), template literals, comparison operands (`variant === "flex"`), subscripts (`map["flex"]`), function calls, method chains, or an expression that is not an array/object literal
107
+ - Component props other than `class` / `class:list` (`<Card klass="flex" />`), `set:html` / `set:text` values, strings inside `{}` expressions (`{JSON.stringify("flex")}`), and ambiguous strings in the frontmatter or `<script>`. A `class` or `class:list` passed to a component is rewritten as a static value, like JSX `className`; forward `Astro.props.class` unchanged on the receiving side
108
+ - `<style>` selectors (`.flex`, `@scope (.flex)`, `[class~="flex"]`); add hand-written selectors such as `.sr-only { … }` to `mangleExclude`
109
+
110
+ HTML comments, `<code>` / `<pre>` bodies, ordinary non-class attributes (`<input type="flex">`) and `<style>` declarations (`border: 1px solid`) are neither rewritten nor inspected.
111
+
112
+ JavaScript, TypeScript, JSX, TSX, and Astro frontmatter transforms emit Source Map v3 mappings back to the original source. Comments and ordinary strings containing `class="..."` are not treated as JSX attributes. An ordinary string that still references a mapped class fails closed as ambiguous.
98
113
 
99
114
  Conditional expressions, nested calls, selectors, and other ambiguous references fail the build. The plugin restores readable CSS after a failed transform.
100
115
 
101
116
  Add dynamically or externally referenced class names, including production E2E selectors, to `mangleExclude`.
102
117
 
103
- Vue, Astro, and Svelte templates are not supported by this option yet. A mapped class in one of these files fails the build instead of emitting mismatched output.
118
+ Vue and Svelte templates are not supported by this option. A mapped class in one of these files fails the build instead of emitting mismatched output.
104
119
 
105
120
  Files under `node_modules` are neither rewritten nor inspected.
106
121
 
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,7 +171,7 @@ 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[\\/]/;
174
176
  function hasOwn(classes, token) {
175
177
  return Object.hasOwn(classes, token);
@@ -215,6 +217,7 @@ function findRawTextClosing(code, tagName, start) {
215
217
  function analyzeHtml(code, id) {
216
218
  const markup = new Array(code.length).fill(" ");
217
219
  const scriptRanges = [];
220
+ const commentRanges = [];
218
221
  const exampleStack = [];
219
222
  let index = 0;
220
223
  while (index < code.length) {
@@ -222,7 +225,12 @@ function analyzeHtml(code, id) {
222
225
  if (open < 0) break;
223
226
  if (code.startsWith("<!--", open)) {
224
227
  const commentEnd = code.indexOf("-->", open + 4);
225
- index = commentEnd < 0 ? code.length : commentEnd + 3;
228
+ const end = commentEnd < 0 ? code.length : commentEnd + 3;
229
+ commentRanges.push({
230
+ start: open,
231
+ end
232
+ });
233
+ index = end;
226
234
  continue;
227
235
  }
228
236
  const tagEnd = findHtmlTagEnd(code, open);
@@ -258,7 +266,8 @@ function analyzeHtml(code, id) {
258
266
  if (exampleStack.length > 0) throw new Error(`[gustcss] unclosed <${exampleStack.at(-1)}> in ${id}`);
259
267
  return {
260
268
  markup: markup.join(""),
261
- scriptRanges
269
+ scriptRanges,
270
+ commentRanges
262
271
  };
263
272
  }
264
273
  function canStartRegex(code, index, previous) {
@@ -486,7 +495,7 @@ function rejectAmbiguousClassExpressions(code, structure, classes, id) {
486
495
  while ((match = pattern.exec(structure)) !== null) {
487
496
  const open = code.indexOf("{", match.index);
488
497
  const end = findBalancedEnd(code, open, "{", "}");
489
- if (end < 0) break;
498
+ if (end < 0) throw new Error(`[gustcss] incomplete class/className expression in ${id}. Ensure the expression is properly closed.`);
490
499
  const ambiguous = findMappedToken(code.slice(open + 1, end - 1), classes);
491
500
  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
501
  pattern.lastIndex = end;
@@ -580,30 +589,53 @@ function sourcesForFile(code, id) {
580
589
  const inspection = new Array(code.length).fill(" ");
581
590
  for (const range of html.scriptRanges) {
582
591
  const script = code.slice(range.start, range.end);
583
- const js = createJsMasks(script);
592
+ const jsScript = createJsMasks(script);
584
593
  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];
594
+ structure[range.start + offset] = jsScript.structure[offset];
595
+ inspection[range.start + offset] = jsScript.inspection[offset];
587
596
  }
588
597
  }
598
+ for (const range of html.commentRanges) for (let offset = range.start; offset < range.end; offset += 1) inspection[offset] = " ";
589
599
  return {
590
600
  attributes: html.markup,
591
601
  structure: structure.join(""),
592
602
  inspection: inspection.join("")
593
603
  };
594
604
  }
595
- function collectRecognizedContextEdits(code, classes, id) {
605
+ function collectEdits(code, classes, id) {
596
606
  const edits = [];
597
607
  const sources = sourcesForFile(code, id);
608
+ const commentRanges = [];
609
+ let commentIndex = 0;
610
+ while ((commentIndex = code.indexOf("<!--", commentIndex)) >= 0) {
611
+ const commentEnd = code.indexOf("-->", commentIndex + 4);
612
+ if (commentEnd < 0) {
613
+ commentRanges.push({
614
+ start: commentIndex,
615
+ end: code.length
616
+ });
617
+ break;
618
+ }
619
+ commentRanges.push({
620
+ start: commentIndex,
621
+ end: commentEnd + 3
622
+ });
623
+ commentIndex = commentEnd + 3;
624
+ }
625
+ function isInComment(pos) {
626
+ return commentRanges.some((range) => pos >= range.start && pos < range.end);
627
+ }
598
628
  const attributePattern = /((?<![:\w-])class(?:Name)?\s*=\s*)(?:(["'])([\s\S]*?)(\2)|(\{\s*)(["'])([\s\S]*?)(\6)(\s*\}))/g;
599
629
  let match;
600
630
  while ((match = attributePattern.exec(sources.attributes)) !== null) {
631
+ if (isInComment(match.index)) continue;
601
632
  if (!HTML_SOURCE.test(id) && !isJsxAttributeAt(sources.structure, match.index)) continue;
602
633
  if (match[2] !== void 0) collectClassListEdits(match[3], classes, match.index + match[1].length + 1, edits);
603
634
  else collectClassListEdits(match[7], classes, match.index + match[1].length + match[5].length + 1, edits);
604
635
  }
605
636
  const templatePattern = /((?<![:\w-])class(?:Name)?\s*=\s*\{\s*`)([\s\S]*?)(`\s*\})/g;
606
637
  while ((match = templatePattern.exec(sources.attributes)) !== null) {
638
+ if (isInComment(match.index)) continue;
607
639
  if (!HTML_SOURCE.test(id) && !isJsxAttributeAt(sources.structure, match.index)) continue;
608
640
  collectTemplateBodyEdits(match[2], classes, match.index + match[1].length, edits);
609
641
  }
@@ -613,7 +645,27 @@ function collectRecognizedContextEdits(code, classes, id) {
613
645
  if (sources.structure[match.index] === " ") continue;
614
646
  collectClassListEdits(match[3], classes, match.index + match[1].length + 1, edits);
615
647
  }
616
- return applyEdits(code, edits);
648
+ return edits;
649
+ }
650
+ function collectRecognizedContextEdits(code, classes, id) {
651
+ return applyEdits(code, collectEdits(code, classes, id));
652
+ }
653
+ /**
654
+ * Collect the rewrites for a source fragment (TypeScript frontmatter, an inline
655
+ * `<script>` body, …) and run the same fail-closed checks as `transformSource`,
656
+ * but return the edits instead of the rewritten code so the caller can place
657
+ * them inside a larger document.
658
+ */
659
+ function collectFragmentEdits(code, classes, id) {
660
+ const edits = collectEdits(code, classes, id);
661
+ const rewritten = applyEdits(code, edits).code;
662
+ const sources = sourcesForFile(rewritten, id);
663
+ rejectDynamicSetAttributeCalls(rewritten, sources.structure, classes, id);
664
+ rejectDynamicClassNameAssignments(rewritten, sources.structure, classes, id);
665
+ rejectAmbiguousClassExpressions(rewritten, sources.structure, classes, id);
666
+ const ambiguous = findMappedInQuotedSegments(sources.inspection, classes);
667
+ 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;
617
669
  }
618
670
  const BASE64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
619
671
  function encodeVLQ(value) {
@@ -701,7 +753,7 @@ function createClassNameTransformer(classes) {
701
753
  const transformWithSourceMap = (code, id) => {
702
754
  if (UNSUPPORTED_FRAMEWORK_SOURCE.test(id)) {
703
755
  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.`);
756
+ 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
757
  return {
706
758
  code,
707
759
  map: null
@@ -721,9 +773,392 @@ function createClassNameTransformer(classes) {
721
773
  };
722
774
  const transform = (code, id) => transformWithSourceMap(code, id).code;
723
775
  transform.withSourceMap = transformWithSourceMap;
776
+ transform.classes = classes;
724
777
  return transform;
725
778
  }
726
779
  //#endregion
780
+ //#region src/astro.js
781
+ /**
782
+ * Class-name mangling for `.astro` sources, driven by the AST that
783
+ * `@astrojs/compiler`'s `parse()` returns.
784
+ *
785
+ * Only unambiguous class contexts are rewritten; any other place where a
786
+ * mapped class name shows up fails the build (fail-closed), because a class
787
+ * that reaches the DOM unrenamed would no longer match the mangled CSS.
788
+ */
789
+ const CLASS_VALUED_BEFORE = /* @__PURE__ */ new Set([
790
+ "[",
791
+ ",",
792
+ "&&",
793
+ "||"
794
+ ]);
795
+ const CLASS_VALUED_AFTER = /* @__PURE__ */ new Set([
796
+ ",",
797
+ "]",
798
+ "&&",
799
+ "||"
800
+ ]);
801
+ const OPERATORS = [
802
+ "===",
803
+ "!==",
804
+ "&&",
805
+ "||",
806
+ "??",
807
+ "=>",
808
+ "==",
809
+ "!=",
810
+ "?.",
811
+ "<=",
812
+ ">="
813
+ ];
814
+ const PUNCTUATION = "[]{}(),:?.!+-*/%<>=&|~^;";
815
+ /** Map UTF-8 byte offsets (what the compiler reports) to string indexes. */
816
+ function byteToIndexMap(code) {
817
+ const map = [];
818
+ let byte = 0;
819
+ for (let index = 0; index < code.length; index += 1) {
820
+ const point = code.codePointAt(index);
821
+ const width = point < 128 ? 1 : point < 2048 ? 2 : point < 65536 ? 3 : 4;
822
+ for (let i = 0; i < width; i += 1) map[byte + i] = index;
823
+ byte += width;
824
+ if (point > 65535) index += 1;
825
+ }
826
+ map[byte] = code.length;
827
+ return map;
828
+ }
829
+ /**
830
+ * Tokenize a JavaScript expression just far enough to know, for every string
831
+ * literal and identifier, what surrounds it. Template literals are reported
832
+ * as strings with quote "`" and are never rewritten. Returns null when the
833
+ * expression cannot be tokenized (unterminated string, unknown character).
834
+ */
835
+ function tokenizeExpression(source) {
836
+ const tokens = [];
837
+ let index = 0;
838
+ while (index < source.length) {
839
+ const char = source[index];
840
+ if (/\s/.test(char)) {
841
+ index += 1;
842
+ continue;
843
+ }
844
+ if (source.startsWith("//", index)) {
845
+ const newline = source.indexOf("\n", index);
846
+ index = newline < 0 ? source.length : newline + 1;
847
+ continue;
848
+ }
849
+ if (source.startsWith("/*", index)) {
850
+ const close = source.indexOf("*/", index + 2);
851
+ if (close < 0) return null;
852
+ index = close + 2;
853
+ continue;
854
+ }
855
+ if (char === "\"" || char === "'" || char === "`") {
856
+ const start = index;
857
+ index += 1;
858
+ while (index < source.length && source[index] !== char) {
859
+ if (source[index] === "\\") index += 1;
860
+ index += 1;
861
+ }
862
+ if (index >= source.length) return null;
863
+ tokens.push({
864
+ type: "string",
865
+ quote: char,
866
+ start,
867
+ end: index + 1,
868
+ value: source.slice(start + 1, index)
869
+ });
870
+ index += 1;
871
+ continue;
872
+ }
873
+ if (/[A-Za-z_$]/.test(char)) {
874
+ const start = index;
875
+ while (index < source.length && /[\w$]/.test(source[index])) index += 1;
876
+ tokens.push({
877
+ type: "identifier",
878
+ start,
879
+ end: index,
880
+ value: source.slice(start, index)
881
+ });
882
+ continue;
883
+ }
884
+ if (/[0-9]/.test(char)) {
885
+ const start = index;
886
+ while (index < source.length && /[\w.]/.test(source[index])) index += 1;
887
+ tokens.push({
888
+ type: "number",
889
+ start,
890
+ end: index,
891
+ value: source.slice(start, index)
892
+ });
893
+ continue;
894
+ }
895
+ const operator = OPERATORS.find((candidate) => source.startsWith(candidate, index));
896
+ if (operator) {
897
+ tokens.push({
898
+ type: "operator",
899
+ start: index,
900
+ end: index + operator.length,
901
+ value: operator
902
+ });
903
+ index += operator.length;
904
+ continue;
905
+ }
906
+ if (PUNCTUATION.includes(char)) {
907
+ tokens.push({
908
+ type: "operator",
909
+ start: index,
910
+ end: index + 1,
911
+ value: char
912
+ });
913
+ index += 1;
914
+ continue;
915
+ }
916
+ return null;
917
+ }
918
+ return tokens;
919
+ }
920
+ /**
921
+ * Rewrite the class-valued string literals of a `class:list` expression.
922
+ * Accepted shapes are array literals, object literals and their nesting.
923
+ * Inside them only array elements (also as the right operand of `&&` / `||`)
924
+ * and quoted object keys are class-valued; every other occurrence of a mapped
925
+ * class name fails closed.
926
+ */
927
+ function collectClassListExpressionEdits(source, classes, baseOffset, edits, id) {
928
+ const fail = (token, reason) => {
929
+ 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.`);
930
+ };
931
+ const tokens = tokenizeExpression(source);
932
+ const first = tokens && tokens[0];
933
+ const last = tokens && tokens[tokens.length - 1];
934
+ if (!(tokens && tokens.length >= 2 && (first.value === "[" && last.value === "]" || first.value === "{" && last.value === "}"))) {
935
+ const mapped = findMappedToken(source, classes);
936
+ if (mapped) fail(mapped, "remains in an expression that is not an array or object literal");
937
+ return;
938
+ }
939
+ const stack = [];
940
+ for (let i = 0; i < tokens.length; i += 1) {
941
+ const token = tokens[i];
942
+ const previous = tokens[i - 1];
943
+ const next = tokens[i + 1];
944
+ const context = stack[stack.length - 1];
945
+ if (token.type === "operator") {
946
+ if (token.value === "[") {
947
+ const subscript = previous && (previous.type === "identifier" || previous.type === "string" || previous.value === ")" || previous.value === "]");
948
+ stack.push(subscript ? "subscript" : "array");
949
+ } else if (token.value === "{") stack.push("object");
950
+ else if (token.value === "(") stack.push("call");
951
+ else if (token.value === "]" || token.value === "}" || token.value === ")") {
952
+ if (stack.length === 0) fail(findMappedToken(source, classes) || "?", "appears in an unbalanced");
953
+ stack.pop();
954
+ }
955
+ continue;
956
+ }
957
+ if (token.type === "identifier") {
958
+ if (!hasOwn(classes, token.value)) continue;
959
+ 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}")`);
960
+ fail(token.value, "is referenced as an identifier");
961
+ }
962
+ if (token.type !== "string") continue;
963
+ const mapped = findMappedToken(token.value, classes);
964
+ if (token.quote === "`") {
965
+ if (mapped) fail(mapped, "remains in a template literal");
966
+ continue;
967
+ }
968
+ const arrayElement = context === "array" && previous && CLASS_VALUED_BEFORE.has(previous.value) && next && CLASS_VALUED_AFTER.has(next.value);
969
+ const objectKey = context === "object" && previous && (previous.value === "{" || previous.value === ",") && next && next.value === ":";
970
+ if (arrayElement || objectKey) collectClassListEdits(token.value, classes, baseOffset + token.start + 1, edits);
971
+ else if (mapped) fail(mapped, "remains in a position that is not an array element or object key");
972
+ }
973
+ if (stack.length !== 0) fail(findMappedToken(source, classes) || "?", "appears in an unbalanced");
974
+ }
975
+ /**
976
+ * Find a mapped class referenced by a stylesheet selector, including at-rule
977
+ * preludes (`@scope (.flex)`) and attribute selectors (`[class~="flex"]`).
978
+ * Declarations and comments are ignored.
979
+ */
980
+ function findMappedClassInStylesheet(css, classes) {
981
+ const source = css.replace(/\/\*[\s\S]*?\*\//g, " ");
982
+ let prelude = "";
983
+ let quote = null;
984
+ for (let i = 0; i < source.length; i += 1) {
985
+ const char = source[i];
986
+ if (quote) {
987
+ prelude += char;
988
+ if (char === "\\") {
989
+ prelude += source[i + 1] || "";
990
+ i += 1;
991
+ } else if (char === quote) quote = null;
992
+ continue;
993
+ }
994
+ if (char === "\"" || char === "'") {
995
+ quote = char;
996
+ prelude += char;
997
+ continue;
998
+ }
999
+ if (char === "{") {
1000
+ const found = findMappedClassInSelector(prelude.trim(), classes);
1001
+ if (found) return found;
1002
+ prelude = "";
1003
+ } else if (char === "}" || char === ";") prelude = "";
1004
+ else prelude += char;
1005
+ }
1006
+ return null;
1007
+ }
1008
+ function findMappedClassInSelector(selector, classes) {
1009
+ const classPattern = /\.((?:\\.|[A-Za-z0-9_-])+)/g;
1010
+ let match;
1011
+ while ((match = classPattern.exec(selector)) !== null) {
1012
+ const name = match[1].replace(/\\(.)/g, "$1");
1013
+ if (hasOwn(classes, name)) return name;
1014
+ }
1015
+ const attributePattern = /\[\s*class\s*[~|^$*]?=\s*(["']?)([^\]"']+)\1\s*[is]?\s*\]/g;
1016
+ while ((match = attributePattern.exec(selector)) !== null) {
1017
+ const mapped = findMappedToken(match[2], classes);
1018
+ if (mapped) return mapped;
1019
+ }
1020
+ return null;
1021
+ }
1022
+ /**
1023
+ * Locate the value of an attribute from its start offset (the attribute
1024
+ * name). Returns the index range of the value and its delimiter.
1025
+ */
1026
+ function locateAttributeValue(code, attribute, start) {
1027
+ let index = start + attribute.name.length;
1028
+ while (index < code.length && /\s/.test(code[index])) index += 1;
1029
+ if (code[index] !== "=") return null;
1030
+ index += 1;
1031
+ while (index < code.length && /\s/.test(code[index])) index += 1;
1032
+ const delimiter = code[index];
1033
+ if (delimiter === "\"" || delimiter === "'" || delimiter === "`") {
1034
+ const end = code.indexOf(delimiter, index + 1);
1035
+ return end < 0 ? null : {
1036
+ start: index + 1,
1037
+ end,
1038
+ delimiter
1039
+ };
1040
+ }
1041
+ if (delimiter === "{") {
1042
+ const end = findBalancedEnd(code, index, "{", "}");
1043
+ return end < 0 ? null : {
1044
+ start: index + 1,
1045
+ end: end - 1,
1046
+ delimiter
1047
+ };
1048
+ }
1049
+ return null;
1050
+ }
1051
+ const STRING_LITERAL = /^\s*(["'])((?:\\.|(?!\1)[^\\])*)\1\s*$/;
1052
+ /**
1053
+ * Create the `.astro` transformer. `parse` is `@astrojs/compiler`'s parse
1054
+ * function, injected so the plugin and the tests can supply their own copy.
1055
+ */
1056
+ function createAstroTransformer({ parse, classes }) {
1057
+ return async function transformAstro(code, id) {
1058
+ const { ast } = await parse(code, { position: true });
1059
+ const toIndex = byteToIndexMap(code);
1060
+ const edits = [];
1061
+ const offsetOf = (node) => toIndex[node.position.start.offset];
1062
+ const stop = (mapped, where) => {
1063
+ 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.`);
1064
+ };
1065
+ const fragmentEdits = (text, start, kind) => {
1066
+ for (const edit of collectFragmentEdits(text, classes, `${id}#${kind}.ts`)) edits.push({
1067
+ start: start + edit.start,
1068
+ end: start + edit.end,
1069
+ replacement: edit.replacement
1070
+ });
1071
+ };
1072
+ const visitAttribute = (attribute, node) => {
1073
+ const start = offsetOf(attribute);
1074
+ const isComponent = node.type === "component";
1075
+ const value = attribute.value || "";
1076
+ if (attribute.name === "class") {
1077
+ const location = locateAttributeValue(code, attribute, start);
1078
+ if (location && attribute.kind === "quoted") {
1079
+ collectClassListEdits(code.slice(location.start, location.end), classes, location.start, edits);
1080
+ return;
1081
+ }
1082
+ if (location && attribute.kind === "expression") {
1083
+ const expression = code.slice(location.start, location.end);
1084
+ const literal = STRING_LITERAL.exec(expression);
1085
+ if (literal) {
1086
+ const inner = expression.indexOf(literal[1]) + 1;
1087
+ collectClassListEdits(literal[2], classes, location.start + inner, edits);
1088
+ return;
1089
+ }
1090
+ }
1091
+ const mapped = findMappedToken(value, classes);
1092
+ if (mapped) stop(mapped, `a dynamic class attribute (${attribute.kind})`);
1093
+ return;
1094
+ }
1095
+ if (attribute.name === "class:list") {
1096
+ const location = locateAttributeValue(code, attribute, start);
1097
+ if (!location || attribute.kind !== "expression") {
1098
+ const mapped = findMappedToken(value, classes);
1099
+ if (mapped) stop(mapped, `a class:list attribute of kind ${attribute.kind}`);
1100
+ return;
1101
+ }
1102
+ collectClassListExpressionEdits(code.slice(location.start, location.end), classes, location.start, edits, id);
1103
+ return;
1104
+ }
1105
+ if (attribute.kind === "spread") {
1106
+ const mapped = findMappedToken(`${attribute.name} ${value}`, classes);
1107
+ if (mapped) stop(mapped, `a spread attribute on <${node.name}>`);
1108
+ return;
1109
+ }
1110
+ if (attribute.name === "set:html" || attribute.name === "set:text" || isComponent) {
1111
+ const mapped = findMappedToken(value, classes);
1112
+ if (mapped) stop(mapped, isComponent ? `the "${attribute.name}" prop of <${node.name}>` : `a ${attribute.name} value`);
1113
+ }
1114
+ };
1115
+ const visit = (node) => {
1116
+ switch (node.type) {
1117
+ case "frontmatter": {
1118
+ const start = code.indexOf(node.value, offsetOf(node));
1119
+ if (start >= 0) fragmentEdits(node.value, start, "frontmatter");
1120
+ return;
1121
+ }
1122
+ case "comment":
1123
+ case "text":
1124
+ case "doctype": return;
1125
+ case "expression":
1126
+ for (const child of node.children) if (child.type === "text") {
1127
+ const mapped = findMappedToken(child.value, classes);
1128
+ if (mapped && /["'`]/.test(child.value)) stop(mapped, "a template expression");
1129
+ } else visit(child);
1130
+ return;
1131
+ case "element":
1132
+ case "component":
1133
+ case "custom-element":
1134
+ case "fragment":
1135
+ for (const attribute of node.attributes) visitAttribute(attribute, node);
1136
+ if (node.type === "element" && node.name === "style") {
1137
+ for (const child of node.children) {
1138
+ if (child.type !== "text") continue;
1139
+ const mapped = findMappedClassInStylesheet(child.value, classes);
1140
+ if (mapped) stop(mapped, "a <style> selector");
1141
+ }
1142
+ return;
1143
+ }
1144
+ if (node.type === "element" && node.name === "script") {
1145
+ for (const child of node.children) if (child.type === "text") fragmentEdits(child.value, offsetOf(child), "script");
1146
+ return;
1147
+ }
1148
+ for (const child of node.children) visit(child);
1149
+ return;
1150
+ default: for (const child of node.children || []) visit(child);
1151
+ }
1152
+ };
1153
+ visit(ast);
1154
+ const applied = applyEdits(code, edits);
1155
+ return {
1156
+ code: applied.code,
1157
+ map: createSourceMap(code, applied.code, applied.origins, id)
1158
+ };
1159
+ };
1160
+ }
1161
+ //#endregion
727
1162
  //#region src/index.js
728
1163
  /**
729
1164
  * @gustcss/vite - Vite plugin for CSS Utility Generator
@@ -738,6 +1173,24 @@ function createClassNameTransformer(classes) {
738
1173
  * are provided by the developer in their build configuration. This plugin is
739
1174
  * designed for build-time use only and should not process untrusted input.
740
1175
  */
1176
+ const ASTRO_SOURCE = /\.astro(?:$|\?)/;
1177
+ const ASTRO_MODULE = /^[^\0?]*\.astro$/;
1178
+ /**
1179
+ * Resolve @astrojs/compiler parse function from the project.
1180
+ */
1181
+ async function resolveAstroCompiler(root) {
1182
+ const projectRequire = (0, node_module.createRequire)(path.default.join(root, "package.json"));
1183
+ const attempts = [
1184
+ () => projectRequire.resolve("@astrojs/compiler"),
1185
+ () => (0, node_module.createRequire)(projectRequire.resolve("astro/package.json")).resolve("@astrojs/compiler"),
1186
+ () => (0, node_module.createRequire)(require("url").pathToFileURL(__filename).href).resolve("@astrojs/compiler")
1187
+ ];
1188
+ for (const attempt of attempts) try {
1189
+ const mod = await import((0, node_url.pathToFileURL)(attempt()).href);
1190
+ if (typeof mod.parse === "function") return mod.parse;
1191
+ } catch {}
1192
+ return null;
1193
+ }
741
1194
  function cssUtility(opts = {}) {
742
1195
  const output = opts.output || "src/styles/utility.css";
743
1196
  const content = opts.content || ["./src/**/*.{js,ts,jsx,tsx,astro,vue}"];
@@ -750,6 +1203,8 @@ function cssUtility(opts = {}) {
750
1203
  let rollbackContext = null;
751
1204
  let manifestSnapshot = null;
752
1205
  let watchProcess = null;
1206
+ const loadedAstroModules = /* @__PURE__ */ new Set();
1207
+ let astroTransformer = null;
753
1208
  function findBinary(cwd) {
754
1209
  return import_runner.default.resolveBinary({
755
1210
  cwd,
@@ -821,15 +1276,19 @@ function cssUtility(opts = {}) {
821
1276
  fs: fs.default
822
1277
  }, (tempConfigPath) => run(tempConfigPath));
823
1278
  }
1279
+ let viteConfig = null;
824
1280
  return {
825
1281
  name: "gustcss",
826
1282
  ...mangleClassNames ? { enforce: "pre" } : {},
827
1283
  configResolved(config) {
1284
+ viteConfig = config;
828
1285
  const cwd = config.root || process.cwd();
829
1286
  const binaryPath = findBinary(cwd);
830
1287
  try {
831
1288
  const enableMangle = mangleClassNames && config.command === "build";
832
1289
  buildCSS(cwd, binaryPath, enableMangle);
1290
+ loadedAstroModules.clear();
1291
+ astroTransformer = null;
833
1292
  rollbackContext = enableMangle ? {
834
1293
  cwd,
835
1294
  binaryPath
@@ -839,8 +1298,30 @@ function cssUtility(opts = {}) {
839
1298
  throw error;
840
1299
  }
841
1300
  },
1301
+ async load(id) {
1302
+ if (!classNameTransformer || !ASTRO_MODULE.test(id)) return null;
1303
+ const filePath = id.startsWith("/@fs/") ? id.slice(4) : id;
1304
+ 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.`);
1305
+ const code = fs.default.readFileSync(filePath, "utf-8");
1306
+ loadedAstroModules.add(id);
1307
+ if (!astroTransformer) {
1308
+ const parse = await resolveAstroCompiler(viteConfig?.root || process.cwd());
1309
+ 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.`);
1310
+ astroTransformer = createAstroTransformer({
1311
+ parse,
1312
+ classes: classNameTransformer.classes
1313
+ });
1314
+ }
1315
+ const result = await astroTransformer(code, filePath);
1316
+ return result.code === code ? null : result;
1317
+ },
842
1318
  transform(code, id) {
843
1319
  if (!classNameTransformer) return null;
1320
+ if (ASTRO_MODULE.test(id)) {
1321
+ 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.`);
1322
+ return null;
1323
+ }
1324
+ if (ASTRO_SOURCE.test(id)) return null;
844
1325
  const transformed = classNameTransformer.withSourceMap(code, id);
845
1326
  return transformed.code === code ? null : transformed;
846
1327
  },
@@ -907,9 +1388,15 @@ function cssUtility(opts = {}) {
907
1388
  }
908
1389
  if (tempConfigPath && fs.default.existsSync(tempConfigPath)) fs.default.unlinkSync(tempConfigPath);
909
1390
  };
1391
+ const close = server.close.bind(server);
1392
+ server.close = async () => {
1393
+ cleanup();
1394
+ return close();
1395
+ };
910
1396
  server.httpServer?.on("close", cleanup);
911
1397
  process.once("SIGINT", cleanup);
912
1398
  process.once("SIGTERM", cleanup);
1399
+ process.once("exit", cleanup);
913
1400
  },
914
1401
  writeBundle() {
915
1402
  if (!manifestSnapshot || fs.default.existsSync(manifestSnapshot.path)) return;
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,7 +165,7 @@ 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[\\/]/;
169
170
  function hasOwn(classes, token) {
170
171
  return Object.hasOwn(classes, token);
@@ -210,6 +211,7 @@ function findRawTextClosing(code, tagName, start) {
210
211
  function analyzeHtml(code, id) {
211
212
  const markup = new Array(code.length).fill(" ");
212
213
  const scriptRanges = [];
214
+ const commentRanges = [];
213
215
  const exampleStack = [];
214
216
  let index = 0;
215
217
  while (index < code.length) {
@@ -217,7 +219,12 @@ function analyzeHtml(code, id) {
217
219
  if (open < 0) break;
218
220
  if (code.startsWith("<!--", open)) {
219
221
  const commentEnd = code.indexOf("-->", open + 4);
220
- index = commentEnd < 0 ? code.length : commentEnd + 3;
222
+ const end = commentEnd < 0 ? code.length : commentEnd + 3;
223
+ commentRanges.push({
224
+ start: open,
225
+ end
226
+ });
227
+ index = end;
221
228
  continue;
222
229
  }
223
230
  const tagEnd = findHtmlTagEnd(code, open);
@@ -253,7 +260,8 @@ function analyzeHtml(code, id) {
253
260
  if (exampleStack.length > 0) throw new Error(`[gustcss] unclosed <${exampleStack.at(-1)}> in ${id}`);
254
261
  return {
255
262
  markup: markup.join(""),
256
- scriptRanges
263
+ scriptRanges,
264
+ commentRanges
257
265
  };
258
266
  }
259
267
  function canStartRegex(code, index, previous) {
@@ -481,7 +489,7 @@ function rejectAmbiguousClassExpressions(code, structure, classes, id) {
481
489
  while ((match = pattern.exec(structure)) !== null) {
482
490
  const open = code.indexOf("{", match.index);
483
491
  const end = findBalancedEnd(code, open, "{", "}");
484
- if (end < 0) break;
492
+ if (end < 0) throw new Error(`[gustcss] incomplete class/className expression in ${id}. Ensure the expression is properly closed.`);
485
493
  const ambiguous = findMappedToken(code.slice(open + 1, end - 1), classes);
486
494
  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
495
  pattern.lastIndex = end;
@@ -575,30 +583,53 @@ function sourcesForFile(code, id) {
575
583
  const inspection = new Array(code.length).fill(" ");
576
584
  for (const range of html.scriptRanges) {
577
585
  const script = code.slice(range.start, range.end);
578
- const js = createJsMasks(script);
586
+ const jsScript = createJsMasks(script);
579
587
  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];
588
+ structure[range.start + offset] = jsScript.structure[offset];
589
+ inspection[range.start + offset] = jsScript.inspection[offset];
582
590
  }
583
591
  }
592
+ for (const range of html.commentRanges) for (let offset = range.start; offset < range.end; offset += 1) inspection[offset] = " ";
584
593
  return {
585
594
  attributes: html.markup,
586
595
  structure: structure.join(""),
587
596
  inspection: inspection.join("")
588
597
  };
589
598
  }
590
- function collectRecognizedContextEdits(code, classes, id) {
599
+ function collectEdits(code, classes, id) {
591
600
  const edits = [];
592
601
  const sources = sourcesForFile(code, id);
602
+ const commentRanges = [];
603
+ let commentIndex = 0;
604
+ while ((commentIndex = code.indexOf("<!--", commentIndex)) >= 0) {
605
+ const commentEnd = code.indexOf("-->", commentIndex + 4);
606
+ if (commentEnd < 0) {
607
+ commentRanges.push({
608
+ start: commentIndex,
609
+ end: code.length
610
+ });
611
+ break;
612
+ }
613
+ commentRanges.push({
614
+ start: commentIndex,
615
+ end: commentEnd + 3
616
+ });
617
+ commentIndex = commentEnd + 3;
618
+ }
619
+ function isInComment(pos) {
620
+ return commentRanges.some((range) => pos >= range.start && pos < range.end);
621
+ }
593
622
  const attributePattern = /((?<![:\w-])class(?:Name)?\s*=\s*)(?:(["'])([\s\S]*?)(\2)|(\{\s*)(["'])([\s\S]*?)(\6)(\s*\}))/g;
594
623
  let match;
595
624
  while ((match = attributePattern.exec(sources.attributes)) !== null) {
625
+ if (isInComment(match.index)) continue;
596
626
  if (!HTML_SOURCE.test(id) && !isJsxAttributeAt(sources.structure, match.index)) continue;
597
627
  if (match[2] !== void 0) collectClassListEdits(match[3], classes, match.index + match[1].length + 1, edits);
598
628
  else collectClassListEdits(match[7], classes, match.index + match[1].length + match[5].length + 1, edits);
599
629
  }
600
630
  const templatePattern = /((?<![:\w-])class(?:Name)?\s*=\s*\{\s*`)([\s\S]*?)(`\s*\})/g;
601
631
  while ((match = templatePattern.exec(sources.attributes)) !== null) {
632
+ if (isInComment(match.index)) continue;
602
633
  if (!HTML_SOURCE.test(id) && !isJsxAttributeAt(sources.structure, match.index)) continue;
603
634
  collectTemplateBodyEdits(match[2], classes, match.index + match[1].length, edits);
604
635
  }
@@ -608,7 +639,27 @@ function collectRecognizedContextEdits(code, classes, id) {
608
639
  if (sources.structure[match.index] === " ") continue;
609
640
  collectClassListEdits(match[3], classes, match.index + match[1].length + 1, edits);
610
641
  }
611
- return applyEdits(code, edits);
642
+ return edits;
643
+ }
644
+ function collectRecognizedContextEdits(code, classes, id) {
645
+ return applyEdits(code, collectEdits(code, classes, id));
646
+ }
647
+ /**
648
+ * Collect the rewrites for a source fragment (TypeScript frontmatter, an inline
649
+ * `<script>` body, …) and run the same fail-closed checks as `transformSource`,
650
+ * but return the edits instead of the rewritten code so the caller can place
651
+ * them inside a larger document.
652
+ */
653
+ function collectFragmentEdits(code, classes, id) {
654
+ const edits = collectEdits(code, classes, id);
655
+ const rewritten = applyEdits(code, edits).code;
656
+ const sources = sourcesForFile(rewritten, id);
657
+ rejectDynamicSetAttributeCalls(rewritten, sources.structure, classes, id);
658
+ rejectDynamicClassNameAssignments(rewritten, sources.structure, classes, id);
659
+ rejectAmbiguousClassExpressions(rewritten, sources.structure, classes, id);
660
+ const ambiguous = findMappedInQuotedSegments(sources.inspection, classes);
661
+ 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;
612
663
  }
613
664
  const BASE64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
614
665
  function encodeVLQ(value) {
@@ -696,7 +747,7 @@ function createClassNameTransformer(classes) {
696
747
  const transformWithSourceMap = (code, id) => {
697
748
  if (UNSUPPORTED_FRAMEWORK_SOURCE.test(id)) {
698
749
  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.`);
750
+ 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
751
  return {
701
752
  code,
702
753
  map: null
@@ -716,9 +767,392 @@ function createClassNameTransformer(classes) {
716
767
  };
717
768
  const transform = (code, id) => transformWithSourceMap(code, id).code;
718
769
  transform.withSourceMap = transformWithSourceMap;
770
+ transform.classes = classes;
719
771
  return transform;
720
772
  }
721
773
  //#endregion
774
+ //#region src/astro.js
775
+ /**
776
+ * Class-name mangling for `.astro` sources, driven by the AST that
777
+ * `@astrojs/compiler`'s `parse()` returns.
778
+ *
779
+ * Only unambiguous class contexts are rewritten; any other place where a
780
+ * mapped class name shows up fails the build (fail-closed), because a class
781
+ * that reaches the DOM unrenamed would no longer match the mangled CSS.
782
+ */
783
+ const CLASS_VALUED_BEFORE = /* @__PURE__ */ new Set([
784
+ "[",
785
+ ",",
786
+ "&&",
787
+ "||"
788
+ ]);
789
+ const CLASS_VALUED_AFTER = /* @__PURE__ */ new Set([
790
+ ",",
791
+ "]",
792
+ "&&",
793
+ "||"
794
+ ]);
795
+ const OPERATORS = [
796
+ "===",
797
+ "!==",
798
+ "&&",
799
+ "||",
800
+ "??",
801
+ "=>",
802
+ "==",
803
+ "!=",
804
+ "?.",
805
+ "<=",
806
+ ">="
807
+ ];
808
+ const PUNCTUATION = "[]{}(),:?.!+-*/%<>=&|~^;";
809
+ /** Map UTF-8 byte offsets (what the compiler reports) to string indexes. */
810
+ function byteToIndexMap(code) {
811
+ const map = [];
812
+ let byte = 0;
813
+ for (let index = 0; index < code.length; index += 1) {
814
+ const point = code.codePointAt(index);
815
+ const width = point < 128 ? 1 : point < 2048 ? 2 : point < 65536 ? 3 : 4;
816
+ for (let i = 0; i < width; i += 1) map[byte + i] = index;
817
+ byte += width;
818
+ if (point > 65535) index += 1;
819
+ }
820
+ map[byte] = code.length;
821
+ return map;
822
+ }
823
+ /**
824
+ * Tokenize a JavaScript expression just far enough to know, for every string
825
+ * literal and identifier, what surrounds it. Template literals are reported
826
+ * as strings with quote "`" and are never rewritten. Returns null when the
827
+ * expression cannot be tokenized (unterminated string, unknown character).
828
+ */
829
+ function tokenizeExpression(source) {
830
+ const tokens = [];
831
+ let index = 0;
832
+ while (index < source.length) {
833
+ const char = source[index];
834
+ if (/\s/.test(char)) {
835
+ index += 1;
836
+ continue;
837
+ }
838
+ if (source.startsWith("//", index)) {
839
+ const newline = source.indexOf("\n", index);
840
+ index = newline < 0 ? source.length : newline + 1;
841
+ continue;
842
+ }
843
+ if (source.startsWith("/*", index)) {
844
+ const close = source.indexOf("*/", index + 2);
845
+ if (close < 0) return null;
846
+ index = close + 2;
847
+ continue;
848
+ }
849
+ if (char === "\"" || char === "'" || char === "`") {
850
+ const start = index;
851
+ index += 1;
852
+ while (index < source.length && source[index] !== char) {
853
+ if (source[index] === "\\") index += 1;
854
+ index += 1;
855
+ }
856
+ if (index >= source.length) return null;
857
+ tokens.push({
858
+ type: "string",
859
+ quote: char,
860
+ start,
861
+ end: index + 1,
862
+ value: source.slice(start + 1, index)
863
+ });
864
+ index += 1;
865
+ continue;
866
+ }
867
+ if (/[A-Za-z_$]/.test(char)) {
868
+ const start = index;
869
+ while (index < source.length && /[\w$]/.test(source[index])) index += 1;
870
+ tokens.push({
871
+ type: "identifier",
872
+ start,
873
+ end: index,
874
+ value: source.slice(start, index)
875
+ });
876
+ continue;
877
+ }
878
+ if (/[0-9]/.test(char)) {
879
+ const start = index;
880
+ while (index < source.length && /[\w.]/.test(source[index])) index += 1;
881
+ tokens.push({
882
+ type: "number",
883
+ start,
884
+ end: index,
885
+ value: source.slice(start, index)
886
+ });
887
+ continue;
888
+ }
889
+ const operator = OPERATORS.find((candidate) => source.startsWith(candidate, index));
890
+ if (operator) {
891
+ tokens.push({
892
+ type: "operator",
893
+ start: index,
894
+ end: index + operator.length,
895
+ value: operator
896
+ });
897
+ index += operator.length;
898
+ continue;
899
+ }
900
+ if (PUNCTUATION.includes(char)) {
901
+ tokens.push({
902
+ type: "operator",
903
+ start: index,
904
+ end: index + 1,
905
+ value: char
906
+ });
907
+ index += 1;
908
+ continue;
909
+ }
910
+ return null;
911
+ }
912
+ return tokens;
913
+ }
914
+ /**
915
+ * Rewrite the class-valued string literals of a `class:list` expression.
916
+ * Accepted shapes are array literals, object literals and their nesting.
917
+ * Inside them only array elements (also as the right operand of `&&` / `||`)
918
+ * and quoted object keys are class-valued; every other occurrence of a mapped
919
+ * class name fails closed.
920
+ */
921
+ function collectClassListExpressionEdits(source, classes, baseOffset, edits, id) {
922
+ const fail = (token, reason) => {
923
+ 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.`);
924
+ };
925
+ const tokens = tokenizeExpression(source);
926
+ const first = tokens && tokens[0];
927
+ const last = tokens && tokens[tokens.length - 1];
928
+ if (!(tokens && tokens.length >= 2 && (first.value === "[" && last.value === "]" || first.value === "{" && last.value === "}"))) {
929
+ const mapped = findMappedToken(source, classes);
930
+ if (mapped) fail(mapped, "remains in an expression that is not an array or object literal");
931
+ return;
932
+ }
933
+ const stack = [];
934
+ for (let i = 0; i < tokens.length; i += 1) {
935
+ const token = tokens[i];
936
+ const previous = tokens[i - 1];
937
+ const next = tokens[i + 1];
938
+ const context = stack[stack.length - 1];
939
+ if (token.type === "operator") {
940
+ if (token.value === "[") {
941
+ const subscript = previous && (previous.type === "identifier" || previous.type === "string" || previous.value === ")" || previous.value === "]");
942
+ stack.push(subscript ? "subscript" : "array");
943
+ } else if (token.value === "{") stack.push("object");
944
+ else if (token.value === "(") stack.push("call");
945
+ else if (token.value === "]" || token.value === "}" || token.value === ")") {
946
+ if (stack.length === 0) fail(findMappedToken(source, classes) || "?", "appears in an unbalanced");
947
+ stack.pop();
948
+ }
949
+ continue;
950
+ }
951
+ if (token.type === "identifier") {
952
+ if (!hasOwn(classes, token.value)) continue;
953
+ 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}")`);
954
+ fail(token.value, "is referenced as an identifier");
955
+ }
956
+ if (token.type !== "string") continue;
957
+ const mapped = findMappedToken(token.value, classes);
958
+ if (token.quote === "`") {
959
+ if (mapped) fail(mapped, "remains in a template literal");
960
+ continue;
961
+ }
962
+ const arrayElement = context === "array" && previous && CLASS_VALUED_BEFORE.has(previous.value) && next && CLASS_VALUED_AFTER.has(next.value);
963
+ const objectKey = context === "object" && previous && (previous.value === "{" || previous.value === ",") && next && next.value === ":";
964
+ if (arrayElement || objectKey) collectClassListEdits(token.value, classes, baseOffset + token.start + 1, edits);
965
+ else if (mapped) fail(mapped, "remains in a position that is not an array element or object key");
966
+ }
967
+ if (stack.length !== 0) fail(findMappedToken(source, classes) || "?", "appears in an unbalanced");
968
+ }
969
+ /**
970
+ * Find a mapped class referenced by a stylesheet selector, including at-rule
971
+ * preludes (`@scope (.flex)`) and attribute selectors (`[class~="flex"]`).
972
+ * Declarations and comments are ignored.
973
+ */
974
+ function findMappedClassInStylesheet(css, classes) {
975
+ const source = css.replace(/\/\*[\s\S]*?\*\//g, " ");
976
+ let prelude = "";
977
+ let quote = null;
978
+ for (let i = 0; i < source.length; i += 1) {
979
+ const char = source[i];
980
+ if (quote) {
981
+ prelude += char;
982
+ if (char === "\\") {
983
+ prelude += source[i + 1] || "";
984
+ i += 1;
985
+ } else if (char === quote) quote = null;
986
+ continue;
987
+ }
988
+ if (char === "\"" || char === "'") {
989
+ quote = char;
990
+ prelude += char;
991
+ continue;
992
+ }
993
+ if (char === "{") {
994
+ const found = findMappedClassInSelector(prelude.trim(), classes);
995
+ if (found) return found;
996
+ prelude = "";
997
+ } else if (char === "}" || char === ";") prelude = "";
998
+ else prelude += char;
999
+ }
1000
+ return null;
1001
+ }
1002
+ function findMappedClassInSelector(selector, classes) {
1003
+ const classPattern = /\.((?:\\.|[A-Za-z0-9_-])+)/g;
1004
+ let match;
1005
+ while ((match = classPattern.exec(selector)) !== null) {
1006
+ const name = match[1].replace(/\\(.)/g, "$1");
1007
+ if (hasOwn(classes, name)) return name;
1008
+ }
1009
+ const attributePattern = /\[\s*class\s*[~|^$*]?=\s*(["']?)([^\]"']+)\1\s*[is]?\s*\]/g;
1010
+ while ((match = attributePattern.exec(selector)) !== null) {
1011
+ const mapped = findMappedToken(match[2], classes);
1012
+ if (mapped) return mapped;
1013
+ }
1014
+ return null;
1015
+ }
1016
+ /**
1017
+ * Locate the value of an attribute from its start offset (the attribute
1018
+ * name). Returns the index range of the value and its delimiter.
1019
+ */
1020
+ function locateAttributeValue(code, attribute, start) {
1021
+ let index = start + attribute.name.length;
1022
+ while (index < code.length && /\s/.test(code[index])) index += 1;
1023
+ if (code[index] !== "=") return null;
1024
+ index += 1;
1025
+ while (index < code.length && /\s/.test(code[index])) index += 1;
1026
+ const delimiter = code[index];
1027
+ if (delimiter === "\"" || delimiter === "'" || delimiter === "`") {
1028
+ const end = code.indexOf(delimiter, index + 1);
1029
+ return end < 0 ? null : {
1030
+ start: index + 1,
1031
+ end,
1032
+ delimiter
1033
+ };
1034
+ }
1035
+ if (delimiter === "{") {
1036
+ const end = findBalancedEnd(code, index, "{", "}");
1037
+ return end < 0 ? null : {
1038
+ start: index + 1,
1039
+ end: end - 1,
1040
+ delimiter
1041
+ };
1042
+ }
1043
+ return null;
1044
+ }
1045
+ const STRING_LITERAL = /^\s*(["'])((?:\\.|(?!\1)[^\\])*)\1\s*$/;
1046
+ /**
1047
+ * Create the `.astro` transformer. `parse` is `@astrojs/compiler`'s parse
1048
+ * function, injected so the plugin and the tests can supply their own copy.
1049
+ */
1050
+ function createAstroTransformer({ parse, classes }) {
1051
+ return async function transformAstro(code, id) {
1052
+ const { ast } = await parse(code, { position: true });
1053
+ const toIndex = byteToIndexMap(code);
1054
+ const edits = [];
1055
+ const offsetOf = (node) => toIndex[node.position.start.offset];
1056
+ const stop = (mapped, where) => {
1057
+ 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.`);
1058
+ };
1059
+ const fragmentEdits = (text, start, kind) => {
1060
+ for (const edit of collectFragmentEdits(text, classes, `${id}#${kind}.ts`)) edits.push({
1061
+ start: start + edit.start,
1062
+ end: start + edit.end,
1063
+ replacement: edit.replacement
1064
+ });
1065
+ };
1066
+ const visitAttribute = (attribute, node) => {
1067
+ const start = offsetOf(attribute);
1068
+ const isComponent = node.type === "component";
1069
+ const value = attribute.value || "";
1070
+ if (attribute.name === "class") {
1071
+ const location = locateAttributeValue(code, attribute, start);
1072
+ if (location && attribute.kind === "quoted") {
1073
+ collectClassListEdits(code.slice(location.start, location.end), classes, location.start, edits);
1074
+ return;
1075
+ }
1076
+ if (location && attribute.kind === "expression") {
1077
+ const expression = code.slice(location.start, location.end);
1078
+ const literal = STRING_LITERAL.exec(expression);
1079
+ if (literal) {
1080
+ const inner = expression.indexOf(literal[1]) + 1;
1081
+ collectClassListEdits(literal[2], classes, location.start + inner, edits);
1082
+ return;
1083
+ }
1084
+ }
1085
+ const mapped = findMappedToken(value, classes);
1086
+ if (mapped) stop(mapped, `a dynamic class attribute (${attribute.kind})`);
1087
+ return;
1088
+ }
1089
+ if (attribute.name === "class:list") {
1090
+ const location = locateAttributeValue(code, attribute, start);
1091
+ if (!location || attribute.kind !== "expression") {
1092
+ const mapped = findMappedToken(value, classes);
1093
+ if (mapped) stop(mapped, `a class:list attribute of kind ${attribute.kind}`);
1094
+ return;
1095
+ }
1096
+ collectClassListExpressionEdits(code.slice(location.start, location.end), classes, location.start, edits, id);
1097
+ return;
1098
+ }
1099
+ if (attribute.kind === "spread") {
1100
+ const mapped = findMappedToken(`${attribute.name} ${value}`, classes);
1101
+ if (mapped) stop(mapped, `a spread attribute on <${node.name}>`);
1102
+ return;
1103
+ }
1104
+ if (attribute.name === "set:html" || attribute.name === "set:text" || isComponent) {
1105
+ const mapped = findMappedToken(value, classes);
1106
+ if (mapped) stop(mapped, isComponent ? `the "${attribute.name}" prop of <${node.name}>` : `a ${attribute.name} value`);
1107
+ }
1108
+ };
1109
+ const visit = (node) => {
1110
+ switch (node.type) {
1111
+ case "frontmatter": {
1112
+ const start = code.indexOf(node.value, offsetOf(node));
1113
+ if (start >= 0) fragmentEdits(node.value, start, "frontmatter");
1114
+ return;
1115
+ }
1116
+ case "comment":
1117
+ case "text":
1118
+ case "doctype": return;
1119
+ case "expression":
1120
+ for (const child of node.children) if (child.type === "text") {
1121
+ const mapped = findMappedToken(child.value, classes);
1122
+ if (mapped && /["'`]/.test(child.value)) stop(mapped, "a template expression");
1123
+ } else visit(child);
1124
+ return;
1125
+ case "element":
1126
+ case "component":
1127
+ case "custom-element":
1128
+ case "fragment":
1129
+ for (const attribute of node.attributes) visitAttribute(attribute, node);
1130
+ if (node.type === "element" && node.name === "style") {
1131
+ for (const child of node.children) {
1132
+ if (child.type !== "text") continue;
1133
+ const mapped = findMappedClassInStylesheet(child.value, classes);
1134
+ if (mapped) stop(mapped, "a <style> selector");
1135
+ }
1136
+ return;
1137
+ }
1138
+ if (node.type === "element" && node.name === "script") {
1139
+ for (const child of node.children) if (child.type === "text") fragmentEdits(child.value, offsetOf(child), "script");
1140
+ return;
1141
+ }
1142
+ for (const child of node.children) visit(child);
1143
+ return;
1144
+ default: for (const child of node.children || []) visit(child);
1145
+ }
1146
+ };
1147
+ visit(ast);
1148
+ const applied = applyEdits(code, edits);
1149
+ return {
1150
+ code: applied.code,
1151
+ map: createSourceMap(code, applied.code, applied.origins, id)
1152
+ };
1153
+ };
1154
+ }
1155
+ //#endregion
722
1156
  //#region src/index.js
723
1157
  /**
724
1158
  * @gustcss/vite - Vite plugin for CSS Utility Generator
@@ -733,6 +1167,24 @@ function createClassNameTransformer(classes) {
733
1167
  * are provided by the developer in their build configuration. This plugin is
734
1168
  * designed for build-time use only and should not process untrusted input.
735
1169
  */
1170
+ const ASTRO_SOURCE = /\.astro(?:$|\?)/;
1171
+ const ASTRO_MODULE = /^[^\0?]*\.astro$/;
1172
+ /**
1173
+ * Resolve @astrojs/compiler parse function from the project.
1174
+ */
1175
+ async function resolveAstroCompiler(root) {
1176
+ const projectRequire = createRequire(path.join(root, "package.json"));
1177
+ const attempts = [
1178
+ () => projectRequire.resolve("@astrojs/compiler"),
1179
+ () => createRequire(projectRequire.resolve("astro/package.json")).resolve("@astrojs/compiler"),
1180
+ () => createRequire(import.meta.url).resolve("@astrojs/compiler")
1181
+ ];
1182
+ for (const attempt of attempts) try {
1183
+ const mod = await import(pathToFileURL(attempt()).href);
1184
+ if (typeof mod.parse === "function") return mod.parse;
1185
+ } catch {}
1186
+ return null;
1187
+ }
736
1188
  function cssUtility(opts = {}) {
737
1189
  const output = opts.output || "src/styles/utility.css";
738
1190
  const content = opts.content || ["./src/**/*.{js,ts,jsx,tsx,astro,vue}"];
@@ -745,6 +1197,8 @@ function cssUtility(opts = {}) {
745
1197
  let rollbackContext = null;
746
1198
  let manifestSnapshot = null;
747
1199
  let watchProcess = null;
1200
+ const loadedAstroModules = /* @__PURE__ */ new Set();
1201
+ let astroTransformer = null;
748
1202
  function findBinary(cwd) {
749
1203
  return import_runner.default.resolveBinary({
750
1204
  cwd,
@@ -816,15 +1270,19 @@ function cssUtility(opts = {}) {
816
1270
  fs
817
1271
  }, (tempConfigPath) => run(tempConfigPath));
818
1272
  }
1273
+ let viteConfig = null;
819
1274
  return {
820
1275
  name: "gustcss",
821
1276
  ...mangleClassNames ? { enforce: "pre" } : {},
822
1277
  configResolved(config) {
1278
+ viteConfig = config;
823
1279
  const cwd = config.root || process.cwd();
824
1280
  const binaryPath = findBinary(cwd);
825
1281
  try {
826
1282
  const enableMangle = mangleClassNames && config.command === "build";
827
1283
  buildCSS(cwd, binaryPath, enableMangle);
1284
+ loadedAstroModules.clear();
1285
+ astroTransformer = null;
828
1286
  rollbackContext = enableMangle ? {
829
1287
  cwd,
830
1288
  binaryPath
@@ -834,8 +1292,30 @@ function cssUtility(opts = {}) {
834
1292
  throw error;
835
1293
  }
836
1294
  },
1295
+ async load(id) {
1296
+ if (!classNameTransformer || !ASTRO_MODULE.test(id)) return null;
1297
+ const filePath = id.startsWith("/@fs/") ? id.slice(4) : id;
1298
+ 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.`);
1299
+ const code = fs.readFileSync(filePath, "utf-8");
1300
+ loadedAstroModules.add(id);
1301
+ if (!astroTransformer) {
1302
+ const parse = await resolveAstroCompiler(viteConfig?.root || process.cwd());
1303
+ 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.`);
1304
+ astroTransformer = createAstroTransformer({
1305
+ parse,
1306
+ classes: classNameTransformer.classes
1307
+ });
1308
+ }
1309
+ const result = await astroTransformer(code, filePath);
1310
+ return result.code === code ? null : result;
1311
+ },
837
1312
  transform(code, id) {
838
1313
  if (!classNameTransformer) return null;
1314
+ if (ASTRO_MODULE.test(id)) {
1315
+ 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.`);
1316
+ return null;
1317
+ }
1318
+ if (ASTRO_SOURCE.test(id)) return null;
839
1319
  const transformed = classNameTransformer.withSourceMap(code, id);
840
1320
  return transformed.code === code ? null : transformed;
841
1321
  },
@@ -902,9 +1382,15 @@ function cssUtility(opts = {}) {
902
1382
  }
903
1383
  if (tempConfigPath && fs.existsSync(tempConfigPath)) fs.unlinkSync(tempConfigPath);
904
1384
  };
1385
+ const close = server.close.bind(server);
1386
+ server.close = async () => {
1387
+ cleanup();
1388
+ return close();
1389
+ };
905
1390
  server.httpServer?.on("close", cleanup);
906
1391
  process.once("SIGINT", cleanup);
907
1392
  process.once("SIGTERM", cleanup);
1393
+ process.once("exit", cleanup);
908
1394
  },
909
1395
  writeBundle() {
910
1396
  if (!manifestSnapshot || fs.existsSync(manifestSnapshot.path)) return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gustcss/vite",
3
- "version": "0.10.0",
3
+ "version": "0.10.1",
4
4
  "description": "Vite plugin for GustCSS",
5
5
  "main": "dist/index.mjs",
6
6
  "module": "dist/index.mjs",
@@ -37,9 +37,10 @@
37
37
  "access": "public"
38
38
  },
39
39
  "dependencies": {
40
- "gustcss": "^0.10.0"
40
+ "gustcss": "^0.10.1"
41
41
  },
42
42
  "devDependencies": {
43
+ "@astrojs/compiler": "2.13.0",
43
44
  "rolldown": "^1.1.0",
44
45
  "vitest": "^4.1.8"
45
46
  },