@aihu/css-engine 0.2.5 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +27 -22
  2. package/crates/aihu-css-core/src/apply.rs +314 -0
  3. package/crates/aihu-css-core/src/bin/main.rs +8 -7
  4. package/crates/aihu-css-core/src/cache.rs +8 -5
  5. package/crates/aihu-css-core/src/emit.rs +195 -36
  6. package/crates/aihu-css-core/src/lib.rs +15 -2
  7. package/crates/aihu-css-core/src/palette.rs +301 -0
  8. package/crates/aihu-css-core/src/style_parser.rs +587 -0
  9. package/crates/aihu-css-core/src/theme.rs +14 -0
  10. package/crates/aihu-css-core/src/tokens.rs +1196 -29
  11. package/crates/aihu-css-core/src/variants.rs +251 -3
  12. package/crates/aihu-css-core/tests/apply.rs +203 -0
  13. package/crates/aihu-css-core/tests/apply_regression.rs +150 -0
  14. package/crates/aihu-css-core/tests/binary_error.rs +61 -0
  15. package/crates/aihu-css-core/tests/cache.rs +8 -8
  16. package/crates/aihu-css-core/tests/emit.rs +284 -17
  17. package/crates/aihu-css-core/tests/parity.rs +274 -0
  18. package/crates/aihu-css-core/tests/progressive_snapshot.rs +8 -8
  19. package/crates/aihu-css-core/tests/scoped_snapshot.rs +80 -8
  20. package/crates/aihu-css-core/tests/snapshots/apply__apply_inside_nested_rule.snap +11 -0
  21. package/crates/aihu-css-core/tests/snapshots/apply__arbitrary_value_utility_in_apply.snap +8 -0
  22. package/crates/aihu-css-core/tests/snapshots/apply__arbitrary_value_variant_in_apply.snap +10 -0
  23. package/crates/aihu-css-core/tests/snapshots/apply__base_utility_inlines_declarations.snap +9 -0
  24. package/crates/aihu-css-core/tests/snapshots/apply__dark_variant_cascade_in_apply.snap +10 -0
  25. package/crates/aihu-css-core/tests/snapshots/apply__data_attribute_variant.snap +10 -0
  26. package/crates/aihu-css-core/tests/snapshots/apply__multi_token_apply.snap +11 -0
  27. package/crates/aihu-css-core/tests/snapshots/apply__multiple_apply_directives_per_rule.snap +12 -0
  28. package/crates/aihu-css-core/tests/snapshots/apply__responsive_variant_wraps_media.snap +12 -0
  29. package/crates/aihu-css-core/tests/snapshots/apply__single_variant_lifts_to_nested_rule.snap +10 -0
  30. package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__anchor_snapshot.snap +1 -0
  31. package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__popover_snapshot.snap +1 -0
  32. package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__text_balance_snapshot.snap +1 -0
  33. package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__view_transition_snapshot.snap +1 -0
  34. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_animate_spin_hoists_keyframes.snap +25 -0
  35. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_divide_y_nested_rule.snap +24 -0
  36. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_output_for_sfc.snap +1 -0
  37. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_space_y_nested_rule.snap +24 -0
  38. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_transition_and_transform.snap +26 -0
  39. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_with_authored_style_block.snap +6 -2
  40. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_with_global_style_block.snap +5 -2
  41. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__standard_variants.snap +1 -0
  42. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__theme_default_vs_override.snap +2 -0
  43. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__wc_native_variants.snap +1 -0
  44. package/crates/aihu-css-core/tests/style_parser.rs +257 -0
  45. package/crates/aihu-css-core/tests/tokens.rs +526 -7
  46. package/dist/index.d.ts +0 -9
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +26 -18
  49. package/dist/index.js.map +1 -1
  50. package/dist/runtime/cn.js +13 -0
  51. package/dist/runtime/cn.js.map +1 -1
  52. package/package.json +6 -6
