Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/honest-dingos-push.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@mincho-js/css": minor
---

**theme**
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Use a proper heading instead of bold emphasis.

Markdownlint flags this line because **theme** uses emphasis rather than a heading. For better document structure, use a proper heading level.

Apply this diff:

-**theme**
+## theme
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
**theme**
## theme
🧰 Tools
🪛 markdownlint-cli2 (0.18.1)

5-5: Emphasis used instead of a heading

(MD036, no-emphasis-as-heading)

🤖 Prompt for AI Agents
.changeset/honest-dingos-push.md around line 5: the line uses bold emphasis
"**theme**" instead of a markdown heading; replace it with an appropriate
heading (e.g., "## theme" or "# theme" depending on desired level) so the file
uses a proper heading syntax and satisfies markdownlint.

- Add `theme()` `this.fallbackVar()` API

153 changes: 129 additions & 24 deletions packages/css/src/theme/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ type WithOptionalLayer<T extends Theme> = T & {
interface ThemeSubFunctions {
fallbackVar: typeof fallbackVar;
raw(varReference: unknown): string;
alias(varReference: unknown): string;
}

type ResolveThemeOutput<T extends Theme> = Resolve<ResolveTheme<T>>;
Expand Down Expand Up @@ -125,7 +126,18 @@ function assignTokensWithPrefix<ThemeTokens extends Theme>(
const vars: AssignedVars = {};
const resolvedTokens = {} as ResolveTheme<ThemeTokens>;

// Add fallbackVar utility to the context for use in getters
// Execute two-pass token resolution:
// 1. Assign CSS variables to all tokens
// 2. Resolve semantic token references
const context: TokenProcessingContext = {
prefix,
path: [],
parentPath: prefix,
cssVarMap: {},
aliasMap: new Set()
};

// Add utilities to the context for use in getters
Object.defineProperty(resolvedTokens, "fallbackVar", {
value: fallbackVar,
enumerable: false,
Expand All @@ -141,21 +153,28 @@ function assignTokensWithPrefix<ThemeTokens extends Theme>(
writable: false
});

// Execute two-pass token resolution:
// 1. Assign CSS variables to all tokens
// 2. Resolve semantic token references
const context: TokenProcessingContext = {
prefix,
path: [],
parentPath: prefix,
cssVarMap: {}
};
// Add alias utility to create object references instead of CSS variables
Object.defineProperty(resolvedTokens, "alias", {
value: createAliasFunction(context),
enumerable: false,
configurable: false,
writable: false
});

assignTokenVariables(tokens, vars, resolvedTokens, context);
resolveSemanticTokens(tokens, vars, resolvedTokens, context);

return { vars, resolvedTokens };
}

/**
* Removes hash suffixes from CSS variable names and var() references
*/
function stripHash(str: string): string {
// Remove hash suffix from CSS variable names and var() references
return str.replace(/__[a-zA-Z0-9]+/g, "");
}

// == Two-Pass Token Resolution ===============================================
/**
* Context object for token processing functions
Expand All @@ -165,6 +184,7 @@ interface TokenProcessingContext {
path: string[]; // Current path in object tree
parentPath: string; // Current variable path
cssVarMap: CSSVarMap; // Cache for CSS variable names
aliasMap: Set<string>; // Track paths that should be aliases (not create CSS vars)
}

/**
Expand Down Expand Up @@ -218,7 +238,8 @@ function assignTokenVariables(
prefix: context.prefix,
path: currentPath,
parentPath: varPath,
cssVarMap: context.cssVarMap
cssVarMap: context.cssVarMap,
aliasMap: context.aliasMap
}
);
} else {
Expand Down Expand Up @@ -280,7 +301,8 @@ function assignTokenVariables(
prefix: context.prefix,
path: currentPath,
parentPath: varPath,
cssVarMap: context.cssVarMap
cssVarMap: context.cssVarMap,
aliasMap: context.aliasMap
});
continue;
}
Expand Down Expand Up @@ -313,17 +335,28 @@ function resolveSemanticTokens(

// Handle getters (semantic tokens)
if (typeof descriptor.get === "function") {
const cssVar = getCSSVarByPath(varPath, context);

// Set the current path for alias() to access
currentProcessingPath = currentPath;
// Call getter with resolvedTokens as this context
// This allows semantic tokens to reference other tokens
const computedValue = descriptor.get.call(resolvedTokens);
// Clear the current path after getter execution
currentProcessingPath = [];

// Store the computed reference (should be a var() reference)
vars[cssVar] = computedValue;
// Check if this path is marked as an alias
const currentPathStr = currentPath.join(".");

// Update resolvedTokens with the actual reference
setByPath(resolvedTokens, currentPath, computedValue);
if (context.aliasMap.has(currentPathStr)) {
// Don't create a CSS variable for aliases
// Just update resolvedTokens with the aliased reference (unhashed)
const unhashedValue = stripHash(computedValue);
setByPath(resolvedTokens, currentPath, unhashedValue);
} else {
// Normal flow: create CSS variable
const cssVar = getCSSVarByPath(varPath, context);
vars[cssVar] = computedValue;
setByPath(resolvedTokens, currentPath, computedValue);
}
continue;
}

Expand All @@ -343,7 +376,8 @@ function resolveSemanticTokens(
prefix: context.prefix,
path: currentPath,
parentPath: varPath,
cssVarMap: context.cssVarMap
cssVarMap: context.cssVarMap,
aliasMap: context.aliasMap
}
);
continue;
Expand All @@ -355,7 +389,8 @@ function resolveSemanticTokens(
prefix: context.prefix,
path: currentPath,
parentPath: varPath,
cssVarMap: context.cssVarMap
cssVarMap: context.cssVarMap,
aliasMap: context.aliasMap
});
}
}
Expand Down Expand Up @@ -503,6 +538,24 @@ function createRawExtractor(vars: AssignedVars) {
};
}

/**
* Creates a function that marks tokens as aliases.
* Aliased tokens don't create their own CSS variables but reference existing ones.
*/
// Store the current processing path for alias() to access
let currentProcessingPath: string[] = [];

function createAliasFunction(context: TokenProcessingContext) {
return function alias(varReference: unknown): string {
// Mark the current processing path as an alias
const currentPathStr = currentProcessingPath.join(".");

context.aliasMap.add(currentPathStr);
// Return the var reference unchanged
return String(varReference);
};
}

// == Token Value Extractors ==================================================
function extractFontFamilyValue(value: TokenFontFamilyValue): CSSVarValue {
if (Array.isArray(value)) {
Expand Down Expand Up @@ -652,10 +705,6 @@ if (import.meta.vitest) {
}

// Test utility functions for handling hashed CSS variables
function stripHash(str: string): string {
// Remove hash suffix from CSS variable names and var() references
return str.replace(/__[a-zA-Z0-9]+/g, "");
}

function normalizeVars(vars: AssignedVars): AssignedVars {
const normalized: AssignedVars = {};
Expand Down Expand Up @@ -894,6 +943,62 @@ if (import.meta.vitest) {
);
});

it("handles alias references in semantic tokens", () => {
const result = assignTokens(
composedValue({
color: {
base: {
blue: "#0000ff",
red: "#ff0000"
},
semantic: {
get primary(): string {
// Use alias to reference without creating CSS var
return this.alias(this.color.base.blue);
},
get danger(): string {
// Another alias
return this.alias(this.color.base.red);
},
get secondary(): string {
// Normal reference (creates CSS var)
return this.color.base.blue;
}
}
}
})
);

validateHashFormat(result.vars);

const normalizedVars = normalizeVars(result.vars);
// Base tokens should have CSS variables
expect(normalizedVars["--color-base-blue"]).toBe("#0000ff");
expect(normalizedVars["--color-base-red"]).toBe("#ff0000");

// Aliased semantic tokens should NOT have CSS variables

expect(normalizedVars["--color-semantic-primary"]).toBeUndefined();
expect(normalizedVars["--color-semantic-danger"]).toBeUndefined();

// Non-aliased semantic token should have CSS variable
expect(stripHash(normalizedVars["--color-semantic-secondary"])).toBe(
"var(--color-base-blue)"
);

// Resolved tokens should all use var() references
const normalizedTokens = normalizeResolvedTokens(result.resolvedTokens);
expect(normalizedTokens.color.semantic.primary).toBe(
"var(--color-base-blue)"
);
expect(normalizedTokens.color.semantic.danger).toBe(
"var(--color-base-red)"
);
expect(normalizedTokens.color.semantic.secondary).toBe(
"var(--color-base-blue)"
);
});

it("handles raw value extraction in semantic tokens", () => {
const result = assignTokens(
composedValue({
Expand Down