@aihu/css-engine 0.1.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 (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +122 -0
  3. package/crates/aihu-css-core/Cargo.toml +22 -0
  4. package/crates/aihu-css-core/src/ast.rs +173 -0
  5. package/crates/aihu-css-core/src/bin/main.rs +73 -0
  6. package/crates/aihu-css-core/src/cache.rs +182 -0
  7. package/crates/aihu-css-core/src/emit.rs +236 -0
  8. package/crates/aihu-css-core/src/features/anchor.rs +41 -0
  9. package/crates/aihu-css-core/src/features/mod.rs +33 -0
  10. package/crates/aihu-css-core/src/features/popover.rs +40 -0
  11. package/crates/aihu-css-core/src/features/text_balance.rs +36 -0
  12. package/crates/aihu-css-core/src/features/view_transition.rs +38 -0
  13. package/crates/aihu-css-core/src/lib.rs +67 -0
  14. package/crates/aihu-css-core/src/progressive.rs +200 -0
  15. package/crates/aihu-css-core/src/scanner.rs +235 -0
  16. package/crates/aihu-css-core/src/theme.rs +179 -0
  17. package/crates/aihu-css-core/src/tokens.rs +470 -0
  18. package/crates/aihu-css-core/src/variants.rs +124 -0
  19. package/crates/aihu-css-core/tests/cache.rs +71 -0
  20. package/crates/aihu-css-core/tests/emit.rs +148 -0
  21. package/crates/aihu-css-core/tests/fixtures/button.ast.json +19 -0
  22. package/crates/aihu-css-core/tests/progressive_snapshot.rs +102 -0
  23. package/crates/aihu-css-core/tests/scanner.rs +99 -0
  24. package/crates/aihu-css-core/tests/scoped_snapshot.rs +73 -0
  25. package/crates/aihu-css-core/tests/snapshot.rs +24 -0
  26. package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__anchor_snapshot.snap +26 -0
  27. package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__popover_snapshot.snap +26 -0
  28. package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__text_balance_snapshot.snap +23 -0
  29. package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__view_transition_snapshot.snap +25 -0
  30. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__flat_output_for_class_list.snap +6 -0
  31. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_output_for_sfc.snap +25 -0
  32. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_with_authored_style_block.snap +26 -0
  33. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_with_global_style_block.snap +24 -0
  34. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__standard_variants.snap +33 -0
  35. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__theme_default_vs_override.snap +45 -0
  36. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__wc_native_variants.snap +28 -0
  37. package/crates/aihu-css-core/tests/snapshots/snapshot__compiles_basic_class.snap +5 -0
  38. package/crates/aihu-css-core/tests/snapshots/snapshot__compiles_multiple_classes.snap +8 -0
  39. package/crates/aihu-css-core/tests/snapshots/tokens__arbitrary_values.snap +9 -0
  40. package/crates/aihu-css-core/tests/snapshots/tokens__category_borders.snap +8 -0
  41. package/crates/aihu-css-core/tests/snapshots/tokens__category_colors.snap +10 -0
  42. package/crates/aihu-css-core/tests/snapshots/tokens__category_effects.snap +8 -0
  43. package/crates/aihu-css-core/tests/snapshots/tokens__category_layout.snap +12 -0
  44. package/crates/aihu-css-core/tests/snapshots/tokens__category_spacing.snap +11 -0
  45. package/crates/aihu-css-core/tests/snapshots/tokens__category_typography.snap +11 -0
  46. package/crates/aihu-css-core/tests/tokens.rs +79 -0
  47. package/dist/index.d.ts +76 -0
  48. package/dist/index.d.ts.map +1 -0
  49. package/dist/index.js +120 -0
  50. package/dist/index.js.map +1 -0
  51. package/dist/runtime/cn.d.ts +14 -0
  52. package/dist/runtime/cn.d.ts.map +1 -0
  53. package/dist/runtime/cn.js +107 -0
  54. package/dist/runtime/cn.js.map +1 -0
  55. package/dist/runtime/progressive.d.ts +54 -0
  56. package/dist/runtime/progressive.d.ts.map +1 -0
  57. package/dist/runtime/progressive.js +132 -0
  58. package/dist/runtime/progressive.js.map +1 -0
  59. package/package.json +54 -0
  60. package/styles/aihu-default.css +73 -0
  61. package/styles/aihu-graphite.css +71 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Fellwork
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,122 @@
1
+ # @aihu/css-engine
2
+
3
+ > **Aihu** — agentic discovery and interaction, for human purpose.
4
+
5
+ aihu CSS engine — Tailwind v4 hard fork with WC-native scoped output.
6
+
7
+ Part of the **compiler + toolchain** layer of Aihu. Build-time only — does not ship to the client. The compiler reads `.aihu` SFC source (per the [Block Structure spec](../../docs/superpowers/specs/2026-05-02-spec-block-structure.md)) and emits standards-compliant Web Components.
8
+
9
+ <!-- BEGIN_HANDWRITTEN: prose -->
10
+ > aihu CSS engine — a hard fork of Tailwind v4 with Web-Component-native scoped output, AST-aware scanning, and progressive feature emission.
11
+
12
+ **Status:** v0 — bootstrap. The fork's identity, perf optimizations, and scoped emitter are all under construction. See [`docs/superpowers/specs/2026-05-10-aihu-css-engine-and-primitives-design.md`](../../docs/superpowers/specs/2026-05-10-aihu-css-engine-and-primitives-design.md) for the full design.
13
+
14
+ ### Status by capability (Plan 1 bootstrap)
15
+
16
+ | Capability | Plan that lands it |
17
+ |---|---|
18
+ | Package builds; compile pipeline scaffolded | **Plan 1 (this one)** |
19
+ | AST scanner consuming `@aihu/compiler` | Plan 2 |
20
+ | Scoped-output mode (`:host` embedding) | Plan 2 |
21
+ | WC-native variants (`host:`, `slotted:`, `part-*:`) | Plan 2 |
22
+ | Progressive features (`view-transition:`, `anchor:`, etc.) | Plan 3 |
23
+ | Style packs (`aihu-default`, `aihu-graphite`) | Plan 3 |
24
+ | `cn()` runtime helper | Plan 3 |
25
+
26
+ ### Local development
27
+
28
+ ```bash
29
+ # Build Rust core (run from repo root or this dir)
30
+ cargo build --release -p aihu-css-core
31
+
32
+ # Build TS layer
33
+ bun run build
34
+
35
+ # Run tests
36
+ bun run test # vitest e2e
37
+ bun run test:rust # cargo + insta snapshots
38
+ ```
39
+ <!-- END_HANDWRITTEN: prose -->
40
+
41
+ ## Install
42
+
43
+ <!-- BEGIN_AUTOGEN: install -->
44
+ <!-- regenerate: bun scripts/sync-readme.ts (also runs in pre-commit + CI) -->
45
+
46
+ ```bash
47
+ npm install @aihu/css-engine
48
+ # or
49
+ bun add @aihu/css-engine
50
+ ```
51
+
52
+ <sub><i>Auto-generated against `@aihu/css-engine@0.1.0`.</i></sub>
53
+
54
+ <!-- END_AUTOGEN: install -->
55
+
56
+ ## Package facts
57
+
58
+ <!-- BEGIN_AUTOGEN: stats -->
59
+ <!-- regenerate: bun scripts/sync-readme.ts (also runs in pre-commit + CI) -->
60
+
61
+ | | |
62
+ |---|---|
63
+ | **Version** | `0.1.0` |
64
+ | **Tier** | D — Compiler — CSS engine (Tailwind v4 hard fork, WC-native scoped output) |
65
+ | **Published files** | 5 entries |
66
+ | **License** | MIT |
67
+
68
+ <sub><i>Auto-generated against `@aihu/css-engine@0.1.0`.</i></sub>
69
+
70
+ <!-- END_AUTOGEN: stats -->
71
+
72
+ ## Exports
73
+
74
+ <!-- BEGIN_AUTOGEN: exports -->
75
+ <!-- regenerate: bun scripts/sync-readme.ts (also runs in pre-commit + CI) -->
76
+
77
+ | Subpath | ESM | CJS |
78
+ |---|---|---|
79
+ | `.` | `./dist/index.js` | `—` |
80
+ | `./runtime/cn` | `./dist/runtime/cn.js` | `—` |
81
+ | `./runtime/progressive` | `./dist/runtime/progressive.js` | `—` |
82
+
83
+ <sub><i>Auto-generated against `@aihu/css-engine@0.1.0`.</i></sub>
84
+
85
+ <!-- END_AUTOGEN: exports -->
86
+
87
+ ## Dependencies
88
+
89
+ <!-- BEGIN_AUTOGEN: deps -->
90
+ <!-- regenerate: bun scripts/sync-readme.ts (also runs in pre-commit + CI) -->
91
+
92
+ **Dependencies:**
93
+
94
+ - `@aihu/compiler` — `workspace:*`
95
+
96
+ <sub><i>Auto-generated against `@aihu/css-engine@0.1.0`.</i></sub>
97
+
98
+ <!-- END_AUTOGEN: deps -->
99
+
100
+ ## See also
101
+
102
+ <!-- BEGIN_AUTOGEN: see-also -->
103
+ <!-- regenerate: bun scripts/sync-readme.ts (also runs in pre-commit + CI) -->
104
+
105
+ - [CSS Engine + Primitives design spec](../../docs/superpowers/specs/2026-05-10-aihu-css-engine-and-primitives-design.md)
106
+ - [@aihu/compiler](../compiler)
107
+ - [Aihu framework root](../../README.md)
108
+
109
+ <sub><i>Auto-generated against `@aihu/css-engine@0.1.0`.</i></sub>
110
+
111
+ <!-- END_AUTOGEN: see-also -->
112
+
113
+ ## License
114
+
115
+ <!-- BEGIN_AUTOGEN: license -->
116
+ <!-- regenerate: bun scripts/sync-readme.ts (also runs in pre-commit + CI) -->
117
+
118
+ MIT — see [LICENSE](../../LICENSE).
119
+
120
+ <sub><i>Auto-generated against `@aihu/css-engine@0.1.0`.</i></sub>
121
+
122
+ <!-- END_AUTOGEN: license -->
@@ -0,0 +1,22 @@
1
+ [package]
2
+ name = "aihu-css-core"
3
+ version = "0.0.0"
4
+ edition.workspace = true
5
+ license.workspace = true
6
+ repository.workspace = true
7
+ description = "aihu CSS engine core — utility compilation and CSS emission."
8
+
9
+ [lib]
10
+ crate-type = ["cdylib", "rlib"]
11
+ path = "src/lib.rs"
12
+
13
+ [[bin]]
14
+ name = "aihu-css-compile"
15
+ path = "src/bin/main.rs"
16
+
17
+ [dependencies]
18
+ serde = { version = "1", features = ["derive"] }
19
+ serde_json = "1"
20
+
21
+ [dev-dependencies]
22
+ insta = { version = "1", features = ["yaml"] }
@@ -0,0 +1,173 @@
1
+ //! `ast.rs` — serde `Deserialize` mirror of the compiler's `--ast-json` output.
2
+ //!
3
+ //! This is the wire-format contract the scanner consumes. It mirrors the
4
+ //! `SfcAst` shape emitted by `@aihu/compiler`'s `compile_to_ast` /
5
+ //! `aihu-compile --ast-json` (see `docs/superpowers/specs/compiler-ast-export-hook.md`
6
+ //! §4.1 / §4.3, CSS-engine spec `22d3a66e` §3 edge #1).
7
+ //!
8
+ //! The three `SfcAttr` variants (Static / Binding / Macro) are frozen as part
9
+ //! of the v1.0 stability contract — the scanner's class-extraction correctness
10
+ //! depends entirely on them staying distinct. `ast_version` is asserted on
11
+ //! entry (§6 Q3 evolution policy): additive changes keep `1`; a breaking shape
12
+ //! change bumps it and is rejected here with a clear error.
13
+
14
+ use serde::Deserialize;
15
+
16
+ /// The AST schema version this scanner understands. A mismatch is rejected by
17
+ /// [`parse_ast`].
18
+ pub const SUPPORTED_AST_VERSION: u32 = 1;
19
+
20
+ /// Top-level AST export — one per `.aihu` SFC. Mirrors the compiler's
21
+ /// `SfcAstOwned` / the TS `SfcAst` interface (spec §4.1).
22
+ #[derive(Debug, Clone, PartialEq, Deserialize)]
23
+ pub struct SfcAst {
24
+ /// Resolved custom-element tag name (`meta.name` → `route.name` → file stem).
25
+ pub tag: String,
26
+ /// The `@style` block, if the SFC declared one.
27
+ #[serde(default)]
28
+ pub style: Option<SfcStyleBlock>,
29
+ /// Parsed template tree. `None` when the SFC has no `@template` block.
30
+ #[serde(default)]
31
+ pub template: Option<Vec<SfcNode>>,
32
+ /// SFC-level metadata.
33
+ pub meta: SfcMeta,
34
+ /// AST schema version — bumped on any breaking shape change.
35
+ #[serde(rename = "astVersion")]
36
+ pub ast_version: u32,
37
+ }
38
+
39
+ #[derive(Debug, Clone, PartialEq, Deserialize)]
40
+ pub struct SfcStyleBlock {
41
+ /// Verbatim CSS body of the `@style` block (braces stripped, `$global`
42
+ /// token removed by the compiler).
43
+ pub content: String,
44
+ /// `Scoped` (default) or `Global` (`@style { $global ... }`).
45
+ pub scope: SfcStyleScope,
46
+ }
47
+
48
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
49
+ #[serde(rename_all = "lowercase")]
50
+ pub enum SfcStyleScope {
51
+ Scoped,
52
+ Global,
53
+ }
54
+
55
+ #[derive(Debug, Clone, PartialEq, Deserialize)]
56
+ pub struct SfcMeta {
57
+ /// From `@meta { name }` / `@route { name }` / file stem.
58
+ pub name: String,
59
+ }
60
+
61
+ /// Discriminated union mirroring the compiler's `SfcNodeOwned` (spec §4.1).
62
+ /// Serialized with a `kind` tag (camelCase: `element`, `macroElement`, …).
63
+ #[derive(Debug, Clone, PartialEq, Deserialize)]
64
+ #[serde(tag = "kind", rename_all = "camelCase")]
65
+ pub enum SfcNode {
66
+ Element {
67
+ tag: String,
68
+ #[serde(default)]
69
+ attrs: Vec<SfcAttr>,
70
+ #[serde(default)]
71
+ children: Vec<SfcNode>,
72
+ },
73
+ MacroElement {
74
+ name: String,
75
+ #[serde(default)]
76
+ attrs: Vec<SfcAttr>,
77
+ #[serde(default)]
78
+ children: Vec<SfcNode>,
79
+ },
80
+ Text {
81
+ value: String,
82
+ },
83
+ Interpolation {
84
+ expr: String,
85
+ },
86
+ IfBlock {
87
+ #[serde(default)]
88
+ branches: Vec<SfcIfBranch>,
89
+ },
90
+ #[serde(rename_all = "camelCase")]
91
+ EachBlock {
92
+ list: String,
93
+ item: String,
94
+ idx: Option<String>,
95
+ key: Option<String>,
96
+ #[serde(default)]
97
+ body: Vec<SfcNode>,
98
+ empty_body: Option<Vec<SfcNode>>,
99
+ },
100
+ HtmlBlock {
101
+ expr: String,
102
+ },
103
+ }
104
+
105
+ #[derive(Debug, Clone, PartialEq, Deserialize)]
106
+ pub struct SfcIfBranch {
107
+ pub cond: String,
108
+ #[serde(default)]
109
+ pub body: Vec<SfcNode>,
110
+ }
111
+
112
+ /// Discriminated union mirroring the compiler's `SfcAttrOwned` `Attr` — the
113
+ /// three class-forms key on `kind` (lowercase: `static` / `binding` / `macro`).
114
+ #[derive(Debug, Clone, PartialEq, Deserialize)]
115
+ #[serde(tag = "kind", rename_all = "lowercase")]
116
+ pub enum SfcAttr {
117
+ /// Form A — `class="btn primary"`.
118
+ Static { name: String, value: String },
119
+ /// Form B — `$class={expr}` (and array `$class={[a, b]}`).
120
+ Binding { name: String, expr: String },
121
+ /// Form C — `$class:active={cond}` (and `on:`/`bind:`/`emit:`/`if`/…).
122
+ Macro {
123
+ name: String,
124
+ #[allow(dead_code)]
125
+ value: SfcMacroValue,
126
+ },
127
+ }
128
+
129
+ #[derive(Debug, Clone, PartialEq, Deserialize)]
130
+ #[serde(tag = "form", rename_all = "lowercase")]
131
+ pub enum SfcMacroValue {
132
+ Quoted { value: String },
133
+ Curly { expr: String },
134
+ Boolean,
135
+ }
136
+
137
+ /// Parse a `--ast-json` payload into the typed [`SfcAst`], validating the
138
+ /// schema version (spec §6 Q3). A version mismatch returns a descriptive
139
+ /// error rather than silently mis-scanning a future shape.
140
+ pub fn parse_ast(json: &str) -> Result<SfcAst, AstError> {
141
+ let ast: SfcAst = serde_json::from_str(json).map_err(AstError::Deserialize)?;
142
+ if ast.ast_version != SUPPORTED_AST_VERSION {
143
+ return Err(AstError::Version {
144
+ found: ast.ast_version,
145
+ supported: SUPPORTED_AST_VERSION,
146
+ });
147
+ }
148
+ Ok(ast)
149
+ }
150
+
151
+ /// Errors from [`parse_ast`].
152
+ #[derive(Debug)]
153
+ pub enum AstError {
154
+ /// JSON did not match the `SfcAst` wire shape.
155
+ Deserialize(serde_json::Error),
156
+ /// `astVersion` is not the version this scanner supports.
157
+ Version { found: u32, supported: u32 },
158
+ }
159
+
160
+ impl std::fmt::Display for AstError {
161
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
162
+ match self {
163
+ AstError::Deserialize(e) => write!(f, "failed to deserialize SfcAst: {e}"),
164
+ AstError::Version { found, supported } => write!(
165
+ f,
166
+ "unsupported astVersion {found} (this engine supports {supported}); \
167
+ rebuild @aihu/compiler and @aihu/css-engine to matching versions"
168
+ ),
169
+ }
170
+ }
171
+ }
172
+
173
+ impl std::error::Error for AstError {}
@@ -0,0 +1,73 @@
1
+ //! aihu-css-compile — CLI entry for the engine.
2
+ //!
3
+ //! Two input modes:
4
+ //!
5
+ //! - **Class-list mode** (Plan 1, default): reads utility class names (one per
6
+ //! line / whitespace-separated) from stdin or a file argument, emits flat
7
+ //! `.class { … }` CSS to stdout.
8
+ //! - **AST mode** (Plan 2, `--ast-json`): reads an `SfcAst` JSON (the output of
9
+ //! `aihu-compile --ast-json`) from stdin, emits scoped shadow-DOM CSS.
10
+
11
+ use std::io::{self, Read, Write};
12
+
13
+ fn main() {
14
+ let argv: Vec<String> = std::env::args().collect();
15
+
16
+ // Build-time mode: dump the conflict-group map (Plan 3 Task 9). The TS build
17
+ // step (`scripts/gen-cn-conflict-map.ts`) reads this to generate the `cn()`
18
+ // conflict map, so the runtime merge map never drifts from the utility table.
19
+ if argv.iter().any(|a| a == "--dump-conflict-groups") {
20
+ let groups = aihu_css_core::tokens::conflict_groups();
21
+ let json = serde_json::to_string(&groups).expect("serialize conflict groups");
22
+ println!("{json}");
23
+ return;
24
+ }
25
+
26
+ let ast_mode = argv.iter().any(|a| a == "--ast-json");
27
+
28
+ // Positional file arg = first non-flag argument after argv[0].
29
+ let file_arg = argv
30
+ .iter()
31
+ .skip(1)
32
+ .find(|a| !a.starts_with("--"))
33
+ .cloned();
34
+
35
+ let mut buf = String::new();
36
+ match &file_arg {
37
+ Some(path) => {
38
+ buf = std::fs::read_to_string(path).unwrap_or_else(|e| {
39
+ eprintln!("aihu-css-compile: failed to read {path}: {e}");
40
+ std::process::exit(2);
41
+ });
42
+ }
43
+ None => {
44
+ io::stdin()
45
+ .read_to_string(&mut buf)
46
+ .expect("failed reading stdin");
47
+ }
48
+ }
49
+
50
+ let css = if ast_mode {
51
+ match aihu_css_core::parse_ast(&buf) {
52
+ Ok(ast) => aihu_css_core::compile_sfc_scoped(&ast),
53
+ Err(e) => {
54
+ eprintln!("aihu-css-compile: {e}");
55
+ std::process::exit(1);
56
+ }
57
+ }
58
+ } else {
59
+ let classes: Vec<String> = buf
60
+ .lines()
61
+ .flat_map(|line| line.split_whitespace())
62
+ .filter(|s| !s.is_empty())
63
+ .map(String::from)
64
+ .collect();
65
+ aihu_css_core::compile_classes(&classes)
66
+ };
67
+
68
+ let stdout = io::stdout();
69
+ let mut handle = stdout.lock();
70
+ handle
71
+ .write_all(css.as_bytes())
72
+ .expect("failed writing stdout");
73
+ }
@@ -0,0 +1,182 @@
1
+ //! `cache.rs` — AST-hashed per-SFC incremental compilation cache (Plan 2 Task 8).
2
+ //!
3
+ //! Keyed by a stable hash over `(SfcAst, ThemeRegistry version)`: a change to
4
+ //! either input invalidates the entry. The hash is a fast non-cryptographic
5
+ //! `DefaultHasher` — we want change detection, not security. The cache lives
6
+ //! in-process (the Vite plugin / `aihu css build` holds it across the dev
7
+ //! session) so an unchanged SFC recompiles in near-zero time (the `css-6`
8
+ //! perf gate later asserts < 30 ms across a 50-SFC fixture).
9
+
10
+ use std::collections::HashMap;
11
+ use std::hash::{Hash, Hasher};
12
+
13
+ use crate::ast::{SfcAst, SfcAttr, SfcNode, SfcStyleScope};
14
+ use crate::emit::emit_sfc_scoped;
15
+
16
+ /// An in-process compilation cache. Construct one per dev session / build run.
17
+ #[derive(Debug, Default)]
18
+ pub struct CssCache {
19
+ entries: HashMap<u64, String>,
20
+ /// Number of full recompiles performed — used by tests/benches to prove a
21
+ /// cache hit skipped the compile path.
22
+ recompiles: u64,
23
+ /// Number of cache hits served.
24
+ hits: u64,
25
+ }
26
+
27
+ impl CssCache {
28
+ pub fn new() -> Self {
29
+ Self::default()
30
+ }
31
+
32
+ /// Compile an SFC, returning a cached result on an unchanged-input hit.
33
+ /// `theme_version` participates in the key so a theme change invalidates
34
+ /// every entry.
35
+ pub fn compile(&mut self, ast: &SfcAst, theme_version: u64) -> String {
36
+ let key = hash_ast(ast, theme_version);
37
+ if let Some(cached) = self.entries.get(&key) {
38
+ self.hits += 1;
39
+ return cached.clone();
40
+ }
41
+ self.recompiles += 1;
42
+ let css = emit_sfc_scoped(ast);
43
+ self.entries.insert(key, css.clone());
44
+ css
45
+ }
46
+
47
+ /// Total full recompiles since construction.
48
+ pub fn recompiles(&self) -> u64 {
49
+ self.recompiles
50
+ }
51
+
52
+ /// Total cache hits since construction.
53
+ pub fn hits(&self) -> u64 {
54
+ self.hits
55
+ }
56
+
57
+ /// Number of distinct entries currently cached.
58
+ pub fn len(&self) -> usize {
59
+ self.entries.len()
60
+ }
61
+
62
+ pub fn is_empty(&self) -> bool {
63
+ self.entries.is_empty()
64
+ }
65
+
66
+ /// Drop all cached entries (keeps the hit/recompile counters).
67
+ pub fn clear(&mut self) {
68
+ self.entries.clear();
69
+ }
70
+ }
71
+
72
+ /// Compute a stable change-detection hash over the AST + theme version.
73
+ pub fn hash_ast(ast: &SfcAst, theme_version: u64) -> u64 {
74
+ let mut h = std::collections::hash_map::DefaultHasher::new();
75
+ theme_version.hash(&mut h);
76
+ hash_sfc(ast, &mut h);
77
+ h.finish()
78
+ }
79
+
80
+ fn hash_sfc(ast: &SfcAst, h: &mut impl Hasher) {
81
+ ast.tag.hash(h);
82
+ ast.ast_version.hash(h);
83
+ match &ast.style {
84
+ Some(s) => {
85
+ 1u8.hash(h);
86
+ s.content.hash(h);
87
+ matches!(s.scope, SfcStyleScope::Global).hash(h);
88
+ }
89
+ None => 0u8.hash(h),
90
+ }
91
+ match &ast.template {
92
+ Some(nodes) => {
93
+ 1u8.hash(h);
94
+ for n in nodes {
95
+ hash_node(n, h);
96
+ }
97
+ }
98
+ None => 0u8.hash(h),
99
+ }
100
+ }
101
+
102
+ fn hash_node(node: &SfcNode, h: &mut impl Hasher) {
103
+ match node {
104
+ SfcNode::Element { tag, attrs, children } => {
105
+ 0u8.hash(h);
106
+ tag.hash(h);
107
+ for a in attrs {
108
+ hash_attr(a, h);
109
+ }
110
+ for c in children {
111
+ hash_node(c, h);
112
+ }
113
+ }
114
+ SfcNode::MacroElement { name, attrs, children } => {
115
+ 1u8.hash(h);
116
+ name.hash(h);
117
+ for a in attrs {
118
+ hash_attr(a, h);
119
+ }
120
+ for c in children {
121
+ hash_node(c, h);
122
+ }
123
+ }
124
+ SfcNode::Text { value } => {
125
+ 2u8.hash(h);
126
+ value.hash(h);
127
+ }
128
+ SfcNode::Interpolation { expr } => {
129
+ 3u8.hash(h);
130
+ expr.hash(h);
131
+ }
132
+ SfcNode::IfBlock { branches } => {
133
+ 4u8.hash(h);
134
+ for b in branches {
135
+ b.cond.hash(h);
136
+ for c in &b.body {
137
+ hash_node(c, h);
138
+ }
139
+ }
140
+ }
141
+ SfcNode::EachBlock { list, item, idx, key, body, empty_body } => {
142
+ 5u8.hash(h);
143
+ list.hash(h);
144
+ item.hash(h);
145
+ idx.hash(h);
146
+ key.hash(h);
147
+ for c in body {
148
+ hash_node(c, h);
149
+ }
150
+ if let Some(eb) = empty_body {
151
+ for c in eb {
152
+ hash_node(c, h);
153
+ }
154
+ }
155
+ }
156
+ SfcNode::HtmlBlock { expr } => {
157
+ 6u8.hash(h);
158
+ expr.hash(h);
159
+ }
160
+ }
161
+ }
162
+
163
+ fn hash_attr(attr: &SfcAttr, h: &mut impl Hasher) {
164
+ // Only the class-bearing attrs affect CSS output, but hashing all attrs is
165
+ // cheap and keeps the key robust against future emit changes.
166
+ match attr {
167
+ SfcAttr::Static { name, value } => {
168
+ 0u8.hash(h);
169
+ name.hash(h);
170
+ value.hash(h);
171
+ }
172
+ SfcAttr::Binding { name, expr } => {
173
+ 1u8.hash(h);
174
+ name.hash(h);
175
+ expr.hash(h);
176
+ }
177
+ SfcAttr::Macro { name, .. } => {
178
+ 2u8.hash(h);
179
+ name.hash(h);
180
+ }
181
+ }
182
+ }