diff --git a/package-lock.json b/package-lock.json index a1d0d2622..36a51cca3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7438,6 +7438,8 @@ "version": "1.2.0", "license": "CC0-1.0", "dependencies": { + "@csstools/css-parser-algorithms": "^1.0.0", + "@csstools/css-tokenizer": "^1.0.0", "postcss-value-parser": "^4.2.0" }, "devDependencies": { @@ -9289,6 +9291,8 @@ "@csstools/postcss-design-tokens": { "version": "file:plugins/postcss-design-tokens", "requires": { + "@csstools/css-parser-algorithms": "^1.0.0", + "@csstools/css-tokenizer": "^1.0.0", "postcss-import": "^15.0.0", "postcss-value-parser": "^4.2.0", "style-dictionary-design-tokens-example": "^1.1.0" diff --git a/plugins/postcss-design-tokens/.tape.mjs b/plugins/postcss-design-tokens/.tape.mjs index 7022b87a9..4ab9117a5 100644 --- a/plugins/postcss-design-tokens/.tape.mjs +++ b/plugins/postcss-design-tokens/.tape.mjs @@ -11,6 +11,13 @@ postcssTape(plugin)({ ], warnings: 1 }, + 'at-rule': { + message: "supports at rules", + }, + 'at-rule-error': { + message: "supports at rules", + warnings: 1 + }, 'units': { message: "supports units usage", plugins: [ diff --git a/plugins/postcss-design-tokens/CHANGELOG.md b/plugins/postcss-design-tokens/CHANGELOG.md index 8435c0822..3edf7adab 100644 --- a/plugins/postcss-design-tokens/CHANGELOG.md +++ b/plugins/postcss-design-tokens/CHANGELOG.md @@ -3,6 +3,7 @@ ### Unreleased (major) - Updated: Support for Node v14+ (major). +- Added support for design tokens in at rules (`@media`, `@supports`, ...) ### 1.2.0 (September 7, 2022) diff --git a/plugins/postcss-design-tokens/README.md b/plugins/postcss-design-tokens/README.md index 157c91b64..7d55d020d 100644 --- a/plugins/postcss-design-tokens/README.md +++ b/plugins/postcss-design-tokens/README.md @@ -13,8 +13,12 @@ }, "size": { "spacing": { - "small": { "value": "16px" } + "small": { "value": "16px" }, + "medium": { "value": "18px" } } + }, + "viewport": { + "medium": { "value": "35rem" } } } ``` @@ -29,6 +33,12 @@ padding-bottom: design-token('size.spacing.small' to rem); } +@media (min-width: design-token('viewport.medium')) { + .foo { + padding-bottom: design-token('size.spacing.medium' to rem); + } +} + /* becomes */ .foo { @@ -37,6 +47,12 @@ padding-left: 16px; padding-bottom: 1rem; } + +@media (min-width: 35rem) { + .foo { + padding-bottom: 1.1rem; + } +} ``` ## Usage @@ -195,6 +211,12 @@ postcssDesignTokens({ padding-bottom: design-token('size.spacing.small' to rem); } +@media (min-width: design-token('viewport.medium')) { + .foo { + padding-bottom: design-token('size.spacing.medium' to rem); + } +} + /* becomes */ .foo { @@ -203,6 +225,12 @@ postcssDesignTokens({ padding-left: 16px; padding-bottom: 0.8rem; } + +@media (min-width: 35rem) { + .foo { + padding-bottom: 0.9rem; + } +} ``` ### Customize function and at rule names @@ -290,7 +318,7 @@ The `@design-tokens` rule is used to import design tokens from a JSON file into @design-tokens url('./tokens-dark-mode.json') format('style-dictionary3') when('dark'); ``` -You can also import tokens from an `npm` pacakge: +You can also import tokens from an `npm` package: ```pcss @design-tokens url('node_modules://my-npm-package/tokens.json') format('style-dictionary3'); diff --git a/plugins/postcss-design-tokens/docs/README.md b/plugins/postcss-design-tokens/docs/README.md index 8c4627335..8482c890e 100644 --- a/plugins/postcss-design-tokens/docs/README.md +++ b/plugins/postcss-design-tokens/docs/README.md @@ -187,7 +187,7 @@ The `@design-tokens` rule is used to import design tokens from a JSON file into @design-tokens url('./tokens-dark-mode.json') format('style-dictionary3') when('dark'); ``` -You can also import tokens from an `npm` pacakge: +You can also import tokens from an `npm` package: ```pcss @design-tokens url('node_modules://my-npm-package/tokens.json') format('style-dictionary3'); diff --git a/plugins/postcss-design-tokens/package.json b/plugins/postcss-design-tokens/package.json index 04063556c..e7bb182f1 100644 --- a/plugins/postcss-design-tokens/package.json +++ b/plugins/postcss-design-tokens/package.json @@ -38,6 +38,8 @@ "dist" ], "dependencies": { + "@csstools/css-parser-algorithms": "^1.0.0", + "@csstools/css-tokenizer": "^1.0.0", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { diff --git a/plugins/postcss-design-tokens/src/index.ts b/plugins/postcss-design-tokens/src/index.ts index 7c095ac27..ed00108ab 100644 --- a/plugins/postcss-design-tokens/src/index.ts +++ b/plugins/postcss-design-tokens/src/index.ts @@ -3,7 +3,7 @@ import { Token } from './data-formats/base/token'; import { tokensFromImport } from './data-formats/parse-import'; import { mergeTokens } from './data-formats/token'; import { parsePluginOptions, pluginOptions } from './options'; -import { onCSSValue } from './values'; +import { transform } from './transform'; const creator: PluginCreator = (opts?: pluginOptions) => { const options = parsePluginOptions(opts); @@ -68,12 +68,32 @@ const creator: PluginCreator = (opts?: pluginOptions) => { return; } - const modifiedValue = onCSSValue(tokens, result, decl, options); - if (modifiedValue === decl.value) { + try { + const modifiedValue = transform(tokens, result, decl, decl.value, options); + if (modifiedValue === decl.value) { + return; + } + + decl.value = modifiedValue; + } catch (err) { + decl.warn(result, `Failed to parse and transform "${decl.value}"`); + } + }, + AtRule(atRule, { result }) { + if (!atRule.params.toLowerCase().includes(options.valueFunctionName)) { return; } - decl.value = modifiedValue; + try { + const modifiedValue = transform(tokens, result, atRule, atRule.params, options); + if (modifiedValue === atRule.params) { + return; + } + + atRule.params = modifiedValue; + } catch(err) { + atRule.warn(result, `Failed to parse and transform "${atRule.params}"`); + } }, }; }, diff --git a/plugins/postcss-design-tokens/src/parse-component-values.ts b/plugins/postcss-design-tokens/src/parse-component-values.ts new file mode 100644 index 000000000..13b8cb659 --- /dev/null +++ b/plugins/postcss-design-tokens/src/parse-component-values.ts @@ -0,0 +1,33 @@ +import { parseListOfComponentValues } from '@csstools/css-parser-algorithms'; +import { CSSToken, tokenizer } from '@csstools/css-tokenizer'; + +export function parseComponentValuesFromTokens(tokens: Array) { + return parseListOfComponentValues(tokens, { + onParseError: (err) => { + throw new Error(JSON.stringify(err)); + }, + }); +} + +export function parseComponentValues(source: string) { + const t = tokenizer({ css: source }, { + commentsAreTokens: true, + onParseError: (err) => { + throw new Error(JSON.stringify(err)); + }, + }); + + const tokens: Array = []; + + { + while (!t.endOfFile()) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + tokens.push(t.nextToken()!); + } + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + tokens.push(t.nextToken()!); // EOF-token + } + + return parseComponentValuesFromTokens(tokens); +} diff --git a/plugins/postcss-design-tokens/src/transform.ts b/plugins/postcss-design-tokens/src/transform.ts new file mode 100644 index 000000000..e641fdb98 --- /dev/null +++ b/plugins/postcss-design-tokens/src/transform.ts @@ -0,0 +1,135 @@ +import { ComponentValue, isCommentNode, isFunctionNode, isTokenNode, isWhitespaceNode } from '@csstools/css-parser-algorithms'; +import { TokenType } from '@csstools/css-tokenizer'; +import type { Node, Result } from 'postcss'; +import { Token, TokenTransformOptions } from './data-formats/base/token'; +import { parsedPluginOptions } from './options'; +import { parseComponentValues } from './parse-component-values'; + +export function transform(tokens: Map, result: Result, postCSSNode: Node, source: string, opts: parsedPluginOptions) { + const componentValues = parseComponentValues(source); + + let didChangeSomething = false; + componentValues.forEach((componentValue, index) => { + if (!('walk' in componentValue)) { + return; + } + + { + const replacements = transformComponentValue(componentValue, tokens, result, postCSSNode, opts); + if (replacements) { + componentValues.splice(index, 1, ...replacements); + didChangeSomething = true; + return false; + } + } + + componentValue.walk((entry, nodeIndex) => { + if (typeof nodeIndex === 'string') { + // Should never happen in FunctionNode + return; + } + + const replacements = transformComponentValue(entry.node, tokens, result, postCSSNode, opts); + if (replacements) { + entry.parent.value.splice(nodeIndex, 1, ...replacements); + didChangeSomething = true; + return false; + } + }); + }); + + if (!didChangeSomething) { + return source; + } + + return componentValues.map((x) => x.toString()).join(''); +} + + +function transformComponentValue(node: ComponentValue, tokens: Map, result: Result, postCSSNode: Node, opts: parsedPluginOptions) { + if (!isFunctionNode(node)) { + return; + } + + if (node.nameTokenValue().toLowerCase() !== opts.valueFunctionName) { + return; + } + + let tokenName = ''; + let operator = ''; + let operatorSubject = ''; + + for (let i = 0; i < node.value.length; i++) { + const subValue = node.value[i]; + if (isWhitespaceNode(subValue) || isCommentNode(subValue)) { + continue; + } + + if ( + !tokenName && + isTokenNode(subValue) && + subValue.value[0] === TokenType.String + ) { + tokenName = subValue.value[4].value; + continue; + } + + if ( + tokenName && + !operator && + isTokenNode(subValue) && + subValue.value[0] === TokenType.Ident && + subValue.value[4].value.toLowerCase() === 'to' + ) { + operator = 'to'; + continue; + } + + if ( + tokenName && + operator && + isTokenNode(subValue) && + subValue.value[0] === TokenType.Ident + ) { + operatorSubject = subValue.value[4].value; + continue; + } + + break; + } + + if (!tokenName) { + postCSSNode.warn(result, 'Expected at least a single string literal for the design-token function.'); + return; + } + + const replacement = tokens.get(tokenName); + if (!replacement) { + postCSSNode.warn(result, `design-token: "${tokenName}" is not configured.`); + return; + } + + if (!operator) { + return parseComponentValues(replacement.cssValue()); + } + + const transformOptions: TokenTransformOptions = { + pluginOptions: opts.unitsAndValues, + }; + + if (operator === 'to') { + if (!operatorSubject) { + postCSSNode.warn(result, `Invalid or missing unit in "${node.toString()}"`); + return; + } + + transformOptions.toUnit = operatorSubject; + + try { + return parseComponentValues(replacement.cssValue(transformOptions)); + } catch (err) { + postCSSNode.warn(result, (err as Error).message); + return; + } + } +} diff --git a/plugins/postcss-design-tokens/src/values.ts b/plugins/postcss-design-tokens/src/values.ts deleted file mode 100644 index 1a9956a8f..000000000 --- a/plugins/postcss-design-tokens/src/values.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { Declaration, Result } from 'postcss'; -import valueParser from 'postcss-value-parser'; -import { Token, TokenTransformOptions } from './data-formats/base/token'; -import { parsedPluginOptions } from './options'; - -export function onCSSValue(tokens: Map, result: Result, decl: Declaration, opts: parsedPluginOptions) { - const valueAST = valueParser(decl.value); - - valueAST.walk(node => { - if (node.type !== 'function' || node.value.toLowerCase() !== opts.valueFunctionName) { - return; - } - - if (!node.nodes || node.nodes.length === 0) { - decl.warn(result, 'Expected at least a single string literal for the design-token function.'); - return; - } - - if (node.nodes[0].type !== 'string') { - decl.warn(result, 'Expected at least a single string literal for the design-token function.'); - return; - } - - const tokenName = node.nodes[0].value; - const replacement = tokens.get(tokenName); - if (!replacement) { - decl.warn(result, `design-token: "${tokenName}" is not configured.`); - return; - } - - const remainingNodes = node.nodes.slice(1).filter(x => x.type === 'word'); - if (!remainingNodes.length) { - node.value = replacement.cssValue(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (node as any).nodes = undefined; - return; - } - - const transformOptions: TokenTransformOptions = { - pluginOptions: opts.unitsAndValues, - }; - for (let i = 0; i < remainingNodes.length; i++) { - if ( - remainingNodes[i].type === 'word' && - remainingNodes[i].value.toLowerCase() === 'to' && - remainingNodes[i + 1] && - remainingNodes[i + 1].type === 'word' - ) { - transformOptions.toUnit = remainingNodes[i + 1].value; - i++; - } - } - - try { - node.value = replacement.cssValue(transformOptions); - } catch (err) { - decl.warn(result, (err as Error).message); - return; - } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (node as any).nodes = undefined; - }); - - return String(valueAST); -} diff --git a/plugins/postcss-design-tokens/test/at-rule-error.css b/plugins/postcss-design-tokens/test/at-rule-error.css new file mode 100644 index 000000000..39fb0aebd --- /dev/null +++ b/plugins/postcss-design-tokens/test/at-rule-error.css @@ -0,0 +1 @@ +@supports (min-width: rgb(design-token('lists.comma' diff --git a/plugins/postcss-design-tokens/test/at-rule-error.expect.css b/plugins/postcss-design-tokens/test/at-rule-error.expect.css new file mode 100644 index 000000000..39fb0aebd --- /dev/null +++ b/plugins/postcss-design-tokens/test/at-rule-error.expect.css @@ -0,0 +1 @@ +@supports (min-width: rgb(design-token('lists.comma' diff --git a/plugins/postcss-design-tokens/test/at-rule.css b/plugins/postcss-design-tokens/test/at-rule.css new file mode 100644 index 000000000..7d4d09631 --- /dev/null +++ b/plugins/postcss-design-tokens/test/at-rule.css @@ -0,0 +1,19 @@ +@design-tokens url('./tokens/basic.json') format('style-dictionary3'); + +@media screen and (min-width: design-token('space.large-b')) { + .foo { + order: 1; + } +} + +@media screen and (min-width: calc(design-token('space.large-b') * 10)) { + .foo { + order: 2; + } +} + +@supports (color: rgb(design-token('lists.comma'))) { + .foo { + order: 3; + } +} diff --git a/plugins/postcss-design-tokens/test/at-rule.expect.css b/plugins/postcss-design-tokens/test/at-rule.expect.css new file mode 100644 index 000000000..2e99522e5 --- /dev/null +++ b/plugins/postcss-design-tokens/test/at-rule.expect.css @@ -0,0 +1,17 @@ +@media screen and (min-width: 2rem) { + .foo { + order: 1; + } +} + +@media screen and (min-width: calc(2rem * 10)) { + .foo { + order: 2; + } +} + +@supports (color: rgb(255, 0, 255)) { + .foo { + order: 3; + } +} diff --git a/plugins/postcss-design-tokens/test/basic.css b/plugins/postcss-design-tokens/test/basic.css index 6f484a0c6..61790da39 100644 --- a/plugins/postcss-design-tokens/test/basic.css +++ b/plugins/postcss-design-tokens/test/basic.css @@ -22,3 +22,12 @@ ); color: design-token('does.not.exist'); } + +.lists { + margin: design-token('lists.space'); + color: rgb(design-token('lists.comma')); + complex: does-not-exist( + foo(design-token('lists.space')), + bar(design-token('lists.comma')) + ); +} diff --git a/plugins/postcss-design-tokens/test/basic.expect.css b/plugins/postcss-design-tokens/test/basic.expect.css index 1469720e6..ece7836b9 100644 --- a/plugins/postcss-design-tokens/test/basic.expect.css +++ b/plugins/postcss-design-tokens/test/basic.expect.css @@ -12,3 +12,11 @@ color: red; color: design-token('does.not.exist'); } +.lists { + margin: 0.5rem 0.25rem; + color: rgb(255, 0, 255); + complex: does-not-exist( + foo(0.5rem 0.25rem), + bar(255, 0, 255) + ); +} diff --git a/plugins/postcss-design-tokens/test/examples/example.css b/plugins/postcss-design-tokens/test/examples/example.css index e4e3e2c99..2837e7375 100644 --- a/plugins/postcss-design-tokens/test/examples/example.css +++ b/plugins/postcss-design-tokens/test/examples/example.css @@ -6,3 +6,9 @@ padding-left: design-token('size.spacing.small' to px); padding-bottom: design-token('size.spacing.small' to rem); } + +@media (min-width: design-token('viewport.medium')) { + .foo { + padding-bottom: design-token('size.spacing.medium' to rem); + } +} diff --git a/plugins/postcss-design-tokens/test/examples/example.expect.css b/plugins/postcss-design-tokens/test/examples/example.expect.css index 5c5964adb..8ccd2c851 100644 --- a/plugins/postcss-design-tokens/test/examples/example.expect.css +++ b/plugins/postcss-design-tokens/test/examples/example.expect.css @@ -4,3 +4,9 @@ padding-left: 16px; padding-bottom: 1rem; } + +@media (min-width: 35rem) { + .foo { + padding-bottom: 1.1rem; + } +} diff --git a/plugins/postcss-design-tokens/test/examples/example.rootFontSize-20.expect.css b/plugins/postcss-design-tokens/test/examples/example.rootFontSize-20.expect.css index 6b28e7c0e..bf3e704c2 100644 --- a/plugins/postcss-design-tokens/test/examples/example.rootFontSize-20.expect.css +++ b/plugins/postcss-design-tokens/test/examples/example.rootFontSize-20.expect.css @@ -4,3 +4,9 @@ padding-left: 16px; padding-bottom: 0.8rem; } + +@media (min-width: 35rem) { + .foo { + padding-bottom: 0.9rem; + } +} diff --git a/plugins/postcss-design-tokens/test/examples/tokens.json b/plugins/postcss-design-tokens/test/examples/tokens.json index 1ff537046..2157ddbfd 100644 --- a/plugins/postcss-design-tokens/test/examples/tokens.json +++ b/plugins/postcss-design-tokens/test/examples/tokens.json @@ -6,7 +6,11 @@ }, "size": { "spacing": { - "small": { "value": "16px" } + "small": { "value": "16px" }, + "medium": { "value": "18px" } } + }, + "viewport": { + "medium": { "value": "35rem" } } } diff --git a/plugins/postcss-design-tokens/test/tokens/basic.json b/plugins/postcss-design-tokens/test/tokens/basic.json index cf31af47e..3ac46090b 100644 --- a/plugins/postcss-design-tokens/test/tokens/basic.json +++ b/plugins/postcss-design-tokens/test/tokens/basic.json @@ -44,5 +44,13 @@ "unitless": { "value": "1.25" } + }, + "lists": { + "space": { + "value": "0.5rem 0.25rem" + }, + "comma": { + "value": "255, 0, 255" + } } }