@@ -0,0 +1,587 @@
1
+ //! `style_parser.rs` — structured `@style`-rule parser (R-SHARED-PARSER).
2
+ //!
3
+ //! Parses an authored `@style` block (the CSS body that `emit_sfc_scoped` folds
4
+ //! into the shadow `<style>`, minus `@theme` directives) into a structured rule
5
+ //! tree. This is the SINGLE source of `@style` structure reused by two passes:
6
+ //!
7
+ //! - **`@apply` expansion** (`apply.rs`, T3): each rule's `@apply` directives are
8
+ //! replaced — base utilities inline as declarations, variant tokens lift to
9
+ //! nested selectors.
10
+ //! - **Variant validation** (`validate.rs`, PR-3): each rule's selector context
11
+ //! is checked against the declared `@meta.variants` axes.
12
+ //!
13
+ //! Codex flagged a naive string scanner as a trap: arbitrary-value utilities
14
+ //! (`bg-[#fff]`), `;` inside `url(...)`/string values, `:` inside selectors and
15
+ //! values, and braces inside comments/strings must NOT break tokenization. So
16
+ //! this is a real comment-aware, string-aware, brace-nesting-aware parse — not a
17
+ //! `split(';')`/`split('{')` scanner.
18
+ //!
19
+ //! ## Rule tree shape
20
+ //!
21
+ //! ```text
22
+ //! @style {
23
+ //! .a, .b { ← StyleRule { selector: ".a, .b", … }
24
+ //! color: red; ← declarations: [Declaration { prop: "color", value: "red" }]
25
+ //! @apply p-4 m-2; ← applies: [ApplyDirective { tokens: ["p-4", "m-2"] }]
26
+ //! @media (...) { ← nested: [StyleNode::AtRule(AtRule { prelude, body })]
27
+ //! .a { … }
28
+ //! }
29
+ //! }
30
+ //! }
31
+ //! ```
32
+ //!
33
+ //! `StyleSheet` is the top-level list of [`StyleNode`]s (a rule, a bare at-rule,
34
+ //! or — for full fidelity round-tripping — verbatim leading/trailing text such
35
+ //! as a stray declaration outside any rule, which authored `@style` may contain).
36
+
37
+ /// A single `prop: value` declaration inside a rule body.
38
+ #[derive(Debug, Clone, PartialEq, Eq)]
39
+ pub struct Declaration {
40
+ /// The property name, trimmed (`color`, `--my-var`, `background`).
41
+ pub prop: String,
42
+ /// The value text, trimmed, WITHOUT the trailing `;`. May itself contain
43
+ /// `:`, `;`-in-`url()`, commas, parentheses, and quoted strings.
44
+ pub value: String,
45
+ }
46
+
47
+ /// An `@apply <tokens>;` directive captured inside a rule body. The tokens are
48
+ /// the whitespace-separated utility class names (variant prefixes intact, e.g.
49
+ /// `hover:bg-accent`, `bg-[#fff]`), exactly as the scanner/emitter expect.
50
+ #[derive(Debug, Clone, PartialEq, Eq)]
51
+ pub struct ApplyDirective {
52
+ pub tokens: Vec<String>,
53
+ }
54
+
55
+ /// A style rule: a selector prelude plus a body of declarations, `@apply`
56
+ /// directives, and nested nodes (nested rules or nested at-rules).
57
+ #[derive(Debug, Clone, PartialEq, Eq)]
58
+ pub struct StyleRule {
59
+ /// The raw selector text (everything before the `{`), trimmed. May be a
60
+ /// selector list (`.a, .b`) and contain `:is(...)`, attribute selectors,
61
+ /// pseudo-classes, combinators, and nesting `&`.
62
+ pub selector: String,
63
+ /// `prop: value` declarations directly in this rule's body, in source order.
64
+ pub declarations: Vec<Declaration>,
65
+ /// `@apply` directives directly in this rule's body, in source order.
66
+ pub applies: Vec<ApplyDirective>,
67
+ /// Nested nodes (nested rules / nested at-rules) in this rule's body.
68
+ pub nested: Vec<StyleNode>,
69
+ }
70
+
71
+ /// A nested or bare at-rule (`@media`, `@supports`, `@container`, …) with a
72
+ /// prelude and a brace body of further [`StyleNode`]s.
73
+ #[derive(Debug, Clone, PartialEq, Eq)]
74
+ pub struct AtRule {
75
+ /// The at-rule name including `@` (`@media`, `@supports`, `@container`).
76
+ pub name: String,
77
+ /// The prelude between the name and the `{` (`(min-width: 600px)`), trimmed.
78
+ pub prelude: String,
79
+ /// The body nodes inside the braces.
80
+ pub body: Vec<StyleNode>,
81
+ }
82
+
83
+ /// A statement-level at-rule with no brace body (`@import url(...);`,
84
+ /// `@charset "utf-8";`). Captured verbatim so round-tripping is lossless.
85
+ #[derive(Debug, Clone, PartialEq, Eq)]
86
+ pub struct AtStatement {
87
+ /// The full statement text WITHOUT the trailing `;` (`@import url("a.css")`).
88
+ pub text: String,
89
+ }
90
+
91
+ /// A node in the `@style` tree.
92
+ #[derive(Debug, Clone, PartialEq, Eq)]
93
+ pub enum StyleNode {
94
+ /// A style rule (`selector { … }`).
95
+ Rule(StyleRule),
96
+ /// A nested/bare at-rule with a brace body (`@media (...) { … }`).
97
+ AtRule(AtRule),
98
+ /// A statement at-rule with no body (`@import …;`).
99
+ AtStatement(AtStatement),
100
+ }
101
+
102
+ /// The parsed `@style` block: an ordered list of top-level nodes.
103
+ #[derive(Debug, Clone, Default, PartialEq, Eq)]
104
+ pub struct StyleSheet {
105
+ pub nodes: Vec<StyleNode>,
106
+ }
107
+
108
+ /// An error raised while parsing a `@style` block — an unbalanced brace, an
109
+ /// unterminated comment, or an unterminated string. Surfaced as a structured
110
+ /// error so callers (`@apply`, validation) can convert it into a `CompileError`.
111
+ #[derive(Debug, Clone, PartialEq, Eq)]
112
+ pub enum StyleParseError {
113
+ /// A `{` with no matching `}` (or vice versa) by end of input.
114
+ UnbalancedBraces,
115
+ /// A `/* … */` comment that never closed.
116
+ UnterminatedComment,
117
+ /// A `"…"` or `'…'` string that never closed.
118
+ UnterminatedString,
119
+ }
120
+
121
+ impl std::fmt::Display for StyleParseError {
122
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123
+ let msg = match self {
124
+ StyleParseError::UnbalancedBraces => "unbalanced braces in @style block",
125
+ StyleParseError::UnterminatedComment => "unterminated comment in @style block",
126
+ StyleParseError::UnterminatedString => "unterminated string in @style block",
127
+ };
128
+ f.write_str(msg)
129
+ }
130
+ }
131
+
132
+ impl std::error::Error for StyleParseError {}
133
+
134
+ /// Parse an authored `@style` body into a structured [`StyleSheet`].
135
+ ///
136
+ /// Input is the raw CSS text inside the `@style { … }` block (the same text
137
+ /// `emit_sfc_scoped` folds), already stripped of `@theme` directives. Output is
138
+ /// the rule tree; [`StyleSheet::to_css`] round-trips it back to equivalent CSS.
139
+ pub fn parse_style(input: &str) -> Result<StyleSheet, StyleParseError> {
140
+ let mut parser = Parser::new(input);
141
+ let nodes = parser.parse_nodes(/* top_level = */ true)?;
142
+ Ok(StyleSheet { nodes })
143
+ }
144
+
145
+ /// Cursor over the input that is aware of comments and strings so structural
146
+ /// characters (`{`, `}`, `;`, `:`) inside them are never treated as syntax.
147
+ struct Parser<'a> {
148
+ src: &'a str,
149
+ bytes: &'a [u8],
150
+ pos: usize,
151
+ }
152
+
153
+ impl<'a> Parser<'a> {
154
+ fn new(src: &'a str) -> Self {
155
+ Self {
156
+ src,
157
+ bytes: src.as_bytes(),
158
+ pos: 0,
159
+ }
160
+ }
161
+
162
+ /// Parse a sequence of nodes until either end of input (`top_level`) or an
163
+ /// unmatched `}` that closes the enclosing block (consumed by the caller).
164
+ fn parse_nodes(&mut self, top_level: bool) -> Result<Vec<StyleNode>, StyleParseError> {
165
+ let mut nodes = Vec::new();
166
+ loop {
167
+ self.skip_trivia()?;
168
+ if self.pos >= self.bytes.len() {
169
+ if top_level {
170
+ return Ok(nodes);
171
+ }
172
+ // Reached EOF inside a block with no closing brace.
173
+ return Err(StyleParseError::UnbalancedBraces);
174
+ }
175
+ if self.bytes[self.pos] == b'}' {
176
+ if top_level {
177
+ // A stray `}` at top level is unbalanced.
178
+ return Err(StyleParseError::UnbalancedBraces);
179
+ }
180
+ // Leave the `}` for the caller to consume.
181
+ return Ok(nodes);
182
+ }
183
+ if let Some(node) = self.parse_statement()? {
184
+ nodes.push(node);
185
+ }
186
+ }
187
+ }
188
+
189
+ /// Parse one statement: a declaration, an `@apply`/at-statement, an at-rule
190
+ /// with a body, or a style rule. Returns `None` for an empty `;`.
191
+ fn parse_statement(&mut self) -> Result<Option<StyleNode>, StyleParseError> {
192
+ // Scan the "prelude" (everything up to the next top-level `{`, `;`, or
193
+ // `}`) while honouring comments/strings/nested brackets/parens.
194
+ let start = self.pos;
195
+ let terminator = self.scan_to_terminator()?;
196
+ let prelude = self.src[start..self.pos].trim().to_string();
197
+
198
+ match terminator {
199
+ Terminator::Brace => {
200
+ // `prelude {` — a rule or a body-bearing at-rule.
201
+ self.pos += 1; // consume `{`
202
+ let body = self.parse_nodes(false)?;
203
+ // consume the matching `}`
204
+ if self.pos >= self.bytes.len() || self.bytes[self.pos] != b'}' {
205
+ return Err(StyleParseError::UnbalancedBraces);
206
+ }
207
+ self.pos += 1;
208
+
209
+ if prelude.starts_with('@') {
210
+ let (name, rest) = split_at_name(&prelude);
211
+ Ok(Some(StyleNode::AtRule(AtRule {
212
+ name,
213
+ prelude: rest,
214
+ body,
215
+ })))
216
+ } else {
217
+ Ok(Some(StyleNode::Rule(build_rule(prelude, body))))
218
+ }
219
+ }
220
+ Terminator::Semicolon | Terminator::Eof => {
221
+ // A statement ending in `;` (or trailing at EOF with no `;`).
222
+ if terminator == Terminator::Semicolon {
223
+ self.pos += 1; // consume `;`
224
+ }
225
+ if prelude.is_empty() {
226
+ return Ok(None);
227
+ }
228
+ if prelude.starts_with('@') {
229
+ // A statement at-rule (e.g. `@import …`). `@apply` is handled
230
+ // inside `build_rule` when it appears in a rule body; a
231
+ // top-level `@apply` (outside any rule) is unusual but we
232
+ // keep it as an at-statement so nothing is lost.
233
+ Ok(Some(StyleNode::AtStatement(AtStatement { text: prelude })))
234
+ } else if let Some((prop, value)) = split_declaration(&prelude) {
235
+ // A bare declaration outside a rule — authored `@style` may
236
+ // legitimately contain custom-property declarations at the
237
+ // block root. Wrap it in a selector-less rule so it round-
238
+ // trips, but the common in-rule case is handled in
239
+ // `build_rule`.
240
+ Ok(Some(StyleNode::Rule(StyleRule {
241
+ selector: String::new(),
242
+ declarations: vec![Declaration { prop, value }],
243
+ applies: Vec::new(),
244
+ nested: Vec::new(),
245
+ })))
246
+ } else {
247
+ // Unrecognized statement (no `:`): keep verbatim as an
248
+ // at-statement-like node only if it began with `@`; otherwise
249
+ // drop empty noise. Here it is non-`@`, non-declaration text
250
+ // — preserve as a selector-less rule with the raw text as a
251
+ // single "declaration"-less marker is lossy, so keep it as a
252
+ // rule selector to round-trip.
253
+ Ok(Some(StyleNode::Rule(StyleRule {
254
+ selector: prelude,
255
+ declarations: Vec::new(),
256
+ applies: Vec::new(),
257
+ nested: Vec::new(),
258
+ })))
259
+ }
260
+ }
261
+ Terminator::CloseBrace => {
262
+ // Hit `}` while scanning a prelude — a trailing fragment before
263
+ // the block closes (e.g. a final declaration with no `;`).
264
+ if prelude.is_empty() {
265
+ return Ok(None);
266
+ }
267
+ if let Some((prop, value)) = split_declaration(&prelude) {
268
+ Ok(Some(StyleNode::Rule(StyleRule {
269
+ selector: String::new(),
270
+ declarations: vec![Declaration { prop, value }],
271
+ applies: Vec::new(),
272
+ nested: Vec::new(),
273
+ })))
274
+ } else if prelude.starts_with('@') {
275
+ Ok(Some(StyleNode::AtStatement(AtStatement { text: prelude })))
276
+ } else {
277
+ Ok(Some(StyleNode::Rule(StyleRule {
278
+ selector: prelude,
279
+ declarations: Vec::new(),
280
+ applies: Vec::new(),
281
+ nested: Vec::new(),
282
+ })))
283
+ }
284
+ }
285
+ }
286
+ }
287
+
288
+ /// Advance to the next top-level structural terminator (`{`, `;`, or `}`),
289
+ /// stepping over comments, strings, `(...)`, and `[...]` so their inner
290
+ /// `{`/`;`/`:` never count. Leaves `self.pos` AT the terminator.
291
+ fn scan_to_terminator(&mut self) -> Result<Terminator, StyleParseError> {
292
+ while self.pos < self.bytes.len() {
293
+ let b = self.bytes[self.pos];
294
+ match b {
295
+ b'/' if self.peek(1) == Some(b'*') => self.skip_block_comment()?,
296
+ b'"' | b'\'' => self.skip_string(b)?,
297
+ b'(' => self.skip_balanced(b'(', b')')?,
298
+ b'[' => self.skip_balanced(b'[', b']')?,
299
+ b'{' => return Ok(Terminator::Brace),
300
+ b';' => return Ok(Terminator::Semicolon),
301
+ b'}' => return Ok(Terminator::CloseBrace),
302
+ _ => self.pos += 1,
303
+ }
304
+ }
305
+ Ok(Terminator::Eof)
306
+ }
307
+
308
+ /// Skip whitespace and comments (used between statements).
309
+ fn skip_trivia(&mut self) -> Result<(), StyleParseError> {
310
+ loop {
311
+ while self.pos < self.bytes.len() && self.bytes[self.pos].is_ascii_whitespace() {
312
+ self.pos += 1;
313
+ }
314
+ if self.pos < self.bytes.len()
315
+ && self.bytes[self.pos] == b'/'
316
+ && self.peek(1) == Some(b'*')
317
+ {
318
+ self.skip_block_comment()?;
319
+ } else {
320
+ break;
321
+ }
322
+ }
323
+ Ok(())
324
+ }
325
+
326
+ fn skip_block_comment(&mut self) -> Result<(), StyleParseError> {
327
+ // self.pos is at `/`, next is `*`.
328
+ self.pos += 2;
329
+ while self.pos < self.bytes.len() {
330
+ if self.bytes[self.pos] == b'*' && self.peek(1) == Some(b'/') {
331
+ self.pos += 2;
332
+ return Ok(());
333
+ }
334
+ self.pos += 1;
335
+ }
336
+ Err(StyleParseError::UnterminatedComment)
337
+ }
338
+
339
+ fn skip_string(&mut self, quote: u8) -> Result<(), StyleParseError> {
340
+ // self.pos is at the opening quote.
341
+ self.pos += 1;
342
+ while self.pos < self.bytes.len() {
343
+ let b = self.bytes[self.pos];
344
+ if b == b'\\' {
345
+ // Skip the escaped byte.
346
+ self.pos += 2;
347
+ continue;
348
+ }
349
+ if b == quote {
350
+ self.pos += 1;
351
+ return Ok(());
352
+ }
353
+ self.pos += 1;
354
+ }
355
+ Err(StyleParseError::UnterminatedString)
356
+ }
357
+
358
+ /// Skip a balanced `(...)` / `[...]` span, honouring comments and strings
359
+ /// inside it so e.g. `url(")")` or `[data-x="}"]` do not confuse the scan.
360
+ fn skip_balanced(&mut self, open: u8, close: u8) -> Result<(), StyleParseError> {
361
+ let mut depth = 0u32;
362
+ while self.pos < self.bytes.len() {
363
+ let b = self.bytes[self.pos];
364
+ match b {
365
+ b'/' if self.peek(1) == Some(b'*') => {
366
+ self.skip_block_comment()?;
367
+ continue;
368
+ }
369
+ b'"' | b'\'' => {
370
+ self.skip_string(b)?;
371
+ continue;
372
+ }
373
+ _ if b == open => depth += 1,
374
+ _ if b == close => {
375
+ depth -= 1;
376
+ if depth == 0 {
377
+ self.pos += 1;
378
+ return Ok(());
379
+ }
380
+ }
381
+ _ => {}
382
+ }
383
+ self.pos += 1;
384
+ }
385
+ // Unbalanced parens/brackets: treat as unbalanced braces (structural).
386
+ Err(StyleParseError::UnbalancedBraces)
387
+ }
388
+
389
+ fn peek(&self, ahead: usize) -> Option<u8> {
390
+ self.bytes.get(self.pos + ahead).copied()
391
+ }
392
+ }
393
+
394
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
395
+ enum Terminator {
396
+ Brace,
397
+ Semicolon,
398
+ CloseBrace,
399
+ Eof,
400
+ }
401
+
402
+ /// Split a body's nodes into declarations / `@apply`s / nested nodes for a rule.
403
+ ///
404
+ /// The body was parsed as a flat `Vec<StyleNode>` by [`Parser::parse_nodes`];
405
+ /// declaration-bearing selector-less rules and at-statements are re-classified
406
+ /// here. `@apply …;` arrives as a `StyleNode::AtStatement` (it has no body), so
407
+ /// we lift those into [`StyleRule::applies`].
408
+ fn build_rule(selector: String, body: Vec<StyleNode>) -> StyleRule {
409
+ let mut declarations = Vec::new();
410
+ let mut applies = Vec::new();
411
+ let mut nested = Vec::new();
412
+
413
+ for node in body {
414
+ match node {
415
+ // A selector-less rule produced by a bare declaration in the body.
416
+ StyleNode::Rule(r) if r.selector.is_empty() && r.nested.is_empty() => {
417
+ declarations.extend(r.declarations);
418
+ applies.extend(r.applies);
419
+ }
420
+ StyleNode::AtStatement(at) => {
421
+ if let Some(directive) = parse_apply(&at.text) {
422
+ applies.push(directive);
423
+ } else {
424
+ // A non-@apply statement at-rule inside a body (rare) — keep
425
+ // it as a nested node so nothing is dropped.
426
+ nested.push(StyleNode::AtStatement(at));
427
+ }
428
+ }
429
+ other => nested.push(other),
430
+ }
431
+ }
432
+
433
+ StyleRule {
434
+ selector,
435
+ declarations,
436
+ applies,
437
+ nested,
438
+ }
439
+ }
440
+
441
+ /// If `text` is an `@apply <tokens>` directive, return the parsed tokens.
442
+ fn parse_apply(text: &str) -> Option<ApplyDirective> {
443
+ let rest = text.strip_prefix("@apply")?;
444
+ // Ensure it's the `@apply` keyword, not `@applyfoo`.
445
+ if !rest.is_empty() && !rest.as_bytes()[0].is_ascii_whitespace() {
446
+ return None;
447
+ }
448
+ let tokens: Vec<String> = rest.split_whitespace().map(|t| t.to_string()).collect();
449
+ Some(ApplyDirective { tokens })
450
+ }
451
+
452
+ /// Split `@name rest` into (`@name`, `rest`) where `@name` is the at-keyword.
453
+ fn split_at_name(prelude: &str) -> (String, String) {
454
+ let end = prelude
455
+ .char_indices()
456
+ .find(|(_, c)| c.is_whitespace() || *c == '(')
457
+ .map(|(i, _)| i)
458
+ .unwrap_or(prelude.len());
459
+ let name = prelude[..end].to_string();
460
+ let rest = prelude[end..].trim().to_string();
461
+ (name, rest)
462
+ }
463
+
464
+ /// Split `prop: value` on the FIRST top-level `:` (none inside `(...)`/`[...]`
465
+ /// /strings). Returns `None` if there is no such `:` (not a declaration).
466
+ fn split_declaration(text: &str) -> Option<(String, String)> {
467
+ let bytes = text.as_bytes();
468
+ let mut i = 0;
469
+ let mut paren = 0u32;
470
+ let mut bracket = 0u32;
471
+ while i < bytes.len() {
472
+ let b = bytes[i];
473
+ match b {
474
+ b'"' | b'\'' => {
475
+ // Skip string.
476
+ let quote = b;
477
+ i += 1;
478
+ while i < bytes.len() {
479
+ if bytes[i] == b'\\' {
480
+ i += 2;
481
+ continue;
482
+ }
483
+ if bytes[i] == quote {
484
+ i += 1;
485
+ break;
486
+ }
487
+ i += 1;
488
+ }
489
+ continue;
490
+ }
491
+ b'(' => paren += 1,
492
+ b')' => paren = paren.saturating_sub(1),
493
+ b'[' => bracket += 1,
494
+ b']' => bracket = bracket.saturating_sub(1),
495
+ b':' if paren == 0 && bracket == 0 => {
496
+ let prop = text[..i].trim().to_string();
497
+ let value = text[i + 1..].trim().to_string();
498
+ if prop.is_empty() {
499
+ return None;
500
+ }
501
+ return Some((prop, value));
502
+ }
503
+ _ => {}
504
+ }
505
+ i += 1;
506
+ }
507
+ None
508
+ }
509
+
510
+ impl StyleSheet {
511
+ /// Render the rule tree back to CSS. Round-trips a parsed unchanged block to
512
+ /// an equivalent (re-formatted) stylesheet. Used by `@apply` after rewriting
513
+ /// directives, and by tests asserting round-trip fidelity.
514
+ pub fn to_css(&self) -> String {
515
+ let mut out = String::new();
516
+ for node in &self.nodes {
517
+ write_node(&mut out, node, 0);
518
+ }
519
+ out
520
+ }
521
+ }
522
+
523
+ fn write_node(out: &mut String, node: &StyleNode, depth: usize) {
524
+ let pad = " ".repeat(depth);
525
+ match node {
526
+ StyleNode::Rule(rule) => {
527
+ if rule.selector.is_empty()
528
+ && rule.nested.is_empty()
529
+ && rule.applies.is_empty()
530
+ && rule.declarations.len() == 1
531
+ {
532
+ // A root-level bare declaration: emit without a wrapping block.
533
+ let d = &rule.declarations[0];
534
+ out.push_str(&pad);
535
+ out.push_str(&d.prop);
536
+ out.push_str(": ");
537
+ out.push_str(&d.value);
538
+ out.push_str(";\n");
539
+ return;
540
+ }
541
+ out.push_str(&pad);
542
+ out.push_str(&rule.selector);
543
+ if !rule.selector.is_empty() {
544
+ out.push(' ');
545
+ }
546
+ out.push_str("{\n");
547
+ let inner = " ".repeat(depth + 1);
548
+ for d in &rule.declarations {
549
+ out.push_str(&inner);
550
+ out.push_str(&d.prop);
551
+ out.push_str(": ");
552
+ out.push_str(&d.value);
553
+ out.push_str(";\n");
554
+ }
555
+ for a in &rule.applies {
556
+ out.push_str(&inner);
557
+ out.push_str("@apply ");
558
+ out.push_str(&a.tokens.join(" "));
559
+ out.push_str(";\n");
560
+ }
561
+ for n in &rule.nested {
562
+ write_node(out, n, depth + 1);
563
+ }
564
+ out.push_str(&pad);
565
+ out.push_str("}\n");
566
+ }
567
+ StyleNode::AtRule(at) => {
568
+ out.push_str(&pad);
569
+ out.push_str(&at.name);
570
+ if !at.prelude.is_empty() {
571
+ out.push(' ');
572
+ out.push_str(&at.prelude);
573
+ }
574
+ out.push_str(" {\n");
575
+ for n in &at.body {
576
+ write_node(out, n, depth + 1);
577
+ }
578
+ out.push_str(&pad);
579
+ out.push_str("}\n");
580
+ }
581
+ StyleNode::AtStatement(at) => {
582
+ out.push_str(&pad);
583
+ out.push_str(&at.text);
584
+ out.push_str(";\n");
585
+ }
586
+ }
587
+ }
@@ -70,6 +70,20 @@ impl ThemeRegistry {
70
70
  }
71
71
  }
72
72
 
73
+ /// Resolve a container-query breakpoint (`@sm`/`@md`/…) to its min-width.
74
+ /// Mirrors [`breakpoint`] but uses Tailwind's container-query scale (which
75
+ /// differs from the viewport breakpoint scale): `@sm`=24rem … `@2xl`=42rem.
76
+ pub fn container_breakpoint(&self, name: &str) -> Option<&'static str> {
77
+ match name {
78
+ "sm" => Some("24rem"),
79
+ "md" => Some("28rem"),
80
+ "lg" => Some("32rem"),
81
+ "xl" => Some("36rem"),
82
+ "2xl" => Some("42rem"),
83
+ _ => None,
84
+ }
85
+ }
86
+
73
87
  /// Merge an `@theme { ... }` block's tokens over the current registry.
74
88
  /// Returns the number of tokens registered/overridden.
75
89
  pub fn apply_theme_block(&mut self, theme_body: &str) -> usize {