@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 +18 -3
- package/dist/index.cjs +497 -10
- package/dist/index.mjs +496 -10
- package/package.json +3 -2
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
|
|
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
|
-
|
|
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
|
|
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|
|
|
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
|
-
|
|
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)
|
|
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
|
|
592
|
+
const jsScript = createJsMasks(script);
|
|
584
593
|
for (let offset = 0; offset < script.length; offset += 1) {
|
|
585
|
-
structure[range.start + offset] =
|
|
586
|
-
inspection[range.start + 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
|
|
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
|
|
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
|
|
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|
|
|
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
|
-
|
|
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)
|
|
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
|
|
586
|
+
const jsScript = createJsMasks(script);
|
|
579
587
|
for (let offset = 0; offset < script.length; offset += 1) {
|
|
580
|
-
structure[range.start + offset] =
|
|
581
|
-
inspection[range.start + 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
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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
|
},
|