Skip to content

Commit 4946c47

Browse files
step 1: add custom variable formatter
1 parent dc7190e commit 4946c47

File tree

9 files changed

+444
-32
lines changed

9 files changed

+444
-32
lines changed

.changeset/violet-lemons-greet.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@primer/primitives': minor
3+
---
4+
5+
Enable token references in composite tokens

scripts/buildTokens.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type {Config} from 'style-dictionary/types'
1+
import type {Config, LogConfig} from 'style-dictionary/types'
22
import {PrimerStyleDictionary} from '../src/primerStyleDictionary.js'
33
import {copyFromDir} from '../src/utilities/index.js'
44
import {deprecatedJson, css, docJson, fallbacks, styleLint} from '../src/platforms/index.js'
@@ -12,6 +12,14 @@ import {themes} from './themes.config.js'
1212
import fs from 'fs'
1313
import {getFallbackTheme} from './utilities/getFallbackTheme.js'
1414

15+
const log: LogConfig = {
16+
warnings: 'disabled', // 'warn' | 'error' | 'disabled'
17+
verbosity: 'silent', // 'default' | 'silent' | 'verbose'
18+
errors: {
19+
brokenReferences: 'throw', // 'throw' | 'console'
20+
},
21+
}
22+
1523
/**
1624
* getStyleDictionaryConfig
1725
* @param filename output file name without extension
@@ -29,13 +37,7 @@ const getStyleDictionaryConfig: StyleDictionaryConfigGenerator = (
2937
): Config => ({
3038
source, // build the special formats
3139
include,
32-
log: {
33-
warnings: 'disabled', // 'warn' | 'error' | 'disabled'
34-
verbosity: 'silent', // 'default' | 'silent' | 'verbose'
35-
errors: {
36-
brokenReferences: 'throw', // 'throw' | 'console'
37-
},
38-
},
40+
log,
3941
platforms: Object.fromEntries(
4042
Object.entries({
4143
css: css(`css/${filename}.css`, options.prefix, options.buildPath, {
@@ -64,6 +66,7 @@ export const buildDesignTokens = async (buildOptions: ConfigGeneratorOptions): P
6466
const extendedSD = await PrimerStyleDictionary.extend({
6567
source: [...source, ...include], // build the special formats
6668
include,
69+
log,
6770
platforms: {
6871
css: css(`internalCss/${filename}.css`, buildOptions.prefix, buildOptions.buildPath, {
6972
themed: true,

src/formats/cssAdvanced.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type {TransformedToken, FormatFn, FormatFnArguments, FormattingOptions} from 'style-dictionary/types'
22
import {format} from 'prettier'
3-
import {fileHeader, formattedVariables, sortByName} from 'style-dictionary/utils'
3+
import {fileHeader, sortByName} from 'style-dictionary/utils'
4+
import getFormattedVariables from './utilities/getFormattedVariables.js'
45

56
const wrapWithSelector = (css: string, selector: string | false): string => {
67
// return without selector
@@ -74,7 +75,7 @@ export const cssAdvanced: FormatFn = async ({
7475
// early abort if no matches
7576
if (!filteredDictionary.allTokens.length) continue
7677
// add tokens into root
77-
const css = formattedVariables({
78+
const css = getFormattedVariables({
7879
format: 'css',
7980
dictionary: filteredDictionary,
8081
outputReferences,
Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
/*
2+
* Copyright 2017 Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance with
5+
* the License. A copy of the License is located at
6+
*
7+
* http://www.apache.org/licenses/LICENSE-2.0
8+
*
9+
* or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
10+
* CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions
11+
* and limitations under the License.
12+
*/
13+
import type {Dictionary, FormattingOptions, OutputReferences, TransformedToken} from 'style-dictionary/types'
14+
import {getReferences, usesReferences} from 'style-dictionary/utils'
15+
16+
/**
17+
* @typedef {import('../../../types/DesignToken.d.ts').TransformedToken} TransformedToken
18+
* @typedef {import('../../../types/DesignToken.d.ts').Dictionary} Dictionary
19+
* @typedef {import('../../../types/File.d.ts').FormattingOptions} Formatting
20+
* @typedef {import('../../../types/Format.d.ts').OutputReferences} OutputReferences
21+
*/
22+
23+
/**
24+
* @type {Formatting}
25+
*/
26+
const defaultFormatting = {
27+
prefix: '',
28+
commentStyle: 'long',
29+
commentPosition: 'inline',
30+
indentation: '',
31+
separator: ' =',
32+
suffix: ';',
33+
}
34+
35+
/**
36+
* Split a string comment by newlines and
37+
* convert to multi-line comment if necessary
38+
* @param {string} toRetToken
39+
* @param {string} comment
40+
* @param {Formatting} options
41+
* @returns {string}
42+
*/
43+
export function addComment(toRetToken: string, comment: string, options: FormattingOptions) {
44+
const {commentStyle, indentation} = options
45+
let {commentPosition} = options
46+
47+
const commentsByNewLine = comment.split('\n')
48+
if (commentsByNewLine.length > 1) {
49+
commentPosition = 'above'
50+
}
51+
52+
let processedComment
53+
switch (commentStyle) {
54+
case 'short':
55+
if (commentPosition === 'inline') {
56+
processedComment = `// ${comment}`
57+
} else {
58+
processedComment = commentsByNewLine.reduce((acc, curr) => `${acc}${indentation}// ${curr}\n`, '')
59+
// remove trailing newline
60+
processedComment = processedComment.replace(/\n$/g, '')
61+
}
62+
break
63+
case 'long':
64+
if (commentsByNewLine.length > 1) {
65+
processedComment = commentsByNewLine.reduce(
66+
(acc, curr) => `${acc}${indentation} * ${curr}\n`,
67+
`${indentation}/**\n`,
68+
)
69+
processedComment += `${indentation} */`
70+
} else {
71+
processedComment = `${commentPosition === 'above' ? indentation : ''}/* ${comment} */`
72+
}
73+
break
74+
}
75+
76+
if (commentPosition === 'above') {
77+
// put the comment above the token if it's multi-line or if commentStyle ended with -above
78+
toRetToken = `${processedComment}\n${toRetToken}`
79+
} else {
80+
toRetToken = `${toRetToken} ${processedComment}`
81+
}
82+
83+
return toRetToken
84+
}
85+
86+
/**
87+
* Creates a function that can be used to format a token. This can be useful
88+
* to use as the function on `dictionary.allTokens.map`. The formatting
89+
* is configurable either by supplying a `format` option or a `formatting` object
90+
* which uses: prefix, indentation, separator, suffix, and commentStyle.
91+
* @memberof module:formatHelpers
92+
* @name createPropertyFormatter
93+
* @example
94+
* ```javascript
95+
* import { propertyFormatNames } from 'style-dictionary/enums';
96+
*
97+
* StyleDictionary.registerFormat({
98+
* name: 'myCustomFormat',
99+
* format: function({ dictionary, options }) {
100+
* const { outputReferences } = options;
101+
* const formatProperty = createPropertyFormatter({
102+
* outputReferences,
103+
* dictionary,
104+
* format: propertyFormatNames.css
105+
* });
106+
* return dictionary.allTokens.map(formatProperty).join('\n');
107+
* }
108+
* });
109+
* ```
110+
* @param {Object} options
111+
* @param {OutputReferences} [options.outputReferences] - Whether or not to output references. You will want to pass this from the `options` object sent to the format function.
112+
* @param {boolean} [options.outputReferenceFallbacks] - Whether or not to output css variable fallback values when using output references. You will want to pass this from the `options` object sent to the format function.
113+
* @param {Dictionary} options.dictionary - The dictionary object sent to the format function
114+
* @param {string} [options.format] - Available formats are: 'css', 'sass', 'less', and 'stylus'. If you want to customize the format and can't use one of those predefined formats, use the `formatting` option
115+
* @param {Formatting} [options.formatting] - Custom formatting properties that define parts of a declaration line in code. The configurable strings are: `prefix`, `indentation`, `separator`, `suffix`, `lineSeparator`, `fileHeaderTimestamp`, `header`, `footer`, `commentStyle` and `commentPosition`. Those are used to generate a line like this: `${indentation}${prefix}${token.name}${separator} ${prop.value}${suffix}`. The remaining formatting options are used for the fileHeader helper.
116+
* @param {boolean} [options.themeable] [false] - Whether tokens should default to being themeable.
117+
* @param {boolean} [options.usesDtcg] [false] - Whether DTCG token syntax should be uses.
118+
* @returns {(token: import('../../../types/DesignToken.d.ts').TransformedToken) => string}
119+
*/
120+
export default function createPropertyFormatterWithRef({
121+
outputReferences = false,
122+
outputReferenceFallbacks = false,
123+
dictionary,
124+
format,
125+
formatting = {},
126+
themeable = false,
127+
usesDtcg = false,
128+
}: {
129+
outputReferences?: OutputReferences
130+
outputReferenceFallbacks?: boolean
131+
dictionary: Dictionary
132+
format?: string
133+
formatting?: FormattingOptions
134+
themeable?: boolean
135+
usesDtcg?: boolean
136+
}) {
137+
/** @type {Formatting} */
138+
const formatDefaults: FormattingOptions = {}
139+
switch (format) {
140+
case 'css':
141+
formatDefaults.prefix = '--'
142+
formatDefaults.indentation = ' '
143+
formatDefaults.separator = ':'
144+
break
145+
}
146+
const mergedOptions = {
147+
...defaultFormatting,
148+
...formatDefaults,
149+
...formatting,
150+
}
151+
const {prefix, commentStyle, indentation, separator, suffix} = mergedOptions
152+
const {tokens, unfilteredTokens} = dictionary
153+
return function (token: TransformedToken) {
154+
let toRetToken = `${indentation}${prefix}${token.name}${separator} `
155+
let value = usesDtcg ? token.$value : token.value
156+
const originalValue = usesDtcg ? token.original.$value : token.original.value
157+
const shouldOutputRef =
158+
usesReferences(originalValue) &&
159+
(typeof outputReferences === 'function' ? outputReferences(token, {dictionary, usesDtcg}) : outputReferences)
160+
/**
161+
* A single value can have multiple references either by interpolation:
162+
* "value": "{size.border.width.value} solid {color.border.primary.value}"
163+
* or if the value is an object:
164+
* "value": {
165+
* "size": "{size.border.width.value}",
166+
* "style": "solid",
167+
* "color": "{color.border.primary.value"}
168+
* }
169+
* This will see if there are references and if there are, replace
170+
* the resolved value with the reference's name.
171+
*/
172+
if (shouldOutputRef) {
173+
// Formats that use this function expect `value` to be a string
174+
// or else you will get '[object Object]' in the output
175+
const refs = getReferences(originalValue, tokens, {unfilteredTokens, warnImmediately: false}, [])
176+
// original can either be an object value, which requires transitive value transformation in web CSS formats
177+
// or a different (primitive) type, meaning it can be stringified.
178+
const originalIsObject = typeof originalValue === 'object' && originalValue !== null
179+
180+
if (!originalIsObject) {
181+
// TODO: find a better way to deal with object-value tokens and outputting refs
182+
// e.g. perhaps it is safer not to output refs when the value is transformed to a non-object
183+
// for example for CSS-like formats we always flatten to e.g. strings
184+
185+
// when original is object value, we replace value by matching ref.value and putting a var instead.
186+
// Due to the original.value being an object, it requires transformation, so undoing the transformation
187+
// by replacing value with original.value is not possible. (this is the early v3 approach to outputting refs)
188+
189+
// when original is string value, we replace value by matching original.value and putting a var instead
190+
// this is more friendly to transitive transforms that transform the string values (v4 way of outputting refs)
191+
value = originalValue
192+
} else {
193+
if (token.$type === 'border') {
194+
const transformedValues = value.split(' ')
195+
value = ['width', 'style', 'color']
196+
.map((prop, index) => {
197+
if (
198+
originalValue[prop].startsWith('{') &&
199+
refs.find(ref => ref.path.join('.') === originalValue[prop].replace(/[{}]/g, ''))?.isSource === true
200+
) {
201+
return originalValue[prop]
202+
}
203+
return transformedValues[index]
204+
})
205+
.join(' ')
206+
}
207+
}
208+
/* eslint-disable-next-line github/array-foreach */
209+
refs.forEach(ref => {
210+
// value should be a string that contains the resolved reference
211+
// because Style Dictionary resolved this in the resolution step.
212+
// Here we are undoing that by replacing the value with
213+
// the reference's name
214+
if (Object.hasOwn(ref, `${usesDtcg ? '$' : ''}value`) && Object.hasOwn(ref, 'name')) {
215+
const refVal = usesDtcg ? ref.$value : ref.value
216+
const replaceFunc = () => {
217+
if (format === 'css') {
218+
if (outputReferenceFallbacks) {
219+
return `var(${prefix}${ref.name}, ${refVal})`
220+
} else {
221+
return `var(${prefix}${ref.name})`
222+
}
223+
} else {
224+
return `${prefix}${ref.name}`
225+
}
226+
}
227+
// TODO: add test
228+
// technically speaking a reference can be made to a number or boolean token, in this case we stringify it first
229+
const regex = new RegExp(`{${ref.path.join('\\.')}(\\.\\$?value)?}`, 'g')
230+
value = `${value}`.replace(regex, replaceFunc)
231+
}
232+
})
233+
}
234+
toRetToken += value
235+
236+
const themeableToken = typeof token.themeable === 'boolean' ? token.themeable : themeable
237+
if (format === 'sass' && themeableToken) {
238+
toRetToken += ' !default'
239+
}
240+
241+
toRetToken += suffix
242+
const comment = token.$description ?? token.comment
243+
if (comment && commentStyle !== 'none') {
244+
toRetToken = addComment(toRetToken, comment, mergedOptions as FormattingOptions)
245+
}
246+
return toRetToken
247+
}
248+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/*
2+
* Copyright 2017 Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance with
5+
* the License. A copy of the License is located at
6+
*
7+
* http://www.apache.org/licenses/LICENSE-2.0
8+
*
9+
* or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
10+
* CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions
11+
* and limitations under the License.
12+
*/
13+
import type {Dictionary, OutputReferences} from 'style-dictionary/types'
14+
import {sortByReference} from 'style-dictionary/utils'
15+
import createPropertyFormatterWithRef from './createPropertyFormatterWithRef.js'
16+
17+
const defaultFormatting = {
18+
lineSeparator: '\n',
19+
}
20+
21+
export default function getFormattedVariables({
22+
format,
23+
dictionary,
24+
outputReferences = false,
25+
outputReferenceFallbacks,
26+
formatting = {},
27+
themeable = false,
28+
usesDtcg = false,
29+
}: {
30+
format: string
31+
dictionary: Dictionary
32+
outputReferences?: OutputReferences
33+
outputReferenceFallbacks?: boolean
34+
formatting?: {
35+
lineSeparator?: string
36+
}
37+
themeable?: boolean
38+
usesDtcg?: boolean
39+
}) {
40+
// typecast, we know that by know the tokens have been transformed
41+
let allTokens = dictionary.allTokens
42+
const tokens = dictionary.tokens
43+
44+
const {lineSeparator} = Object.assign({}, defaultFormatting, formatting)
45+
46+
// Some languages are imperative, meaning a variable has to be defined
47+
// before it is used. If `outputReferences` is true, check if the token
48+
// has a reference, and if it does send it to the end of the array.
49+
// We also need to account for nested references, a -> b -> c. They
50+
// need to be defined in reverse order: c, b, a so that the reference always
51+
// comes after the definition
52+
if (outputReferences) {
53+
// note: using the spread operator here so we get a new array rather than
54+
// mutating the original
55+
allTokens = [...allTokens].sort(sortByReference(tokens, {unfilteredTokens: dictionary.unfilteredTokens, usesDtcg}))
56+
}
57+
58+
return allTokens
59+
.map(
60+
createPropertyFormatterWithRef({
61+
outputReferences,
62+
outputReferenceFallbacks,
63+
dictionary,
64+
format,
65+
formatting,
66+
themeable,
67+
usesDtcg,
68+
}),
69+
)
70+
.filter(function (strVal) {
71+
return !!strVal
72+
})
73+
.join(lineSeparator)
74+
}

0 commit comments

Comments
 (0)