///|
/// Remove `@theme` blocks from a stylesheet, at any depth.
fn remove_theme_nodes_deep(nodes : ArrayView[CssNode]) -> Array[CssNode] {
let output : Array[CssNode] = []
for node in nodes {
match node {
AtRule(name="@theme", ..) => ()
AtRule(name~, params~, nodes=Some(children), span~) =>
output.push(
AtRule(
name~,
params~,
nodes=Some(remove_theme_nodes_deep(children)),
span~,
),
)
Rule(selector~, nodes=children, span~) =>
output.push(
Rule(selector~, nodes=remove_theme_nodes_deep(children), span~),
)
Context(values~, nodes=children, span~) =>
output.push(
Context(values~, nodes=remove_theme_nodes_deep(children), span~),
)
_ => output.push(node)
}
}
output
}
///|
/// Collect the distinct texts that follow a literal `var(` in `css`, sorted.
///
/// The usage test these feed is a *prefix* test — a theme variable counts as used
/// when the CSS contains `var(`, so `var(--text-sm--line-height)` also marks
/// `--text-sm` used. Tokens are therefore kept whole and probed with
/// `sorted_has_prefix` instead of compared for equality, which preserves that
/// behaviour while replacing one whole-CSS search per theme variable (400+ on a
/// full import) with a single pass.
fn var_reference_tokens(css : String) -> Array[String] {
let seen : Map[String, Unit] = Map([])
let length = css.length()
let mut pos = 0
while pos < length {
guard css[pos:].find("var(") is Some(offset) else { break }
let start = pos + offset + 4
let mut end = start
while end < length {
let character = css[end]
if character == ')' ||
character == ',' ||
character == ' ' ||
character == '\n' ||
character == '\t' ||
character == '\r' {
break
}
end += 1
}
if end > start {
seen[css[start:end].to_owned()] = ()
}
pos = start
}
let tokens = seen.keys().to_array()
tokens.sort_by(lexical_compare)
tokens
}
///|
/// Whether any token in the sorted `tokens` starts with `prefix`.
///
/// Strings sharing a prefix are contiguous in lexicographic order and are the
/// first entries at or after the prefix itself, so one binary search decides it.
fn sorted_has_prefix(tokens : ArrayView[String], prefix : String) -> Bool {
let mut low = 0
let mut high = tokens.length()
while low < high {
let middle = (low + high) / 2
if lexical_compare(tokens[middle], prefix) < 0 {
low = middle + 1
} else {
high = middle
}
}
low < tokens.length() && tokens[low].has_prefix(prefix)
}
///|
/// Build the `:root, :host` rule that carries the theme variables in use.
fn theme_rule_nodes(
theme : Map[String, String],
generated_css : String,
) -> Array[CssNode] {
if theme.is_empty() {
return []
}
// Precompute each non-meta theme variable's `var(` search string ONCE.
// Previously the fixed-point below recomputed `theme_variable_name` and
// re-interpolated the search string for every (used × dependency) pair on every
// iteration — O(V²) throwaway string allocations for a full theme.
// Theme options are stored under prefixed keys in the same map, so asking
// "is this variable inline/reference/static?" per variable means building a
// key string and probing the map three times each. The whole map is walked
// here anyway — collect the option sets in that one pass instead.
let names : Array[String] = []
let variables : Array[String] = []
let searches : Array[String] = []
let inline_names : Map[String, Unit] = Map([])
let reference_names : Map[String, Unit] = Map([])
let static_names : Map[String, Unit] = Map([])
for name, _ in theme {
if name.has_prefix(theme_meta_prefix) {
if name.has_prefix(theme_meta_inline_prefix) {
inline_names[name[theme_meta_inline_prefix.length():].to_owned()] = ()
} else if name.has_prefix(theme_meta_reference_prefix) {
reference_names[name[theme_meta_reference_prefix.length():].to_owned()] = ()
} else if name.has_prefix(theme_meta_static_prefix) {
static_names[name[theme_meta_static_prefix.length():].to_owned()] = ()
}
continue
}
names.push(name)
let variable = theme_variable_name(theme, name)
variables.push(variable)
searches.push("var(" + variable)
}
let referenced = var_reference_tokens(generated_css)
let used : Map[String, Unit] = Map([])
for i in 0..
if value.contains("var(") {
for i in 0.. ()
}
}
if additions.is_empty() {
break
}
for name in additions {
used[name] = ()
}
}
let span : SourceSpan = { start: 0, end: 0 }
let declarations : Array[CssNode] = []
for name, value in theme {
if name.has_prefix(theme_meta_prefix) ||
inline_names.contains(name) ||
reference_names.contains(name) {
continue
}
// A theme value that resolves to `initial` is treated as unset: it is not
// emitted to `:root`, while `var(name, fallback)` references elsewhere still
// fall back. This mirrors upstream's deprecated inline-reference defaults.
if trim(value) == "initial" {
continue
}
if used.contains(name) {
declarations.push(
Declaration(
name=theme_variable_name(theme, name),
value~,
important=false,
span~,
),
)
}
}
if declarations.is_empty() {
return []
}
[Rule(selector=":root, :host", nodes=declarations, span~)]
}
///|
/// Parse a Tailwind CSS v4 stylesheet and return a reusable compiler.
///
/// Resolves `@import`s through the async `options.loader`. Hosts without an
/// async runtime (e.g. wasm-gc) should use `compile_sync` instead.
pub async fn compile(
css : String,
options? : CompileOptions = CompileOptions::new(),
) -> Compiler {
let ast = parse_css(css)
let had_imports = contains_import(ast)
let resolved_ast = if had_imports {
guard options.loader is Some(loader) else {
raise MissingStylesheetLoader("@import requires a stylesheet loader")
}
resolve_imports(ast, loader, options.base, [])
} else {
ast
}
compile_from_ast(resolved_ast, css, had_imports, options)
}
///|
/// Synchronous variant of `compile`, resolving `@import`s through
/// `options.sync_loader`.
///
/// This is the entry point for hosts that cannot drive an async runtime, most
/// notably the wasm-gc backend. Behaviour is otherwise identical to `compile`;
/// `MemoryStylesheetLoader` implements the required `SyncStylesheetLoader`.
pub fn compile_sync(
css : String,
options? : CompileOptions = CompileOptions::new(),
) -> Compiler raise CompileError {
let ast = parse_css(css)
let had_imports = contains_import(ast)
let resolved_ast = if had_imports {
guard options.sync_loader is Some(loader) else {
raise MissingStylesheetLoader("@import requires a stylesheet loader")
}
resolve_imports_sync(ast, loader, options.base, [])
} else {
ast
}
compile_from_ast(resolved_ast, css, had_imports, options)
}
///|
/// Shared, fully synchronous tail of `compile`/`compile_sync`: everything after
/// `@import` resolution. `resolved_ast` has had its imports inlined, `source_css`
/// is the stylesheet as authored, and `had_imports` says whether resolution
/// changed it (in which case echoing `source_css` back is not equivalent).
fn compile_from_ast(
resolved_ast : Array[CssNode],
source_css : String,
had_imports : Bool,
options : CompileOptions,
) -> Compiler raise CompileError {
reject_javascript_directives(resolved_ast)
let theme = parse_theme(resolved_ast)
// Theme values may themselves call compile-time functions (e.g. upstream's
// `--default-font-family: --theme(--font-sans, initial)`). Resolve them so the
// emitted `:root` declarations never leak a `--theme()`/`--spacing()` call.
resolve_theme_value_functions(theme)
let (function_ast, did_functions) = substitute_css_functions(
resolved_ast, theme,
)
let source_directives = parse_source_directives(function_ast, options.base)
let (without_sources, did_sources) = remove_source_nodes(function_ast)
let (custom_utilities, functional_utilities) = parse_custom_utilities(
without_sources,
)
let custom_variants = parse_custom_variants(without_sources)
let (custom_utilities, functional_utilities) = resolve_custom_utility_bodies(
custom_utilities, functional_utilities, theme, custom_variants,
)
let (without_utilities, did_custom_utility) = remove_custom_utility_nodes(
without_sources,
)
let (without_custom, did_custom_variant) = remove_custom_variant_nodes(
without_utilities,
)
let (applied_ast, did_apply) = substitute_apply(
without_custom, theme, custom_utilities, functional_utilities, custom_variants,
)
let (applied_ast, did_at_variant) = substitute_at_variant(
applied_ast, theme, custom_variants,
)
let flattened_ast = merge_adjacent_at_rules(flatten_css_nesting(applied_ast))
// Comparing the two trees directly replaces rendering both of them to strings
// just to diff them. `flatten_css_nesting`/`merge_adjacent_at_rules` only ever
// reuse the spans of the nodes they keep, so an unchanged tree compares equal
// and the derived structural equality answers the same question the rendered
// comparison did — without building two copies of the whole stylesheet.
let did_flatten = applied_ast != flattened_ast
let did_rewrite = did_apply ||
did_custom_utility ||
did_custom_variant ||
did_functions ||
did_sources ||
did_at_variant ||
did_flatten
let stylesheet = flattened_ast
let has_utilities = contains_tailwind_utilities(stylesheet) || did_rewrite
// `input` is read at exactly one place: `build`'s `!has_utilities` early
// return, where the compiler echoes the stylesheet back because it generates
// nothing of its own. Rendering it for a stylesheet that *does* have
// `@tailwind utilities` — every real workload — is pure waste, and
// `did_rewrite` implies `has_utilities`, so the rewrite case never needs it
// either. Render only when the echo can actually happen.
let input = if has_utilities {
""
} else if had_imports {
render_css_nodes(stylesheet)
} else {
source_css
}
let initial_candidates : Map[String, Unit] = Map([])
for candidate in source_directives.included {
if !source_directives.excluded.contains(candidate) {
initial_candidates[candidate] = ()
}
}
{
input,
polyfills: options.polyfills,
stylesheet,
theme,
sources: source_directives.sources,
candidates: initial_candidates,
excluded_candidates: source_directives.excluded,
custom_utilities,
functional_utilities,
custom_variants,
has_utilities,
}
}
///|
/// Reject the directives that only make sense with a JavaScript module loader.
///
/// JavaScript configuration files and plugins are out of scope, so they are
/// reported rather than silently ignored.
fn reject_javascript_directives(
nodes : ArrayView[CssNode],
) -> Unit raise CompileError {
for node in nodes {
match node {
AtRule(name="@plugin" | "@config", params~, ..) =>
raise UnsupportedJsCompatibility(
"JavaScript configuration and plugins are not supported: \{trim(params)}",
)
Rule(nodes~, ..) | AtRule(nodes=Some(nodes), ..) | Context(nodes~, ..) =>
reject_javascript_directives(nodes)
_ => ()
}
}
}
///|
fn contains_tailwind_utilities(nodes : ArrayView[CssNode]) -> Bool {
for node in nodes {
match node {
AtRule(name="@tailwind", params~, ..) =>
if params == "utilities" || params.has_prefix("utilities ") {
return true
}
Rule(nodes~, ..) | AtRule(nodes=Some(nodes), ..) | Context(nodes~, ..) =>
if contains_tailwind_utilities(nodes) {
return true
}
_ => ()
}
}
false
}
///|
/// Replace `@theme` and `@tailwind utilities` with their generated content.
///
/// Only the first of each is substituted, matching upstream: later `@theme`
/// blocks and repeated `@tailwind utilities` directives are dropped.
fn compose_stylesheet(
nodes : ArrayView[CssNode],
theme_rule : Array[CssNode],
utilities : Array[CssNode],
state : ComposeState,
) -> Array[CssNode] {
let output : Array[CssNode] = []
for node in nodes {
match node {
AtRule(name="@theme", ..) =>
if !state.emitted_theme {
state.emitted_theme = true
for child in theme_rule {
output.push(child)
}
}
AtRule(name="@tailwind", params~, ..) =>
if params == "utilities" || params.has_prefix("utilities ") {
if !state.emitted_utilities {
state.emitted_utilities = true
for child in utilities {
output.push(child)
}
}
} else {
output.push(node)
}
Rule(selector~, nodes=children, span~) =>
output.push(
Rule(
selector~,
nodes=compose_stylesheet(children, theme_rule, utilities, state),
span~,
),
)
AtRule(name~, params~, nodes=Some(children), span~) => {
let inner = compose_stylesheet(children, theme_rule, utilities, state)
if !inner.is_empty() {
output.push(AtRule(name~, params~, nodes=Some(inner), span~))
}
}
Context(nodes=children, ..) =>
for child in compose_stylesheet(children, theme_rule, utilities, state) {
output.push(child)
}
_ => output.push(node)
}
}
output
}
///|
priv struct ComposeState {
mut emitted_theme : Bool
mut emitted_utilities : Bool
}
///|
/// Return sources discovered while compiling the stylesheet.
pub fn Compiler::sources(self : Compiler) -> Array[Source] {
self.sources.copy()
}
///|
/// Build CSS for all candidates seen so far.
///
/// Unknown candidates are ignored, as in the reference compiler.
pub fn Compiler::build(
self : Compiler,
candidates : ArrayView[String],
) -> String {
for candidate in candidates {
if !self.excluded_candidates.contains(candidate) {
self.candidates[candidate] = ()
}
}
if !self.has_utilities {
return self.input
}
let rendered : Array[RenderedCandidate] = []
for candidate, _ in self.candidates {
match
render_candidate(
self.theme,
self.custom_utilities,
self.functional_utilities,
self.custom_variants,
candidate,
) {
Some(css) => rendered.push(css)
None => ()
}
}
rendered.sort_by(compare_rendered_candidates)
let generated_nodes : Array[CssNode] = []
let generated_footer : Array[CssNode] = []
for item in rendered {
for node in flatten_css_nesting(item.nodes) {
match node {
AtRoot(..) =>
if !generated_footer.contains(node) {
generated_footer.push(node)
}
_ => generated_nodes.push(node)
}
}
}
generated_nodes.append(generated_footer)
let base_nodes = remove_theme_nodes_deep(self.stylesheet)
let merged_generated = merge_adjacent_at_rules(generated_nodes)
let base = render_css_nodes(base_nodes)
let generated = render_css_nodes(merged_generated)
let usage = "\{base}\n\{generated}"
// The author `@property` registrations `usage` would expose at its top level,
// taken from the nodes it is rendered from rather than parsed back out of it.
let author_properties : Array[CssNode] = []
collect_author_property_at_rules(base_nodes, author_properties)
collect_author_property_at_rules(merged_generated, author_properties)
// `@apply` can put generated custom properties in the base stylesheet too.
let (property_header, property_footer) = render_generated_properties(
usage,
author_properties,
at_property_fallback=(self.polyfills & POLYFILL_AT_PROPERTY) != 0,
)
let composed_nodes = compose_stylesheet(
self.stylesheet,
theme_rule_nodes(self.theme, usage),
generated_nodes,
{ emitted_theme: false, emitted_utilities: false },
)
let composed = merge_adjacent_at_rules(
if (self.polyfills & POLYFILL_COLOR_MIX) != 0 {
add_color_mix_fallback(composed_nodes, self.theme, false)
} else {
strip_color_mix_fallback(composed_nodes)
},
)
let out = StringBuilder()
out.write_string(property_header)
out.write_string(render_css_nodes(composed))
out.write_string(property_footer)
out.write_string(render_used_keyframes(self.theme, generated))
out.to_string()
}