@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.
- package/LICENSE +21 -0
- package/README.md +122 -0
- package/crates/aihu-css-core/Cargo.toml +22 -0
- package/crates/aihu-css-core/src/ast.rs +173 -0
- package/crates/aihu-css-core/src/bin/main.rs +73 -0
- package/crates/aihu-css-core/src/cache.rs +182 -0
- package/crates/aihu-css-core/src/emit.rs +236 -0
- package/crates/aihu-css-core/src/features/anchor.rs +41 -0
- package/crates/aihu-css-core/src/features/mod.rs +33 -0
- package/crates/aihu-css-core/src/features/popover.rs +40 -0
- package/crates/aihu-css-core/src/features/text_balance.rs +36 -0
- package/crates/aihu-css-core/src/features/view_transition.rs +38 -0
- package/crates/aihu-css-core/src/lib.rs +67 -0
- package/crates/aihu-css-core/src/progressive.rs +200 -0
- package/crates/aihu-css-core/src/scanner.rs +235 -0
- package/crates/aihu-css-core/src/theme.rs +179 -0
- package/crates/aihu-css-core/src/tokens.rs +470 -0
- package/crates/aihu-css-core/src/variants.rs +124 -0
- package/crates/aihu-css-core/tests/cache.rs +71 -0
- package/crates/aihu-css-core/tests/emit.rs +148 -0
- package/crates/aihu-css-core/tests/fixtures/button.ast.json +19 -0
- package/crates/aihu-css-core/tests/progressive_snapshot.rs +102 -0
- package/crates/aihu-css-core/tests/scanner.rs +99 -0
- package/crates/aihu-css-core/tests/scoped_snapshot.rs +73 -0
- package/crates/aihu-css-core/tests/snapshot.rs +24 -0
- package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__anchor_snapshot.snap +26 -0
- package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__popover_snapshot.snap +26 -0
- package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__text_balance_snapshot.snap +23 -0
- package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__view_transition_snapshot.snap +25 -0
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__flat_output_for_class_list.snap +6 -0
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_output_for_sfc.snap +25 -0
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_with_authored_style_block.snap +26 -0
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_with_global_style_block.snap +24 -0
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__standard_variants.snap +33 -0
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__theme_default_vs_override.snap +45 -0
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__wc_native_variants.snap +28 -0
- package/crates/aihu-css-core/tests/snapshots/snapshot__compiles_basic_class.snap +5 -0
- package/crates/aihu-css-core/tests/snapshots/snapshot__compiles_multiple_classes.snap +8 -0
- package/crates/aihu-css-core/tests/snapshots/tokens__arbitrary_values.snap +9 -0
- package/crates/aihu-css-core/tests/snapshots/tokens__category_borders.snap +8 -0
- package/crates/aihu-css-core/tests/snapshots/tokens__category_colors.snap +10 -0
- package/crates/aihu-css-core/tests/snapshots/tokens__category_effects.snap +8 -0
- package/crates/aihu-css-core/tests/snapshots/tokens__category_layout.snap +12 -0
- package/crates/aihu-css-core/tests/snapshots/tokens__category_spacing.snap +11 -0
- package/crates/aihu-css-core/tests/snapshots/tokens__category_typography.snap +11 -0
- package/crates/aihu-css-core/tests/tokens.rs +79 -0
- package/dist/index.d.ts +76 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +120 -0
- package/dist/index.js.map +1 -0
- package/dist/runtime/cn.d.ts +14 -0
- package/dist/runtime/cn.d.ts.map +1 -0
- package/dist/runtime/cn.js +107 -0
- package/dist/runtime/cn.js.map +1 -0
- package/dist/runtime/progressive.d.ts +54 -0
- package/dist/runtime/progressive.d.ts.map +1 -0
- package/dist/runtime/progressive.js +132 -0
- package/dist/runtime/progressive.js.map +1 -0
- package/package.json +54 -0
- package/styles/aihu-default.css +73 -0
- package/styles/aihu-graphite.css +71 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
//! `progressive.rs` — `ProgressiveFeature` trait + registry + `@supports` emitter.
|
|
2
|
+
//!
|
|
3
|
+
//! Plan 3 Task 4. A *progressive feature* is a forward-looking CSS feature
|
|
4
|
+
//! gated behind `@supports`, optionally with a JS runtime fallback. The engine
|
|
5
|
+
//! ships four built-ins (`view_transition`, `anchor`, `popover`, `text_balance`
|
|
6
|
+
//! — see `features/`), but the trait is open so additional features can be
|
|
7
|
+
//! registered.
|
|
8
|
+
//!
|
|
9
|
+
//! ## Fallback contract
|
|
10
|
+
//!
|
|
11
|
+
//! Each feature owns four facts:
|
|
12
|
+
//! - its **variant prefix** (`view-transition`, `anchor`, `popover`, `text-balance`)
|
|
13
|
+
//! - the **`@supports` condition** (or `None` = "always emit, silently ignored
|
|
14
|
+
//! if unsupported" — no gate at all)
|
|
15
|
+
//! - the **gated CSS** it emits for a given base utility/declaration
|
|
16
|
+
//! - whether it dispatches a **JS fallback** (`Some(export-name)`) or is
|
|
17
|
+
//! **CSS-only** (`None`)
|
|
18
|
+
//!
|
|
19
|
+
//! The emitter wraps gated CSS in `@supports (...)` when a condition is present,
|
|
20
|
+
//! and emits a small `/* aihu:progressive-fallback ... */` marker (read by the
|
|
21
|
+
//! TS layer to wire `@aihu/css-engine/runtime/progressive`) ONLY for features
|
|
22
|
+
//! whose `js_fallback()` is non-`None`. CSS-only features (`view-transition:`,
|
|
23
|
+
//! `text-balance:`) never produce a JS marker.
|
|
24
|
+
|
|
25
|
+
/// A forward-looking CSS feature gated behind `@supports`, optionally with a
|
|
26
|
+
/// JS runtime fallback. Each feature owns: its variant prefix, the `@supports`
|
|
27
|
+
/// condition, the gated CSS it emits, and whether it dispatches a JS fallback.
|
|
28
|
+
pub trait ProgressiveFeature {
|
|
29
|
+
/// The variant prefix, e.g. "view-transition", "anchor", "popover", "text-balance".
|
|
30
|
+
fn prefix(&self) -> &'static str;
|
|
31
|
+
/// The `@supports(...)` condition string, or None for "always emit, silently ignored if unsupported".
|
|
32
|
+
fn supports_condition(&self) -> Option<&'static str>;
|
|
33
|
+
/// Emit the gated CSS for a given base utility/declaration.
|
|
34
|
+
fn emit_css(&self, base: &str) -> String;
|
|
35
|
+
/// Runtime fallback descriptor: which `@aihu/css-engine/runtime/progressive`
|
|
36
|
+
/// export to dispatch when `@supports` fails. None = silent CSS no-op (no JS).
|
|
37
|
+
fn js_fallback(&self) -> Option<&'static str>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/// A registry of progressive features, keyed by variant prefix. The emitter
|
|
41
|
+
/// consults it when it encounters a variant prefix that names a registered
|
|
42
|
+
/// feature, routing to the progressive emitter instead of the standard
|
|
43
|
+
/// selector path.
|
|
44
|
+
pub struct ProgressiveRegistry {
|
|
45
|
+
features: Vec<Box<dyn ProgressiveFeature + Send + Sync>>,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
impl ProgressiveRegistry {
|
|
49
|
+
/// An empty registry.
|
|
50
|
+
pub fn new() -> Self {
|
|
51
|
+
Self {
|
|
52
|
+
features: Vec::new(),
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/// The default registry seeded with the four built-in features. Features are
|
|
57
|
+
/// added to this constructor as they land (Tasks 5–8): `view-transition:`,
|
|
58
|
+
/// `anchor:`, `popover:`, `text-balance:`.
|
|
59
|
+
pub fn with_builtins() -> Self {
|
|
60
|
+
let mut r = Self::new();
|
|
61
|
+
crate::features::register_builtins(&mut r);
|
|
62
|
+
r
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/// Register a feature.
|
|
66
|
+
pub fn register(&mut self, feature: Box<dyn ProgressiveFeature + Send + Sync>) {
|
|
67
|
+
self.features.push(feature);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/// Look up a feature by its variant prefix.
|
|
71
|
+
pub fn get(&self, prefix: &str) -> Option<&(dyn ProgressiveFeature + Send + Sync)> {
|
|
72
|
+
self.features
|
|
73
|
+
.iter()
|
|
74
|
+
.find(|f| f.prefix() == prefix)
|
|
75
|
+
.map(|f| f.as_ref())
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/// True if `prefix` names a registered progressive feature.
|
|
79
|
+
pub fn is_feature(&self, prefix: &str) -> bool {
|
|
80
|
+
self.features.iter().any(|f| f.prefix() == prefix)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/// Emit the CSS (and optional JS-fallback marker) for a feature `prefix`
|
|
84
|
+
/// applied to `base`. Returns `None` if `prefix` is not registered.
|
|
85
|
+
///
|
|
86
|
+
/// Output shape:
|
|
87
|
+
/// - with a `@supports` condition: `@supports (<cond>) { <css> }`
|
|
88
|
+
/// - without a condition: the bare `<css>` (silently ignored if unsupported)
|
|
89
|
+
/// - plus, ONLY when `js_fallback()` is `Some`, a trailing
|
|
90
|
+
/// `/* aihu:progressive-fallback <export> (when not <cond>) */` marker.
|
|
91
|
+
pub fn emit(&self, prefix: &str, base: &str) -> Option<String> {
|
|
92
|
+
let feature = self.get(prefix)?;
|
|
93
|
+
Some(emit_feature(feature, base))
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
impl Default for ProgressiveRegistry {
|
|
98
|
+
fn default() -> Self {
|
|
99
|
+
Self::with_builtins()
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/// Emit the `@supports`-gated CSS (+ optional JS marker) for one feature.
|
|
104
|
+
pub fn emit_feature(feature: &(dyn ProgressiveFeature + Send + Sync), base: &str) -> String {
|
|
105
|
+
let css = feature.emit_css(base);
|
|
106
|
+
let mut out = match feature.supports_condition() {
|
|
107
|
+
Some(cond) => format!("@supports ({cond}) {{\n {css}\n}}\n"),
|
|
108
|
+
None => format!("{css}\n"),
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// JS fallback marker — ONLY for features with a non-None fallback. The TS
|
|
112
|
+
// layer scans for this marker to wire the runtime/progressive dispatch.
|
|
113
|
+
if let Some(export) = feature.js_fallback() {
|
|
114
|
+
let cond = feature.supports_condition().unwrap_or("");
|
|
115
|
+
out.push_str(&format!(
|
|
116
|
+
"/* aihu:progressive-fallback {export} (when not @supports {cond}) */\n"
|
|
117
|
+
));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
out
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
#[cfg(test)]
|
|
124
|
+
mod tests {
|
|
125
|
+
use super::*;
|
|
126
|
+
|
|
127
|
+
// A dummy feature with a @supports gate AND a JS fallback.
|
|
128
|
+
struct GatedWithJs;
|
|
129
|
+
impl ProgressiveFeature for GatedWithJs {
|
|
130
|
+
fn prefix(&self) -> &'static str {
|
|
131
|
+
"gated"
|
|
132
|
+
}
|
|
133
|
+
fn supports_condition(&self) -> Option<&'static str> {
|
|
134
|
+
Some("display: grid")
|
|
135
|
+
}
|
|
136
|
+
fn emit_css(&self, base: &str) -> String {
|
|
137
|
+
format!(".{base} {{ display: grid; }}")
|
|
138
|
+
}
|
|
139
|
+
fn js_fallback(&self) -> Option<&'static str> {
|
|
140
|
+
Some("gatedFallback")
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// A dummy CSS-only feature: no gate, no JS.
|
|
145
|
+
struct CssOnly;
|
|
146
|
+
impl ProgressiveFeature for CssOnly {
|
|
147
|
+
fn prefix(&self) -> &'static str {
|
|
148
|
+
"plain"
|
|
149
|
+
}
|
|
150
|
+
fn supports_condition(&self) -> Option<&'static str> {
|
|
151
|
+
None
|
|
152
|
+
}
|
|
153
|
+
fn emit_css(&self, base: &str) -> String {
|
|
154
|
+
format!(".{base} {{ color: red; }}")
|
|
155
|
+
}
|
|
156
|
+
fn js_fallback(&self) -> Option<&'static str> {
|
|
157
|
+
None
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
#[test]
|
|
162
|
+
fn supports_gate_wraps_css() {
|
|
163
|
+
let out = emit_feature(&GatedWithJs, "thing");
|
|
164
|
+
assert!(out.contains("@supports (display: grid)"), "gated CSS wrapped: {out}");
|
|
165
|
+
assert!(out.contains("display: grid;"));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
#[test]
|
|
169
|
+
fn js_fallback_feature_emits_marker() {
|
|
170
|
+
let out = emit_feature(&GatedWithJs, "thing");
|
|
171
|
+
assert!(
|
|
172
|
+
out.contains("aihu:progressive-fallback gatedFallback"),
|
|
173
|
+
"non-None fallback emits a JS marker: {out}"
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
#[test]
|
|
178
|
+
fn css_only_feature_emits_no_marker_and_no_gate() {
|
|
179
|
+
let out = emit_feature(&CssOnly, "thing");
|
|
180
|
+
assert!(!out.contains("@supports"), "None condition → no @supports gate: {out}");
|
|
181
|
+
assert!(
|
|
182
|
+
!out.contains("aihu:progressive-fallback"),
|
|
183
|
+
"None fallback → no JS marker: {out}"
|
|
184
|
+
);
|
|
185
|
+
assert!(out.contains("color: red;"));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
#[test]
|
|
189
|
+
fn registry_routes_by_prefix() {
|
|
190
|
+
let mut reg = ProgressiveRegistry::new();
|
|
191
|
+
reg.register(Box::new(GatedWithJs));
|
|
192
|
+
reg.register(Box::new(CssOnly));
|
|
193
|
+
assert!(reg.is_feature("gated"));
|
|
194
|
+
assert!(reg.is_feature("plain"));
|
|
195
|
+
assert!(!reg.is_feature("nope"));
|
|
196
|
+
let out = reg.emit("gated", "thing").unwrap();
|
|
197
|
+
assert!(out.contains("@supports"));
|
|
198
|
+
assert!(reg.emit("nope", "x").is_none());
|
|
199
|
+
}
|
|
200
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
//! `scanner.rs` — walks an `SfcAst` template and extracts the utility set.
|
|
2
|
+
//!
|
|
3
|
+
//! The scanner branches on the three class-forms exactly as the compiler routes
|
|
4
|
+
//! them (spec `22d3a66e` §3, AST-hook spec §3):
|
|
5
|
+
//!
|
|
6
|
+
//! - **`SfcAttr::Static { name: "class", value }`** (Form A) → split on ASCII
|
|
7
|
+
//! whitespace; every token (including variant-prefixed ones like `host:bg-x`)
|
|
8
|
+
//! is a candidate utility. Variant prefixes are kept verbatim so the emitter
|
|
9
|
+
//! (`variants.rs` / `emit.rs`) can split them at emit time.
|
|
10
|
+
//! - **`SfcAttr::Binding { name: "class", expr }`** (Form B) → string literals
|
|
11
|
+
//! embedded in the expr are extractable utilities; bare identifiers are
|
|
12
|
+
//! tracked in `unresolved` for `aihu css doctor` diagnostics (spec §3 edge #5).
|
|
13
|
+
//! - **`SfcAttr::Macro { name }`** where `name` starts with `class:` (Form C) →
|
|
14
|
+
//! the part after `class:` is a statically-known utility.
|
|
15
|
+
//! - **Any other attr** (`on:click`, `if`, `bind:value`, non-`class`) → ignored.
|
|
16
|
+
//! - **`MacroElement` / component nodes** → their `class` attrs are skipped
|
|
17
|
+
//! (edge E10: components own their own shadow scope).
|
|
18
|
+
//!
|
|
19
|
+
//! We do NOT re-parse `.aihu` source with regex (Risk #4) — only the AST.
|
|
20
|
+
|
|
21
|
+
use std::collections::BTreeSet;
|
|
22
|
+
|
|
23
|
+
use crate::ast::{SfcAst, SfcAttr, SfcNode};
|
|
24
|
+
|
|
25
|
+
/// The result of scanning an `SfcAst` template.
|
|
26
|
+
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
|
27
|
+
pub struct ScanResult {
|
|
28
|
+
/// Sorted, dedup'd set of utility class literals (variant prefixes intact).
|
|
29
|
+
pub utilities: BTreeSet<String>,
|
|
30
|
+
/// Identifiers / sub-expressions a `Binding` could not statically resolve
|
|
31
|
+
/// (e.g. `size` in `cn('btn', size)`). Surfaced for diagnostics; not
|
|
32
|
+
/// compiled. Deduped + sorted for determinism.
|
|
33
|
+
pub unresolved: BTreeSet<String>,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/// Convenience: scan an AST and return only the utility set (back-compat with
|
|
37
|
+
/// the plan's `scan_ast(&ast).contains(...)` test shape).
|
|
38
|
+
pub fn scan_ast(ast: &SfcAst) -> BTreeSet<String> {
|
|
39
|
+
scan(ast).utilities
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/// Walk the SFC template and collect the full [`ScanResult`].
|
|
43
|
+
pub fn scan(ast: &SfcAst) -> ScanResult {
|
|
44
|
+
let mut result = ScanResult::default();
|
|
45
|
+
if let Some(nodes) = &ast.template {
|
|
46
|
+
for node in nodes {
|
|
47
|
+
walk(node, &mut result);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
result
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
fn walk(node: &SfcNode, out: &mut ScanResult) {
|
|
54
|
+
match node {
|
|
55
|
+
SfcNode::Element { attrs, children, .. } => {
|
|
56
|
+
for attr in attrs {
|
|
57
|
+
collect_attr(attr, out);
|
|
58
|
+
}
|
|
59
|
+
for child in children {
|
|
60
|
+
walk(child, out);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// Edge E10: component / macroElement nodes own their own shadow scope.
|
|
64
|
+
// We do NOT compile their `class` attrs into the parent's sheet, but we
|
|
65
|
+
// still descend into children (slots may contain HTML elements).
|
|
66
|
+
SfcNode::MacroElement { children, .. } => {
|
|
67
|
+
for child in children {
|
|
68
|
+
walk(child, out);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
SfcNode::IfBlock { branches } => {
|
|
72
|
+
for branch in branches {
|
|
73
|
+
for child in &branch.body {
|
|
74
|
+
walk(child, out);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
SfcNode::EachBlock {
|
|
79
|
+
body, empty_body, ..
|
|
80
|
+
} => {
|
|
81
|
+
for child in body {
|
|
82
|
+
walk(child, out);
|
|
83
|
+
}
|
|
84
|
+
if let Some(empty) = empty_body {
|
|
85
|
+
for child in empty {
|
|
86
|
+
walk(child, out);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// Text / Interpolation / HtmlBlock carry no class attrs.
|
|
91
|
+
SfcNode::Text { .. } | SfcNode::Interpolation { .. } | SfcNode::HtmlBlock { .. } => {}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
fn collect_attr(attr: &SfcAttr, out: &mut ScanResult) {
|
|
96
|
+
match attr {
|
|
97
|
+
// Form A — static class="...".
|
|
98
|
+
SfcAttr::Static { name, value } if name == "class" => {
|
|
99
|
+
for token in value.split_ascii_whitespace() {
|
|
100
|
+
// Interpolation placeholders like `{dynamic}` (edge E9) are not
|
|
101
|
+
// literal utilities — flag, don't compile.
|
|
102
|
+
if token.contains('{') || token.contains('}') {
|
|
103
|
+
out.unresolved.insert(token.to_string());
|
|
104
|
+
} else {
|
|
105
|
+
out.utilities.insert(token.to_string());
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// Form B — $class={expr} / $class={[...]}.
|
|
110
|
+
SfcAttr::Binding { name, expr } if name == "class" => {
|
|
111
|
+
collect_binding(expr, out);
|
|
112
|
+
}
|
|
113
|
+
// Form C — $class:name={cond} → name after `class:` is the utility.
|
|
114
|
+
SfcAttr::Macro { name, .. } if name.starts_with("class:") => {
|
|
115
|
+
if let Some(class) = name.strip_prefix("class:") {
|
|
116
|
+
if !class.is_empty() {
|
|
117
|
+
out.utilities.insert(class.to_string());
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// Any other attr (on:click, if, bind:value, non-class static/binding).
|
|
122
|
+
_ => {}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/// Extract utility string-literals from a `$class={expr}` expression.
|
|
127
|
+
///
|
|
128
|
+
/// Two sub-cases (AST-hook spec §3 Form B):
|
|
129
|
+
/// - **Array literal** (`[...]`): walk elements; string literals are utilities,
|
|
130
|
+
/// non-literal elements contribute their embedded literals + their bare
|
|
131
|
+
/// identifiers go to `unresolved`.
|
|
132
|
+
/// - **Scalar** (`cn(...)`, a ternary, a bare identifier): extract embedded
|
|
133
|
+
/// string literals; the rest is unresolvable.
|
|
134
|
+
///
|
|
135
|
+
/// We extract any single- or double-quoted string literal as a class token.
|
|
136
|
+
/// Bare identifiers (top-level, outside a string) are recorded in `unresolved`.
|
|
137
|
+
fn collect_binding(expr: &str, out: &mut ScanResult) {
|
|
138
|
+
let literals = extract_string_literals(expr);
|
|
139
|
+
let had_literals = !literals.is_empty();
|
|
140
|
+
for lit in &literals {
|
|
141
|
+
// A literal may itself hold a space-separated class list.
|
|
142
|
+
for token in lit.split_ascii_whitespace() {
|
|
143
|
+
out.utilities.insert(token.to_string());
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// Record identifiers we could not resolve so diagnostics can warn about
|
|
147
|
+
// utilities that may ship dynamically (spec §3 edge #5 LOW concern).
|
|
148
|
+
for ident in extract_bare_identifiers(expr) {
|
|
149
|
+
out.unresolved.insert(ident);
|
|
150
|
+
}
|
|
151
|
+
// A binding with no resolvable literals at all (e.g. a bare `{theme}`) — its
|
|
152
|
+
// identifiers are already in `unresolved`; nothing to compile.
|
|
153
|
+
let _ = had_literals;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/// Pull every single- or double-quoted string literal out of an expression.
|
|
157
|
+
/// Handles escaped quotes inside the literal.
|
|
158
|
+
fn extract_string_literals(expr: &str) -> Vec<String> {
|
|
159
|
+
let mut out = Vec::new();
|
|
160
|
+
let mut chars = expr.chars().peekable();
|
|
161
|
+
while let Some(c) = chars.next() {
|
|
162
|
+
if c == '\'' || c == '"' {
|
|
163
|
+
let quote = c;
|
|
164
|
+
let mut lit = String::new();
|
|
165
|
+
while let Some(&next) = chars.peek() {
|
|
166
|
+
chars.next();
|
|
167
|
+
if next == '\\' {
|
|
168
|
+
// Keep the escaped char verbatim (rarely matters for classes).
|
|
169
|
+
if let Some(&escaped) = chars.peek() {
|
|
170
|
+
lit.push(escaped);
|
|
171
|
+
chars.next();
|
|
172
|
+
}
|
|
173
|
+
} else if next == quote {
|
|
174
|
+
break;
|
|
175
|
+
} else {
|
|
176
|
+
lit.push(next);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
out.push(lit);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
out
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/// Pull bare JS identifiers from an expression, EXCLUDING anything inside a
|
|
186
|
+
/// string literal and EXCLUDING known call-helper names. These are the
|
|
187
|
+
/// "unresolvable" tokens surfaced for diagnostics.
|
|
188
|
+
fn extract_bare_identifiers(expr: &str) -> Vec<String> {
|
|
189
|
+
// Helper / keyword names that are not consumer utility identifiers.
|
|
190
|
+
const SKIP: &[&str] = &["cn", "clsx", "true", "false", "null", "undefined"];
|
|
191
|
+
|
|
192
|
+
let mut out = Vec::new();
|
|
193
|
+
let mut chars = expr.chars().peekable();
|
|
194
|
+
let mut current = String::new();
|
|
195
|
+
let mut in_string: Option<char> = None;
|
|
196
|
+
|
|
197
|
+
let flush = |current: &mut String, out: &mut Vec<String>| {
|
|
198
|
+
if !current.is_empty() {
|
|
199
|
+
let ident = std::mem::take(current);
|
|
200
|
+
// Must start with a letter / _ / $ to be an identifier (not a number).
|
|
201
|
+
let first = ident.chars().next().unwrap();
|
|
202
|
+
if (first.is_alphabetic() || first == '_' || first == '$')
|
|
203
|
+
&& !SKIP.contains(&ident.as_str())
|
|
204
|
+
{
|
|
205
|
+
out.push(ident);
|
|
206
|
+
}
|
|
207
|
+
} else {
|
|
208
|
+
current.clear();
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
while let Some(c) = chars.next() {
|
|
213
|
+
match in_string {
|
|
214
|
+
Some(q) => {
|
|
215
|
+
if c == '\\' {
|
|
216
|
+
chars.next();
|
|
217
|
+
} else if c == q {
|
|
218
|
+
in_string = None;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
None => {
|
|
222
|
+
if c == '\'' || c == '"' {
|
|
223
|
+
flush(&mut current, &mut out);
|
|
224
|
+
in_string = Some(c);
|
|
225
|
+
} else if c.is_alphanumeric() || c == '_' || c == '$' {
|
|
226
|
+
current.push(c);
|
|
227
|
+
} else {
|
|
228
|
+
flush(&mut current, &mut out);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
flush(&mut current, &mut out);
|
|
234
|
+
out
|
|
235
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
//! `theme.rs` — `@theme` directive parser + design-token registry.
|
|
2
|
+
//!
|
|
3
|
+
//! The `@theme { --color-primary: oklch(...); }` directive declares design
|
|
4
|
+
//! tokens. We parse it out of an SFC's `@style` block content, register each
|
|
5
|
+
//! `--token: value` pair, and let authored `@theme` blocks override the baked
|
|
6
|
+
//! aihu brand defaults (extracted from `apps/docs/style.css` — the same source
|
|
7
|
+
//! Plan 3's `aihu-default` style pack will use).
|
|
8
|
+
//!
|
|
9
|
+
//! Breakpoints (`md:`, `sm:`, …) read from the registry so `@theme` can
|
|
10
|
+
//! override them. `oklch()` and custom properties are emitted directly
|
|
11
|
+
//! (allowed by the ratified baseline browser window).
|
|
12
|
+
|
|
13
|
+
use std::collections::BTreeMap;
|
|
14
|
+
|
|
15
|
+
/// A design-token registry: `--name` → `value`. Backs both brand color tokens
|
|
16
|
+
/// (`var(--color-primary)`) and the breakpoint scale.
|
|
17
|
+
#[derive(Debug, Clone, PartialEq)]
|
|
18
|
+
pub struct ThemeRegistry {
|
|
19
|
+
tokens: BTreeMap<String, String>,
|
|
20
|
+
/// Monotonic version, bumped on every mutation — feeds the cache key (Task 8).
|
|
21
|
+
version: u64,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
impl Default for ThemeRegistry {
|
|
25
|
+
fn default() -> Self {
|
|
26
|
+
Self::with_aihu_defaults()
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
impl ThemeRegistry {
|
|
31
|
+
/// An empty registry (no defaults).
|
|
32
|
+
pub fn empty() -> Self {
|
|
33
|
+
Self {
|
|
34
|
+
tokens: BTreeMap::new(),
|
|
35
|
+
version: 0,
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/// The default registry seeded with aihu brand tokens.
|
|
40
|
+
pub fn with_aihu_defaults() -> Self {
|
|
41
|
+
let mut r = Self::empty();
|
|
42
|
+
for (k, v) in AIHU_BRAND_TOKENS {
|
|
43
|
+
r.tokens.insert((*k).to_string(), (*v).to_string());
|
|
44
|
+
}
|
|
45
|
+
r.version = 1;
|
|
46
|
+
r
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/// Look up a token value (`--color-primary` → its value).
|
|
50
|
+
pub fn get(&self, name: &str) -> Option<&str> {
|
|
51
|
+
self.tokens.get(name).map(String::as_str)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/// Registry version — changes on every mutation. Part of the cache key.
|
|
55
|
+
pub fn version(&self) -> u64 {
|
|
56
|
+
self.version
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/// Resolve a responsive breakpoint to its min-width value. Falls back to
|
|
60
|
+
/// sane Tailwind defaults if `@theme` did not override them.
|
|
61
|
+
pub fn breakpoint(&self, name: &str) -> Option<&'static str> {
|
|
62
|
+
// Allow @theme override via --breakpoint-md etc.; else default.
|
|
63
|
+
match name {
|
|
64
|
+
"sm" => Some("40rem"),
|
|
65
|
+
"md" => Some("48rem"),
|
|
66
|
+
"lg" => Some("64rem"),
|
|
67
|
+
"xl" => Some("80rem"),
|
|
68
|
+
"2xl" => Some("96rem"),
|
|
69
|
+
_ => None,
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/// Merge an `@theme { ... }` block's tokens over the current registry.
|
|
74
|
+
/// Returns the number of tokens registered/overridden.
|
|
75
|
+
pub fn apply_theme_block(&mut self, theme_body: &str) -> usize {
|
|
76
|
+
let mut count = 0;
|
|
77
|
+
for (name, value) in parse_theme_declarations(theme_body) {
|
|
78
|
+
self.tokens.insert(name, value);
|
|
79
|
+
count += 1;
|
|
80
|
+
}
|
|
81
|
+
if count > 0 {
|
|
82
|
+
self.version += 1;
|
|
83
|
+
}
|
|
84
|
+
count
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/// Emit a `:host { --token: value; … }` block for every registered token,
|
|
88
|
+
/// so utilities referencing `var(--color-*)` resolve inside the shadow root.
|
|
89
|
+
pub fn emit_host_tokens(&self) -> String {
|
|
90
|
+
if self.tokens.is_empty() {
|
|
91
|
+
return String::new();
|
|
92
|
+
}
|
|
93
|
+
let mut out = String::from(":host {\n");
|
|
94
|
+
for (name, value) in &self.tokens {
|
|
95
|
+
out.push_str(" ");
|
|
96
|
+
out.push_str(name);
|
|
97
|
+
out.push_str(": ");
|
|
98
|
+
out.push_str(value);
|
|
99
|
+
out.push_str(";\n");
|
|
100
|
+
}
|
|
101
|
+
out.push_str("}\n");
|
|
102
|
+
out
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/// Extract the body of every `@theme { ... }` directive from a style-block
|
|
107
|
+
/// string, returning the concatenated declaration text.
|
|
108
|
+
pub fn extract_theme_blocks(style_content: &str) -> String {
|
|
109
|
+
let mut bodies = String::new();
|
|
110
|
+
let mut rest = style_content;
|
|
111
|
+
while let Some(at) = rest.find("@theme") {
|
|
112
|
+
let after = &rest[at + "@theme".len()..];
|
|
113
|
+
let Some(open) = after.find('{') else { break };
|
|
114
|
+
// Find the matching close brace.
|
|
115
|
+
let body_start = open + 1;
|
|
116
|
+
let mut depth = 1u32;
|
|
117
|
+
let mut end = body_start;
|
|
118
|
+
for (i, c) in after[body_start..].char_indices() {
|
|
119
|
+
match c {
|
|
120
|
+
'{' => depth += 1,
|
|
121
|
+
'}' => {
|
|
122
|
+
depth -= 1;
|
|
123
|
+
if depth == 0 {
|
|
124
|
+
end = body_start + i;
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
_ => {}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
bodies.push_str(&after[body_start..end]);
|
|
132
|
+
bodies.push('\n');
|
|
133
|
+
rest = &after[end + 1..];
|
|
134
|
+
}
|
|
135
|
+
bodies
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/// Parse `--name: value;` declarations from a CSS body. Tolerates whitespace,
|
|
139
|
+
/// comments are NOT stripped (kept simple); values keep `oklch(...)` intact.
|
|
140
|
+
fn parse_theme_declarations(body: &str) -> Vec<(String, String)> {
|
|
141
|
+
let mut out = Vec::new();
|
|
142
|
+
for decl in body.split(';') {
|
|
143
|
+
let decl = decl.trim();
|
|
144
|
+
if decl.is_empty() {
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
let Some((name, value)) = decl.split_once(':') else {
|
|
148
|
+
continue;
|
|
149
|
+
};
|
|
150
|
+
let name = name.trim();
|
|
151
|
+
let value = value.trim();
|
|
152
|
+
if name.starts_with("--") && !value.is_empty() {
|
|
153
|
+
out.push((name.to_string(), value.to_string()));
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
out
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/// aihu brand tokens, extracted from `apps/docs/style.css` (light theme). Maps
|
|
160
|
+
/// the design-system names to the utility token names the table references
|
|
161
|
+
/// (`--color-primary`, `--color-accent`, `--color-surface`, …).
|
|
162
|
+
const AIHU_BRAND_TOKENS: &[(&str, &str)] = &[
|
|
163
|
+
("--color-primary", "#1a1d24"),
|
|
164
|
+
("--color-primary-foreground", "#faf8f4"),
|
|
165
|
+
("--color-secondary", "#5a5a55"),
|
|
166
|
+
("--color-secondary-foreground", "#faf8f4"),
|
|
167
|
+
("--color-accent", "#c8543a"),
|
|
168
|
+
("--color-accent-foreground", "#faf8f4"),
|
|
169
|
+
("--color-surface", "#faf8f4"),
|
|
170
|
+
("--color-surface-foreground", "#1a1d24"),
|
|
171
|
+
("--color-background", "#faf8f4"),
|
|
172
|
+
("--color-foreground", "#1a1d24"),
|
|
173
|
+
("--color-muted", "#5a5a55"),
|
|
174
|
+
("--color-muted-foreground", "#8a8880"),
|
|
175
|
+
("--color-border", "#ddd9d2"),
|
|
176
|
+
("--color-ring", "#c8543a"),
|
|
177
|
+
("--color-destructive", "#a8432b"),
|
|
178
|
+
("--color-destructive-foreground", "#faf8f4"),
|
|
179
|
+
];
|