@primer/primitives
Version:
Typography, spacing, and color primitives for Primer design system
535 lines (482 loc) • 14.2 kB
JavaScript
const glob = require('fast-glob')
const StyleDictionary = require('style-dictionary')
const {fileHeader, formattedVariables} = StyleDictionary.formatHelpers
// REGISTER THE CUSTOM TRANFORMS
/**
* transform: css variable names
* example: `--namespace-item-variant-property-modifier`
*/
StyleDictionary.registerTransform({
name: 'name/css',
type: 'name',
transformer: (token, options) => {
return token.path.join('-')
}
})
/**
* transform: js variable names
* example: `namespace.item.variant.property.modifier`
*/
StyleDictionary.registerTransform({
name: 'name/js',
type: 'name',
transformer: (token, options) => {
return token.path.join('.')
}
})
/**
* transform: js es6 variable names
* example: `NamespaceItemVariantPropertyModifier`
*/
StyleDictionary.registerTransform({
name: 'name/js/es6',
type: 'name',
transformer: (token, options) => {
const tokenPath = token.path.join(' ')
const tokenPathItems = tokenPath.split(' ')
for (var i = 0; i < tokenPathItems.length; i++) {
tokenPathItems[i] = tokenPathItems[i].charAt(0).toUpperCase() + tokenPathItems[i].slice(1)
}
const tokenName = tokenPathItems.join('')
return tokenName
}
})
// find values with px unit
function isPx(value) {
return /[\d\.]+px$/.test(value)
}
// transform: px to rem
StyleDictionary.registerTransform({
name: 'pxToRem',
type: 'value',
transformer: (token, options) => {
if (isPx(token.value)) {
const baseFontSize = 16
const floatValue = parseFloat(token.value.replace('px', ''))
if (isNaN(floatValue)) {
return token.value
}
if (floatValue === 0) {
return '0'
}
return `${floatValue / baseFontSize}rem`
}
return token.value
}
})
//-----
// ts output
StyleDictionary.registerTransform({
name: 'attribute/typescript',
type: 'attribute',
transformer: token => {
return {
typescript: {
// these transforms will need to match the ones you use for typescript
// or you can "chain" the transforms and use token.name and token.value
// like scss and less transforms do.
name: token.path.slice(1).join('.'),
value: token.value
}
}
}
})
// css output
StyleDictionary.registerTransform({
name: 'attribute/css',
type: 'attribute',
transformer: token => {
const tokenName = token.path.slice(1).join('-')
return {
css: {
name: `--${tokenName}`,
value: token.value
}
}
}
})
// transform: composite typography to shorthands
StyleDictionary.registerTransform({
name: 'typography/shorthand',
type: 'value',
transitive: true,
matcher: token => token.type === 'typography',
transformer: token => {
const {value} = token
return `${value.fontWeight} ${value.fontSize}/${value.lineHeight} ${value.fontFamily}`
}
})
// REGISTER THE CUSTOM TRANFORM GROUPS
StyleDictionary.registerTransformGroup({
name: 'css',
transforms: ['name/css', 'pxToRem', 'typography/shorthand']
})
// REGISTER A CUSTOM FORMAT
// wrap mobile tokens in media query
StyleDictionary.registerFormat({
name: 'css/touch-target-mobile',
formatter: function({dictionary, file, options}) {
const {outputReferences} = options
return (
fileHeader({file}) +
`@media (pointer: coarse) { :root {\n` +
formattedVariables({format: 'css', dictionary, outputReferences}) +
'\n}}\n'
)
}
})
// wrap desktop tokens in media query
StyleDictionary.registerFormat({
name: 'css/touch-target-desktop',
formatter: function({dictionary, file, options}) {
const {outputReferences} = options
return (
fileHeader({file}) +
`@media (pointer: fine) { :root {\n` +
formattedVariables({format: 'css', dictionary, outputReferences}) +
'\n}}\n'
)
}
})
StyleDictionary.registerFormat({
name: 'custom/format/custom-media',
formatter({dictionary}) {
return dictionary.allProperties
.map(prop => {
const {value, path, name} = prop
// const tokenPath = path
// const tokenProperty = path[path.length - 1]
return `@custom-media --${name}-viewport ${value};`
})
.join('\n')
}
})
// format docs
StyleDictionary.registerFormat({
name: 'json/docs',
formatter: function({dictionary}) {
const groupedTokens = groupBy(dictionary.allProperties, 'filePath')
return JSON.stringify(groupedTokens, null, 2)
}
})
/**
* Replacement format for javascript/module
*/
StyleDictionary.registerFormat({
name: 'javascript/module-v2',
formatter: function({dictionary, file}) {
const recursiveleyFlattenDictionary = obj => {
const tree = {}
if (typeof obj !== 'object' || Array.isArray(obj)) {
return obj
}
if (obj.hasOwnProperty('value')) {
return obj.value
} else {
for (const name in obj) {
if (obj.hasOwnProperty(name)) {
tree[name] = recursiveleyFlattenDictionary(obj[name])
}
}
}
return tree
}
return (
fileHeader({file}) +
'module.exports = ' +
JSON.stringify(recursiveleyFlattenDictionary(dictionary.tokens), null, 2)
)
}
})
/**
* Replacement format for typescript/module-declarations
* Type schema corresponds to javascript/module-v2 format
*/
StyleDictionary.registerFormat({
name: 'typescript/module-declarations-v2',
formatter: function({dictionary, options, file}) {
const {moduleName = `tokens`} = options
const getType = value => {
switch (typeof value) {
case 'string':
return 'string'
case 'number':
return 'number'
default:
return 'any'
}
}
const recursiveTypeGeneration = obj => {
const tree = {}
const shortHandSizes = ['large', 'medium', 'small']
if (typeof obj !== 'object' || Array.isArray(obj)) {
return obj
}
if (obj.hasOwnProperty('value') && typeof obj.value === 'string') {
return getType(obj.value)
} else {
for (const name in obj) {
if ((obj.hasOwnProperty(name) && obj.name === 'shorthand') || shortHandSizes.includes(obj.name)) {
for (const shorthandKey in obj.value) {
tree[shorthandKey] = getType(obj.value[shorthandKey])
}
return tree
} else if (obj.hasOwnProperty(name)) {
tree[name] = recursiveTypeGeneration(obj[name])
}
}
}
return tree
}
const output =
fileHeader({file}) +
`declare const ${moduleName}: ${JSON.stringify(recursiveTypeGeneration(dictionary.tokens), null, 2)}
export default ${moduleName};`
return output
.replace(/"any"/g, 'any')
.replace(/"string"/g, 'string')
.replace(/"number"/g, 'number')
}
})
/**
* @name groupBy
* @description Equivalent to lodash _.groupBy, to avoid creating another package dependency for users
* @param {Array|Object} The collection to iterate over.
* @param {Function} The iteratee to transform keys.
* @returns {Object} Returns the composed aggregate object.
*/
function groupBy(collection, iteratee = x => x) {
const current = typeof iteratee === 'function' ? iteratee : ({[iteratee]: prop}) => prop
const array = Array.isArray(collection) ? collection : Object.values(collection)
return array.reduce((acc, item) => {
const value = current(item)
acc[value] = acc[value] || []
acc[value].push(item)
return acc
}, {})
}
/**
* @name build
* @description
* Used to generate design tokens programmatically using StyleDictionary
* Called internally to build primitives and exported for self-serve use
*
* @param {Object} options
* @param {string} options.source glob or file path to a JSON object of tokens
* @param {string} options.outputPath location to write the output files
* @param {string} options.namespace a custom namespace to use for the output files
* @param {Platform} options.platforms add custom platform configurations to style-dictionary
* @example
* buildPrimitives({
* source: [`src/colors.json`],
* outputPath: 'dist',
* namespace: 'primer',
* platforms: {...}
* })
*/
function buildPrimitives(
{source, outputPath = 'tokens-v2-private', include, platforms, namespace = 'primer'},
_StyleDictionary = StyleDictionary
) {
console.log('Build started...')
console.log('\n==============================================')
const customParseConfig = {
parsers: [
{
pattern: /\.json$/,
parse: ({contents, filePath}) => {
try {
let mutableContent = JSON.stringify(contents)
if (filePath.includes('/functional/')) {
mutableContent = mutableContent.replace(/<namespace>/g, namespace)
}
const parsed = JSON.parse(mutableContent)
return JSON.parse(parsed)
} catch (error) {
console.log(error)
}
}
}
]
}
const getConfig = files => {
const defaultPlatformConfig = {
css: {
buildPath: `${outputPath}/css/`,
transformGroup: 'css',
// map the array of token file paths to style dictionary output files
files: files.map(filePath => {
return {
format: `css/variables`,
destination: filePath.replace(`.json`, `.css`),
filter: token => token.filePath === filePath,
options: {
outputReferences: true
}
}
})
},
cssViewport: {
buildPath: `${outputPath}/css/tokens/functional/size/`,
transformGroup: 'css',
files: [
{
filter: token => token.filePath.includes('viewport'),
format: 'custom/format/custom-media',
destination: 'viewport.css'
}
]
},
js: {
buildPath: `${outputPath}/js/`,
transforms: ['name/js/es6', 'pxToRem'],
// map the array of token file paths to style dictionary output files
files: files.map(filePath => {
return {
format: `javascript/es6`,
destination: filePath.replace(`.json`, `.js`),
filter: token => token.filePath === filePath
}
})
},
jsModule: {
buildPath: `${outputPath}/js/module/`,
transforms: ['pxToRem'],
// map the array of token file paths to style dictionary output files
files: files.map(filePath => {
return {
format: `javascript/module`,
destination: filePath.replace(`.json`, `.js`),
filter: token => token.filePath === filePath
}
})
},
tsTypes: {
buildPath: `${outputPath}/ts/`,
transforms: ['pxToRem'],
// map the array of token file paths to style dictionary output files
files: files.map(filePath => {
return {
format: `typescript/module-declarations-v2`,
destination: filePath.replace(`.json`, `.d.ts`),
filter: token => token.filePath === filePath
}
})
},
ts: {
buildPath: `${outputPath}/ts/`,
transforms: ['pxToRem'],
// map the array of token file paths to style dictionary output files
files: files.map(filePath => {
return {
format: `javascript/module-v2`,
destination: filePath.replace(`.json`, `.js`),
filter: token => token.filePath === filePath
}
})
}
}
const config = {
...{include: include ? [include] : undefined},
source: files,
...customParseConfig,
platforms: platforms ? platforms : defaultPlatformConfig
}
return config
}
//build all tokens
_StyleDictionary
.extend({
...getConfig(glob.sync([...source]))
})
.buildAllPlatforms()
}
/**
* @name init
* @description
* Triggers the build for @primer/primitive default tokens
* from an npm script. Internal use only. Use `build` for self-serve.
* @private
*/
function _init() {
const outputPath = 'tokens-v2-private'
//build all tokens
buildPrimitives({
source: [`tokens/**/*.json`, `!tokens/**/size-*.json`],
outputPath
})
//build size fine
buildPrimitives({
source: [`tokens/functional/size/size-fine.json`, `tokens/base/size/size.json`],
outputPath
})
//build size coarse
buildPrimitives({
source: [`tokens/functional/size/size-coarse.json`, `tokens/base/size/size.json`],
outputPath
})
buildPrimitives({
source: [`tokens/base/size/size.json`, `tokens/functional/size/size-fine.json`],
outputPath,
platforms: {
css: {
buildPath: `${outputPath}/css/`,
transformGroup: 'css',
files: [
{
destination: `tokens/functional/size/size-fine.css`,
format: `css/touch-target-desktop`,
filter: token => token.filePath.includes('fine'),
options: {
outputReferences: true
}
}
]
}
}
})
buildPrimitives({
source: [`tokens/base/size/size.json`, `tokens/functional/size/size-coarse.json`],
platforms: {
css: {
buildPath: `${outputPath}/css/`,
transformGroup: 'css',
files: [
{
destination: `tokens/functional/size/size-coarse.css`,
format: `css/touch-target-mobile`,
filter: token => token.filePath.includes('coarse'),
options: {
outputReferences: true
}
}
]
}
}
})
//build docs data
buildPrimitives({
source: [`tokens/**/*.json`],
outputPath,
platforms: {
docs: {
buildPath: `${outputPath}/docs/`,
transformGroup: 'css',
files: [
{
format: 'json/docs',
destination: 'docValues.json',
options: {
outputReferences: false
}
}
]
}
}
})
}
module.exports = {
buildPrimitives,
_init,
StyleDictionary
}