diff --git a/src/app.svelte b/src/app.svelte index 797e1c2..17f769e 100644 --- a/src/app.svelte +++ b/src/app.svelte @@ -69,7 +69,12 @@ ]); }); - const rootNodes = $derived(treeState.getChildren(undefined)); + const rootNodes = $derived( + treeState + .getChildren(undefined) + // @todo temporary render only token-set + .filter((item) => item.meta.nodeType === "token-set"), + ); // svelte-ignore state_referenced_locally let selectedItems = new SvelteSet( diff --git a/src/css-variables.test.ts b/src/css-variables.test.ts index e63dcba..0c4da92 100644 --- a/src/css-variables.test.ts +++ b/src/css-variables.test.ts @@ -1,6 +1,7 @@ import { test, expect, describe } from "vitest"; import { generateCssVariables, parseCssVariables } from "./css-variables"; import { parseDesignTokens } from "./tokens"; +import { parseTokenResolver } from "./resolver"; import type { TreeNode } from "./store"; import type { TreeNodeMeta } from "./state.svelte"; @@ -1112,3 +1113,159 @@ describe("parseCssVariables", () => { }); }); }); + +describe("generateCssVariables with modifiers and contexts", () => { + test("skips modifier nodes and their entire subtrees", () => { + const result = parseTokenResolver({ + version: "2025.10", + resolutionOrder: [ + { + type: "set", + name: "base", + sources: [ + { + baseColor: { + $type: "color", + $value: { colorSpace: "srgb", components: [1, 0, 0] }, + }, + }, + ], + }, + { + type: "modifier", + name: "theme", + description: "Color theme", + contexts: { + light: [ + { + primary: { + $type: "color", + $value: { colorSpace: "srgb", components: [0, 0, 1] }, + }, + }, + ], + }, + }, + ], + }); + + expect(result.errors).toHaveLength(0); + const css = generateCssVariables(nodesToMap(result.nodes)); + + // Should contain token from set + expect(css).toContain("--base-color: rgb(100% 0% 0%);"); + + // Should NOT contain token from modifier's context (entire subtree skipped) + expect(css).not.toContain("--primary:"); + expect(css).not.toContain("theme"); + expect(css).not.toContain("light"); + }); + + test("skips tokens in all modifier contexts", () => { + const result = parseTokenResolver({ + version: "2025.10", + resolutionOrder: [ + { + type: "modifier", + name: "theme", + contexts: { + light: [ + { + background: { + $type: "color", + $value: { colorSpace: "srgb", components: [1, 1, 1] }, + }, + }, + ], + dark: [ + { + background: { + $type: "color", + $value: { colorSpace: "srgb", components: [0, 0, 0] }, + }, + }, + ], + }, + }, + ], + }); + + expect(result.errors).toHaveLength(0); + const css = generateCssVariables(nodesToMap(result.nodes)); + + // Should NOT contain background token from any context + expect(css).toContain(":root {\n}"); + }); + + test("skips nested groups and tokens under modifier contexts", () => { + const result = parseTokenResolver({ + version: "2025.10", + resolutionOrder: [ + { + type: "modifier", + name: "contrast", + contexts: { + high: [ + { + colors: { + $type: "color", + text: { + $value: { colorSpace: "srgb", components: [0, 0, 0] }, + }, + }, + }, + ], + }, + }, + ], + }); + + expect(result.errors).toHaveLength(0); + const css = generateCssVariables(nodesToMap(result.nodes)); + + // Should NOT contain any token from modifier context (entire subtree skipped) + expect(css).toContain(":root {\n}"); + }); + + test("skips tokens from multiple modifiers", () => { + const result = parseTokenResolver({ + version: "2025.10", + resolutionOrder: [ + { + type: "modifier", + name: "theme", + contexts: { + light: [ + { + background: { + $type: "color", + $value: { colorSpace: "srgb", components: [1, 1, 1] }, + }, + }, + ], + }, + }, + { + type: "modifier", + name: "contrast", + contexts: { + high: [ + { + text: { + $type: "color", + $value: { colorSpace: "srgb", components: [0, 0, 0] }, + }, + }, + ], + }, + }, + ], + }); + + expect(result.errors).toHaveLength(0); + const css = generateCssVariables(nodesToMap(result.nodes)); + + // Should NOT contain tokens from any modifier + expect(css).toContain(":root {\n}"); + }); +}); diff --git a/src/css-variables.ts b/src/css-variables.ts index 8c7b960..a2e909f 100644 --- a/src/css-variables.ts +++ b/src/css-variables.ts @@ -184,8 +184,16 @@ const processNode = ( lines: string[], nodes: Map>, ) => { + // token-modifier and token-context nodes and their entire subtrees should be skipped + if ( + node.meta.nodeType === "token-modifier" || + node.meta.nodeType === "token-context" + ) { + return; + } + // token-set is intended for grouping globals - // and should be omitted in generated variables + // and should be omitted in generated variables, but its children are processed if (node.meta.nodeType === "token-set") { const children = childrenByParent.get(node.nodeId) ?? []; for (const child of children) { diff --git a/src/export-dialog.svelte b/src/export-dialog.svelte index bcb34ff..330df25 100644 --- a/src/export-dialog.svelte +++ b/src/export-dialog.svelte @@ -17,6 +17,12 @@ // remove set node from data and serialize as DTCG format module const setIds = new Set(); for (const node of nodes.values()) { + if ( + node.meta.nodeType === "token-modifier" || + node.meta.nodeType === "token-context" + ) { + continue; + } if (node.meta.nodeType === "token-set") { setIds.add(node.nodeId); } else { diff --git a/src/resolver.test.ts b/src/resolver.test.ts index 5682b5a..1524174 100644 --- a/src/resolver.test.ts +++ b/src/resolver.test.ts @@ -1104,7 +1104,7 @@ describe("serializeTokenResolver", () => { expect(parseResult2.errors).toHaveLength(0); }); - test("skips modifier items and serializes only sets", () => { + test("serializes both sets and modifiers in resolutionOrder", () => { const resolver = parseTokenResolver({ version: "2025.10", resolutionOrder: [ @@ -1136,9 +1136,12 @@ describe("serializeTokenResolver", () => { const nodes = new Map(resolver.nodes.map((n) => [n.nodeId, n])); const document = serializeTokenResolver(nodes); - // Should only have the Base set, modifier is skipped - expect(document.resolutionOrder).toHaveLength(1); + // Should have both Base set and Theme modifier + expect(document.resolutionOrder).toHaveLength(2); expect(document.resolutionOrder[0].name).toBe("Base"); + expect(document.resolutionOrder[0].type).toBe("set"); + expect(document.resolutionOrder[1].name).toBe("Theme"); + expect(document.resolutionOrder[1].type).toBe("modifier"); }); }); @@ -1621,4 +1624,516 @@ describe("cross-set aliases", () => { ), ).toEqual(original); }); + + test("parses simple modifier with contexts", () => { + const result = parseTokenResolver({ + version: "2025.10", + resolutionOrder: [ + { + type: "modifier", + name: "theme", + contexts: { + light: [ + { + color: { + text: { + $type: "color", + $value: { colorSpace: "srgb", components: [0, 0, 0] }, + }, + }, + }, + ], + dark: [ + { + color: { + text: { + $type: "color", + $value: { colorSpace: "srgb", components: [1, 1, 1] }, + }, + }, + }, + ], + }, + default: "light", + }, + ], + }); + + expect(result.errors).toHaveLength(0); + + // Should have modifier node and context nodes + const modifierNode = result.nodes.find( + (n) => n.meta.nodeType === "token-modifier", + ); + expect(modifierNode).toBeDefined(); + expect(modifierNode?.meta.name).toBe("theme"); + + // Should have 2 context nodes (light and dark) + const contextNodes = result.nodes.filter( + (n) => n.meta.nodeType === "token-context", + ); + expect(contextNodes).toHaveLength(2); + expect(contextNodes.map((n) => n.meta.name).sort()).toEqual([ + "dark", + "light", + ]); + + // Contexts should have modifier as parent + contextNodes.forEach((context) => { + expect(context.parentId).toBe(modifierNode?.nodeId); + }); + + // Default should be NodeRef pointing to light context + if (modifierNode?.meta.nodeType === "token-modifier") { + const modifierMeta = modifierNode.meta; + expect(modifierMeta.default).toBeDefined(); + const defaultNode = result.nodes.find( + (n) => + n.nodeId === modifierMeta.default?.ref && + n.meta.nodeType === "token-context", + ); + expect(defaultNode?.meta.name).toBe("light"); + } + }); + + test("parses modifier contexts with multiple sources", () => { + const result = parseTokenResolver({ + version: "2025.10", + resolutionOrder: [ + { + type: "modifier", + name: "contrast", + contexts: { + normal: [ + { + color: { + text: { + $type: "color", + $value: { colorSpace: "srgb", components: [0.2, 0.2, 0.2] }, + }, + }, + }, + ], + high: [ + { + color: { + text: { + $type: "color", + $value: { colorSpace: "srgb", components: [0, 0, 0] }, + }, + }, + }, + { + color: { + text: { + $type: "color", + $value: { colorSpace: "srgb", components: [0, 0, 0] }, + }, // Override - last wins + }, + }, + ], + }, + }, + ], + }); + + expect(result.errors).toHaveLength(0); + + // Modifier and contexts should exist + const modifierNode = result.nodes.find( + (n) => n.meta.nodeType === "token-modifier", + ); + expect(modifierNode).toBeDefined(); + + const contextNodes = result.nodes.filter( + (n) => n.meta.nodeType === "token-context", + ); + expect(contextNodes).toHaveLength(2); + }); + + test("serializes modifier back to original format", () => { + const original = { + version: "2025.10" as const, + resolutionOrder: [ + { + type: "modifier" as const, + name: "theme", + contexts: { + light: [ + { + color: { + primary: { + $type: "color", + $value: { colorSpace: "srgb", components: [1, 0, 0] }, + }, + }, + }, + ], + dark: [ + { + color: { + primary: { + $type: "color", + $value: { colorSpace: "srgb", components: [0, 0, 0] }, + }, + }, + }, + ], + }, + default: "light", + }, + ], + }; + + const result = parseTokenResolver(original); + expect(result.errors).toHaveLength(0); + + const serialized = serializeTokenResolver( + new Map(result.nodes.map((node) => [node.nodeId, node])), + ); + + expect(serialized).toEqual(original); + }); + + test("preserves modifier description and extensions", () => { + const original = { + version: "2025.10" as const, + resolutionOrder: [ + { + type: "modifier" as const, + name: "theme", + description: "Color theme switcher", + contexts: { + light: [ + { + color: { + bg: { + $type: "color", + $value: { colorSpace: "srgb", components: [1, 1, 1] }, + }, + }, + }, + ], + dark: [ + { + color: { + bg: { + $type: "color", + $value: { colorSpace: "srgb", components: [0, 0, 0] }, + }, + }, + }, + ], + }, + $extensions: { + "figma.com": { + updatedAt: "2025-01-27", + }, + }, + }, + ], + }; + + const result = parseTokenResolver(original); + expect(result.errors).toHaveLength(0); + + const modifierNode = result.nodes.find( + (n) => n.meta.nodeType === "token-modifier", + ); + expect(modifierNode?.meta.description).toBe("Color theme switcher"); + expect(modifierNode?.meta.extensions).toEqual({ + "figma.com": { + updatedAt: "2025-01-27", + }, + }); + + const serialized = serializeTokenResolver( + new Map(result.nodes.map((node) => [node.nodeId, node])), + ); + expect(serialized).toEqual(original); + }); + + test("tokens under context are re-parented to context node", () => { + const result = parseTokenResolver({ + version: "2025.10", + resolutionOrder: [ + { + type: "modifier", + name: "theme", + contexts: { + light: [ + { + colors: { + primary: { + $type: "color", + $value: { colorSpace: "srgb", components: [1, 0, 0] }, + }, + }, + }, + ], + }, + }, + ], + }); + + expect(result.errors).toHaveLength(0); + + const contextNode = result.nodes.find( + (n) => n.meta.nodeType === "token-context", + ); + expect(contextNode).toBeDefined(); + + // Find token under context + const tokenNode = result.nodes.find( + (n) => n.meta.nodeType === "token" && n.meta.name === "primary", + ); + expect(tokenNode).toBeDefined(); + + // Token should have context as ancestor + let currentNode = tokenNode; + while (currentNode?.parentId) { + currentNode = result.nodes.find( + (n) => n.nodeId === currentNode?.parentId, + ); + if (currentNode?.meta.nodeType === "token-context") { + expect(currentNode.nodeId).toBe(contextNode?.nodeId); + return; + } + } + throw new Error("Token should have context as ancestor"); + }); +}); + +describe("modifier isolation - modifiers should not pollute global namespace", () => { + test("modifier can reference global set tokens", () => { + const result = parseTokenResolver({ + version: "2025.10", + resolutionOrder: [ + { + type: "set", + name: "Foundation", + sources: [ + { + colors: { + primary: { + $type: "color", + $value: { colorSpace: "srgb", components: [0, 0, 1] }, + }, + }, + }, + ], + }, + { + type: "modifier", + name: "theme", + contexts: { + dark: [ + { + colors: { + background: { + $type: "color", + $value: "{colors.primary}", + }, + }, + }, + ], + }, + }, + ], + }); + + expect(result.errors).toHaveLength(0); + + // Find the background token in the dark context + const backgroundToken = result.nodes.find( + (n) => n.meta.nodeType === "token" && n.meta.name === "background", + ); + expect(backgroundToken).toBeDefined(); + if (backgroundToken && backgroundToken.meta.nodeType === "token") { + // Should reference the primary token from Foundation set + expect(backgroundToken.meta.value).toHaveProperty("ref"); + } + }); + + test("modifier can reference own context tokens", () => { + const result = parseTokenResolver({ + version: "2025.10", + resolutionOrder: [ + { + type: "modifier", + name: "theme", + contexts: { + dark: [ + { + colors: { + primary: { + $type: "color", + $value: { colorSpace: "srgb", components: [0, 0, 1] }, + }, + background: { + $type: "color", + $value: "{colors.primary}", + }, + }, + }, + ], + }, + }, + ], + }); + + expect(result.errors).toHaveLength(0); + + // Find the background token + const backgroundToken = result.nodes.find( + (n) => n.meta.nodeType === "token" && n.meta.name === "background", + ); + expect(backgroundToken).toBeDefined(); + if (backgroundToken && backgroundToken.meta.nodeType === "token") { + // Should reference the primary token from the same context + expect(backgroundToken.meta.value).toHaveProperty("ref"); + } + }); + + test("modifier cannot reference tokens from other modifiers", () => { + const result = parseTokenResolver({ + version: "2025.10", + resolutionOrder: [ + { + type: "modifier", + name: "theme", + contexts: { + dark: [ + { + colors: { + primary: { + $type: "color", + $value: { colorSpace: "srgb", components: [0, 0, 1] }, + }, + }, + }, + ], + }, + }, + { + type: "modifier", + name: "contrast", + contexts: { + high: [ + { + colors: { + // This tries to reference a token from the 'theme' modifier + // which should fail because modifiers don't pollute global space + background: { + $type: "color", + $value: "{colors.primary}", + }, + }, + }, + ], + }, + }, + ], + }); + + // Should have an error because the reference cannot be resolved + expect(result.errors.length).toBeGreaterThan(0); + expect( + result.errors.some( + (e) => e.path.includes("background") || e.message.includes("not found"), + ), + ).toBe(true); + }); + + test("set cannot reference modifier tokens", () => { + const result = parseTokenResolver({ + version: "2025.10", + resolutionOrder: [ + { + type: "modifier", + name: "theme", + contexts: { + dark: [ + { + colors: { + primary: { + $type: "color", + $value: { colorSpace: "srgb", components: [0, 0, 1] }, + }, + }, + }, + ], + }, + }, + { + type: "set", + name: "Components", + sources: [ + { + button: { + // This tries to reference a token from the 'theme' modifier + // which should fail because modifiers don't pollute global space + background: { + $type: "color", + $value: "{colors.primary}", + }, + }, + }, + ], + }, + ], + }); + + // Should have an error because the reference cannot be resolved + expect(result.errors.length).toBeGreaterThan(0); + expect( + result.errors.some( + (e) => e.path.includes("background") || e.message.includes("not found"), + ), + ).toBe(true); + }); + + test("modifier contexts are isolated from each other", () => { + const result = parseTokenResolver({ + version: "2025.10", + resolutionOrder: [ + { + type: "modifier", + name: "theme", + contexts: { + light: [ + { + colors: { + primary: { + $type: "color", + $value: { colorSpace: "srgb", components: [1, 1, 1] }, + }, + }, + }, + ], + dark: [ + { + colors: { + // This tries to reference 'primary' from the light context + // which should fail because contexts within a modifier are also isolated + background: { + $type: "color", + $value: "{colors.primary}", + }, + }, + }, + ], + }, + }, + ], + }); + + // Should have an error because dark context cannot reference light context tokens + expect(result.errors.length).toBeGreaterThan(0); + expect( + result.errors.some( + (e) => e.path.includes("background") || e.message.includes("not found"), + ), + ).toBe(true); + }); }); diff --git a/src/resolver.ts b/src/resolver.ts index a97a8de..31c7164 100644 --- a/src/resolver.ts +++ b/src/resolver.ts @@ -4,6 +4,7 @@ import { resolverDocumentSchema, type ResolverDocument, type ResolverSet, + type ResolverModifier, } from "./dtcg.schema"; import { serializeDesignTokens, @@ -17,6 +18,8 @@ import type { SetMeta, TokenMeta, TreeNodeMeta, + ModifierMeta, + ContextMeta, } from "./state.svelte"; import type { TreeNode } from "./store"; @@ -101,79 +104,197 @@ export const parseTokenResolver = (input: unknown): ParseResult => { const lastChildIndexPerParent = new Map(); const zeroIndex = generateKeyBetween(null, null); - // PHASE 1: Extract intermediary nodes from all sets + // PHASE 1: Extract intermediary nodes from all sets and modifiers // This collects all tokens/groups with their paths, without resolving references yet - const allIntermediaryNodes = new Map(); + // Global intermediary nodes only contain set nodes (not modifiers) to prevent pollution + const globalIntermediaryNodes = new Map(); const intermediaryNodesBySet = new Map< string, Map >(); for (const item of resolverDoc.resolutionOrder) { - // Silently skip modifier items - if (item.type === "modifier") { + if (item.type === "set") { + const mergedSetSources = mergeSources(item.sources); + const { nodes, errors } = extractIntermediaryNodes(mergedSetSources); + intermediaryNodesBySet.set(item.name, nodes); + // Only add set nodes to global namespace - modifiers are conditional + for (const [path, node] of nodes) { + globalIntermediaryNodes.set(path, node); + } + collectedErrors.push(...errors); continue; } - const mergedSetSources = mergeSources(item.sources); - const { nodes, errors } = extractIntermediaryNodes(mergedSetSources); - intermediaryNodesBySet.set(item.name, nodes); - for (const [path, node] of nodes) { - allIntermediaryNodes.set(path, node); + + if (item.type === "modifier") { + // Extract intermediary nodes from each context's sources + for (const [contextName, sources] of Object.entries(item.contexts)) { + const mergedContextSources = mergeSources(sources); + const { nodes, errors } = + extractIntermediaryNodes(mergedContextSources); + // Use unique key for context to track its nodes separately + const contextKey = `${item.name}/${contextName}`; + intermediaryNodesBySet.set(contextKey, nodes); + // Do NOT add modifier nodes to globalIntermediaryNodes + // Modifiers are conditional and should not pollute global namespace + collectedErrors.push(...errors); + } + continue; } - collectedErrors.push(...errors); + + item satisfies never; } // PHASE 2: Resolve intermediary nodes with cross-set availability // Now that we have all tokens/groups from all sets, resolve references with full visibility for (const item of resolverDoc.resolutionOrder) { - if (item.type === "modifier") { - // Silently skip modifier items - continue; - } - // Get this set's intermediary nodes - const intermediaryNodes = intermediaryNodesBySet.get(item.name); - if (!intermediaryNodes) { + if (item.type === "set") { + // Set processing + // Get this set's intermediary nodes + const intermediaryNodes = intermediaryNodesBySet.get(item.name); + if (!intermediaryNodes) { + continue; + } + // Resolve this set's intermediary nodes, using only global nodes for reference lookup + // Sets can only reference other set tokens (global namespace), not modifier tokens + const { nodes, errors } = resolveIntermediaryNodes( + intermediaryNodes, + globalIntermediaryNodes, + ); + + // Create a new token-set node for this Set + const setNodeId = crypto.randomUUID(); + const prevSetIndex = lastChildIndexPerParent.get(undefined); + const newSetIndex = generateKeyBetween(prevSetIndex ?? zeroIndex, null); + lastChildIndexPerParent.set(undefined, newSetIndex); + + const setNode: TreeNode = { + nodeId: setNodeId, + parentId: undefined, + index: newSetIndex, + meta: { + nodeType: "token-set", + name: item.name, + description: item.description, + extensions: item.$extensions, + }, + }; + + // Add the token-set node + allNodes.push(setNode); + + // Re-parent root-level tokens/groups from this Set to the token-set node + // Only set parentId for nodes at root level (parentId is undefined) + // This preserves the hierarchy of nested groups and tokens within the Set + for (const node of nodes) { + if (node.parentId === undefined) { + node.parentId = setNodeId; + } + allNodes.push(node); + } + + // Collect errors from this Set + collectedErrors.push(...errors); continue; } - // Resolve this set's intermediary nodes, using all accumulated nodes for reference lookup - const { nodes, errors } = resolveIntermediaryNodes( - intermediaryNodes, - allIntermediaryNodes, - ); - // Create a new token-set node for this Set - const setNodeId = crypto.randomUUID(); - const prevSetIndex = lastChildIndexPerParent.get(undefined); - const newSetIndex = generateKeyBetween(prevSetIndex ?? zeroIndex, null); - lastChildIndexPerParent.set(undefined, newSetIndex); - - const setNode: TreeNode = { - nodeId: setNodeId, - parentId: undefined, - index: newSetIndex, - meta: { - nodeType: "token-set", - name: item.name, - description: item.description, - extensions: item.$extensions, - }, - }; + if (item.type === "modifier") { + // Create token-modifier node at root level + const modifierNodeId = crypto.randomUUID(); + const prevModifierIndex = lastChildIndexPerParent.get(undefined); + const newModifierIndex = generateKeyBetween( + prevModifierIndex ?? zeroIndex, + null, + ); + lastChildIndexPerParent.set(undefined, newModifierIndex); + + const modifierNode: TreeNode = { + nodeId: modifierNodeId, + parentId: undefined, + index: newModifierIndex, + meta: { + nodeType: "token-modifier", + name: item.name, + description: item.description, + extensions: item.$extensions, + default: undefined, // Will be set after contexts created + }, + }; + allNodes.push(modifierNode); + + // Process each context + let defaultContextNodeId: string | undefined; + + for (const contextName of Object.keys(item.contexts)) { + const contextNodeId = crypto.randomUUID(); + const contextKey = `${item.name}/${contextName}`; + + // Get this context's intermediary nodes + const contextIntermediaryNodes = intermediaryNodesBySet.get(contextKey); + if (!contextIntermediaryNodes) { + continue; + } + + // Create combined map: context nodes + global nodes + // Modifiers can reference their own nodes OR global set nodes, but NOT other modifier nodes + const modifierAvailableNodes = new Map(globalIntermediaryNodes); + for (const [path, node] of contextIntermediaryNodes) { + modifierAvailableNodes.set(path, node); + } - // Add the token-set node - allNodes.push(setNode); + // Resolve context's intermediary nodes using combined map + const { nodes: resolvedContextNodes, errors: contextErrors } = + resolveIntermediaryNodes( + contextIntermediaryNodes, + modifierAvailableNodes, + ); - // Re-parent root-level tokens/groups from this Set to the token-set node - // Only set parentId for nodes at root level (parentId is undefined) - // This preserves the hierarchy of nested groups and tokens within the Set - for (const node of nodes) { - if (node.parentId === undefined) { - node.parentId = setNodeId; + // Create token-context node + const contextNode: TreeNode = { + nodeId: contextNodeId, + parentId: modifierNodeId, + index: generateKeyBetween( + lastChildIndexPerParent.get(modifierNodeId) ?? zeroIndex, + null, + ), + meta: { + nodeType: "token-context", + name: contextName, + }, + }; + + // Track index for next sibling context + lastChildIndexPerParent.set(modifierNodeId, contextNode.index); + + allNodes.push(contextNode); + + // Re-parent tokens/groups from context sources to context node + // Only root-level tokens/groups (parentId === undefined) get re-parented + // This preserves nested group hierarchies + for (const node of resolvedContextNodes) { + if (node.parentId === undefined) { + node.parentId = contextNodeId; + } + allNodes.push(node); + } + + collectedErrors.push(...contextErrors); + + // Track default context + if (item.default === contextName) { + defaultContextNodeId = contextNodeId; + } + } + + // Set modifier's default after all contexts created + if (defaultContextNodeId) { + modifierNode.meta.default = { + ref: defaultContextNodeId, + }; } - allNodes.push(node); - } - // Collect errors from this Set - collectedErrors.push(...errors); + continue; + } } return { @@ -206,42 +327,72 @@ export const serializeTokenResolver = ( metadata?: { name?: string; description?: string }, ): ResolverDocument => { const setNodes: Array> = []; + const modifierNodes: Array> = []; + for (const node of nodes.values()) { - if (node.parentId === undefined && node.meta.nodeType === "token-set") { - setNodes.push(node as TreeNode); + if (node.parentId === undefined) { + if (node.meta.nodeType === "token-set") { + setNodes.push(node as TreeNode); + } + if (node.meta.nodeType === "token-modifier") { + modifierNodes.push(node as TreeNode); + } } } // Sort by index to maintain document order setNodes.sort(compareTreeNodes); - const resolutionOrder: ResolverSet[] = []; - for (const setNode of setNodes) { - // Create a filtered map containing only this set's descendants (excluding the set node itself) - // serializeDesignTokens expects token and group nodes, not token-set nodes - const setSubtree = new Map>(); - const collectDescendants = (nodeId: string | undefined) => { + modifierNodes.sort(compareTreeNodes); + + const resolutionOrder: (ResolverSet | ResolverModifier)[] = []; + + // Helper function to collect descendants (used for both sets and contexts) + const collectDescendants = (parentNodeId: string | undefined) => { + const subtree = new Map>(); + const _collect = (nodeId: string | undefined) => { let node = nodeId ? nodes.get(nodeId) : undefined; if (!node) { return; } - // Skip the token-set node itself, only collect token and group children - if (node.meta.nodeType !== "token-set") { - // Re-parent direct children of token-set to root (undefined) - if (node.parentId === setNode.nodeId) { - // avoid mutating original nodes - node = { ...node, parentId: undefined }; + // Skip the specified node type and token-modifier/token-context nodes + if ( + node.meta.nodeType === "token-set" || + node.meta.nodeType === "token-modifier" || + node.meta.nodeType === "token-context" + ) { + // But continue collecting descendants + for (const child of nodes.values()) { + if (child.parentId === nodeId) { + _collect(child.nodeId); + } } - setSubtree.set(node.nodeId, node); + return; } + + // Re-parent direct children of parent to root (undefined) + if (node.parentId === parentNodeId && parentNodeId !== undefined) { + // avoid mutating original nodes + node = { ...node, parentId: undefined }; + } + subtree.set(node.nodeId, node); + // Recursively collect all children for (const child of nodes.values()) { if (child.parentId === nodeId) { - collectDescendants(child.nodeId); + _collect(child.nodeId); } } }; + _collect(parentNodeId); + return subtree; + }; + + // Serialize sets + for (const setNode of setNodes) { + // Create a filtered map containing only this set's descendants (excluding the set node itself) + // serializeDesignTokens expects token and group nodes, not token-set nodes + const setSubtree = collectDescendants(setNode.nodeId); - collectDescendants(setNode.nodeId); const source = serializeDesignTokens( setSubtree as Map>, nodes as Map>, // Pass all nodes for cross-set reference lookup @@ -255,6 +406,61 @@ export const serializeTokenResolver = ( }); } + // Serialize modifiers + for (const modifierNode of modifierNodes) { + // Collect all context nodes for this modifier + const contextNodes: Array> = []; + for (const node of nodes.values()) { + if ( + node.parentId === modifierNode.nodeId && + node.meta.nodeType === "token-context" + ) { + contextNodes.push(node as TreeNode); + } + } + contextNodes.sort(compareTreeNodes); + + // Build contexts map + const contexts: Record = {}; + for (const contextNode of contextNodes) { + // Create subtree containing only this context's descendants + const contextSubtree = collectDescendants(contextNode.nodeId); + + // Serialize tokens/groups under this context + // Note: serializeDesignTokens inlines all sources + const serialized = serializeDesignTokens( + contextSubtree as Map>, + nodes as Map>, // Pass all nodes for cross-set reference lookup + ); + contexts[contextNode.meta.name] = [serialized]; + } + + // Find default context name from default NodeRef + let defaultContextName: string | undefined; + if (modifierNode.meta.default) { + const defaultNode = nodes.get(modifierNode.meta.default.ref); + if (defaultNode?.meta.nodeType === "token-context") { + defaultContextName = defaultNode.meta.name; + } + } + + // Build ResolverModifier + const modifier: ResolverModifier = { + type: "modifier", + name: modifierNode.meta.name, + contexts, + ...(modifierNode.meta.description && { + description: modifierNode.meta.description, + }), + ...(defaultContextName && { default: defaultContextName }), + ...(modifierNode.meta.extensions && { + $extensions: modifierNode.meta.extensions, + }), + }; + + resolutionOrder.push(modifier); + } + return { version: "2025.10", name: metadata?.name, diff --git a/src/scss.test.ts b/src/scss.test.ts index 0ae5e2c..0e7b448 100644 --- a/src/scss.test.ts +++ b/src/scss.test.ts @@ -1,6 +1,7 @@ import { test, expect, describe } from "vitest"; import { generateScssVariables } from "./scss"; import { parseDesignTokens } from "./tokens"; +import { parseTokenResolver } from "./resolver"; import type { TreeNode } from "./store"; import type { TreeNodeMeta } from "./state.svelte"; @@ -579,4 +580,116 @@ describe("generateScssVariables", () => { "$typography-body: $weights-normal $sizes-base/1.5 $fonts-body", ); }); + + test("skips modifier nodes and their entire subtrees", () => { + const result = parseTokenResolver({ + version: "2025.10", + resolutionOrder: [ + { + type: "set", + name: "base", + sources: [ + { + baseColor: { + $type: "color", + $value: { colorSpace: "srgb", components: [1, 0, 0] }, + }, + }, + ], + }, + { + type: "modifier", + name: "theme", + description: "Color theme", + contexts: { + light: [ + { + primary: { + $type: "color", + $value: { colorSpace: "srgb", components: [0, 0, 1] }, + }, + }, + ], + }, + }, + ], + }); + + expect(result.errors).toHaveLength(0); + const scss = generateScssVariables(nodesToMap(result.nodes)); + + // Should contain token from set + expect(scss).toContain("$base-color: rgb(100% 0% 0%);"); + + // Should NOT contain token from modifier's context (entire subtree skipped) + expect(scss).not.toContain("$primary:"); + expect(scss).not.toContain("theme"); + expect(scss).not.toContain("light"); + }); + + test("skips tokens in all modifier contexts", () => { + const result = parseTokenResolver({ + version: "2025.10", + resolutionOrder: [ + { + type: "modifier", + name: "theme", + contexts: { + light: [ + { + background: { + $type: "color", + $value: { colorSpace: "srgb", components: [1, 1, 1] }, + }, + }, + ], + dark: [ + { + background: { + $type: "color", + $value: { colorSpace: "srgb", components: [0, 0, 0] }, + }, + }, + ], + }, + }, + ], + }); + + expect(result.errors).toHaveLength(0); + const scss = generateScssVariables(nodesToMap(result.nodes)); + + // Should NOT contain background token from any context + expect(scss).toBe(""); + }); + + test("skips nested groups and tokens under modifier contexts", () => { + const result = parseTokenResolver({ + version: "2025.10", + resolutionOrder: [ + { + type: "modifier", + name: "contrast", + contexts: { + high: [ + { + colors: { + $type: "color", + text: { + $value: { colorSpace: "srgb", components: [0, 0, 0] }, + }, + }, + }, + ], + }, + }, + ], + }); + + expect(result.errors).toHaveLength(0); + const scss = generateScssVariables(nodesToMap(result.nodes)); + + // Should NOT contain any token from modifier context (entire subtree skipped) + expect(scss).toBe(""); + }); }); diff --git a/src/scss.ts b/src/scss.ts index b792f40..ef4f8cf 100644 --- a/src/scss.ts +++ b/src/scss.ts @@ -162,8 +162,16 @@ const processNode = ( lines: string[], nodes: Map>, ) => { + // token-modifier and token-context nodes and their entire subtrees should be skipped + if ( + node.meta.nodeType === "token-modifier" || + node.meta.nodeType === "token-context" + ) { + return; + } + // token-set is intended for grouping globals - // and should be omitted in generated variables + // and should be omitted in generated variables, but its children are processed if (node.meta.nodeType === "token-set") { const children = childrenByParent.get(node.nodeId) ?? []; for (const child of children) { diff --git a/src/state.svelte.ts b/src/state.svelte.ts index 20af1a1..7d7bba2 100644 --- a/src/state.svelte.ts +++ b/src/state.svelte.ts @@ -34,6 +34,21 @@ export type TokenMeta = { extensions?: Record; } & RawValueWithReference; +export type ModifierMeta = { + nodeType: "token-modifier"; + name: string; + description?: string; + default?: NodeRef; + extensions?: Record; +}; + +export type ContextMeta = { + nodeType: "token-context"; + name: string; + description?: string; + extensions?: Record; +}; + /** * Helper function to find the type of a token * Searches through reference chain or parent group hierarchy @@ -42,8 +57,12 @@ export const findTokenType = ( node: TreeNode, nodes: Map>, ): Value["type"] | undefined => { - // Token-set nodes don't have types - if (node.meta.nodeType === "token-set") { + // Node types without explicit types (no $type inheritance) + if ( + node.meta.nodeType === "token-set" || + node.meta.nodeType === "token-modifier" || + node.meta.nodeType === "token-context" + ) { return; } // If token has explicit type, use it @@ -327,7 +346,12 @@ export const isAliasCircular = ( return false; // No circular dependency }; -export type TreeNodeMeta = GroupMeta | TokenMeta | SetMeta; +export type TreeNodeMeta = + | GroupMeta + | TokenMeta + | SetMeta + | ModifierMeta + | ContextMeta; export class TreeState { #store = new TreeStore();