style-dictionary
Version:
Style once, use everywhere. A build system for creating cross-platform styles.
1,003 lines (916 loc) • 36 kB
JavaScript
/*
* Copyright 2017 Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance with
* the License. A copy of the License is located at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
* CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions
* and limitations under the License.
*/
import { dirname } from 'path-unified';
import { fs } from 'style-dictionary/fs';
import chalk from 'chalk';
import combineJSON from './utils/combineJSON.js';
import deepExtend from './utils/deepExtend.js';
import resolveObject from './utils/resolveObject.js';
import getName from './utils/references/getName.js';
import GroupMessages, { verbosityInfo } from './utils/groupMessages.js';
import { detectDtcgSyntax } from './utils/detectDtcgSyntax.js';
import { preprocess } from './utils/preprocess.js';
import { typeDtcgDelegate } from './utils/typeDtcgDelegate.js';
import createFormatArgs from './utils/createFormatArgs.js';
import { deepmerge } from './utils/deepmerge.js';
import { expandTokens } from './utils/expandObjectTokens.js';
import { convertTokenData } from './utils/convertTokenData.js';
import { Register } from './Register.js';
import transformObject from './transform/object.js';
import transformConfig from './transform/config.js';
import performActions from './performActions.js';
import filterTokens from './filterTokens.js';
import cleanFiles from './cleanFiles.js';
import cleanDirs from './cleanDirs.js';
import cleanActions from './cleanActions.js';
import { loadFile } from './utils/loadFile.js';
import { logBrokenReferenceLevels, logVerbosityLevels, logWarningLevels } from './enums/index.js';
/**
* @typedef {import('../types/Volume.d.ts').Volume} Volume
* @typedef {import('../types/Config.d.ts').Config} Config
* @typedef {import('../types/Config.d.ts').PlatformConfig} PlatformConfig
* @typedef {import('../types/Config.d.ts').LogConfig} LogConfig
* @typedef {import('../types/Config.d.ts').Expand} Expand
* @typedef {import('../types/Config.d.ts').ExpandConfig} ExpandConfig
* @typedef {import('../types/File.d.ts').File} File
* @typedef {import('../types/Filter.d.ts').Filter} Filter
* @typedef {import('../types/DesignToken.d.ts').DesignToken} Token
* @typedef {import('../types/DesignToken.d.ts').TransformedToken} TransformedToken
* @typedef {import('../types/DesignToken.d.ts').DesignTokens} Tokens
* @typedef {import('../types/DesignToken.d.ts').PreprocessedTokens} PreprocessedTokens
* @typedef {import('../types/DesignToken.d.ts').TransformedTokens} TransformedTokens
* @typedef {import('../types/DesignToken.d.ts').Dictionary} Dictionary
*/
const PROPERTY_VALUE_COLLISIONS = GroupMessages.GROUP.PropertyValueCollisions;
const PROPERTY_REFERENCE_WARNINGS = GroupMessages.GROUP.PropertyReferenceWarnings;
const UNKNOWN_CSS_FONT_PROPS_WARNINGS = GroupMessages.GROUP.UnknownCSSFontProperties;
const FILTER_WARNINGS = GroupMessages.GROUP.FilteredOutputReferences;
const { throw: throwBrokenReference } = logBrokenReferenceLevels;
const { default: defaultVerbosity, silent, verbose } = logVerbosityLevels;
const { error, warn, disabled } = logWarningLevels;
/**
* Style Dictionary module
*
* @module style-dictionary
* @typicalname StyleDictionary
* @example
* ```js
* import StyleDictionary from 'style-dictionary';
* new StyleDictionary.extend('config.json').buildAllPlatforms();
* ```
*/
export default class StyleDictionary extends Register {
// Placeholder is transformed on prepublish -> see scripts/inject-version.js
// Another option might be import pkg from './package.json' with { "type": "json" } which would work in both browser and node, but support is not there yet.
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import#browser_compatibility
static VERSION = '4.3.2';
/** @returns {Config} */
get options() {
// merge locally registered things with options
// so that when we extend, we include registered things
const opts = deepmerge(
{
hooks: this.hooks,
},
this._options ?? {},
);
return opts;
}
/** @param {Config} v */
set options(v) {
this._options = v;
}
/**
* @param {Config | string} config
* @param {{
* init?: boolean,
* verbosity?: LogConfig['verbosity'],
* warnings?: LogConfig['warnings'],
* volume?: Volume
* }} ctorOpts
*/
constructor(config = {}, { init = true, verbosity, warnings, volume } = {}) {
super();
this.config = config;
this.options = {};
/** @type {PreprocessedTokens} */
this.tokens = {};
/** @type {Token[]} */
this.allTokens = [];
/** @type {Map<string, Token>} */
this.tokenMap = new Map();
/** @type {boolean | undefined} */
this.usesDtcg = undefined;
/** @type {LogConfig} */
this.log = {
warnings: warn,
verbosity: defaultVerbosity,
errors: {
brokenReferences: throwBrokenReference,
},
};
/** @type {string[]} */
this.source = [];
/** @type {string[]} */
this.include = [];
/** @type {ExpandConfig|undefined} */
this.expand = undefined;
/** @type {Record<string, PlatformConfig>} */
this.platforms = {};
/** @type {string[]} */
this.parsers = [];
/** @type {string[]} */
this.preprocessors = [];
if (volume) {
// when a user sets a custom FS shim, mark it for later reference
volume.__custom_fs__ = true;
}
/** @type {Volume} */
this.volume = volume ?? fs;
/**
* Gets set after transform because filter happens on format level,
* so we know they are transformed by then.
* @type {TransformedTokens}
*/
this.unfilteredTokens = {};
/** @type {TransformedToken[]} */
this.unfilteredAllTokens = [];
this.hasInitialized = new Promise((resolve) => {
this.hasInitializedResolve = resolve;
});
/**
* Storing the platform specific transformed tokens so we can prevent re-running exportPlatform when we already know the outcome
* Same thing for platform specific configs, we don't need to call transformConfig again if we already know the outcome
*/
/** @type {Record<string,Dictionary>} */
this._dictionaries = {};
/** @type {Record<string,PlatformConfig>} */
this._platformConfigs = {};
// By default, always call async extend function when constructing new SD instance
// However, for testability and managing error handling,
// you can call constructor with { init: false }
// and call SDInstance.extend() manually (and catch the error).
if (init) {
this.init({ verbosity, warnings });
}
}
/**
* @param {{verbosity?: LogConfig['verbosity'], warnings?: LogConfig['warnings']}} [opts]
* @returns
*/
async init(opts) {
return this.extend(undefined, { ...opts, mutateOriginal: true });
}
/**
* @param {Config | string} [config]
* @param {{
* mutateOriginal?: boolean,
* verbosity?: LogConfig['verbosity'],
* warnings?: LogConfig['warnings'],
* volume?: Volume
* }} [opts]
* @returns {Promise<StyleDictionary>}
*/
async extend(config = this.config, opts = {}) {
// by default, if extend is called it means extending the current instance
// with a new instance without mutating the original
if (!opts.mutateOriginal) {
const newSD = new StyleDictionary(deepmerge(this.options, config), {
init: false,
verbosity: opts.verbosity,
warnings: opts.warnings,
volume: opts.volume,
});
return newSD.init(opts);
}
/** @type {Config} */
let options;
/** @type {Tokens} */
let inlineTokens = {};
/** @type {Tokens} */
let includeTokens = {};
/** @type {Tokens} */
let sourceTokens = {};
// Overloaded method, can accept a string as a path that points to a JS or
// JSON file or a plain object. Potentially refactor.
if (typeof config === 'string') {
options = /** @type {Config} */ (await loadFile(config, this.volume));
} else {
options = config;
}
this.log = {
// our defaults
...this.log,
// user log options override the defaults
...options.log,
// verbosity/warnings is a bit more complex
// can be passed imperatively by constructor (e.g. when using CLI --verbose / --silent / --no-warn)
// otherwise verbosity in user config or fallback to default
verbosity: opts.verbosity ?? options.log?.verbosity ?? this.log.verbosity,
warnings: opts.warnings ?? options.log?.warnings ?? this.log.warnings,
};
this.options = {
...options,
log: this.log,
};
// SD Config options should be passed to class instance as well
Object.entries(this.options).forEach(([key, val]) => {
// Bit of a type hack, making the assumption that any property in options can be set as a prop on StyleDictonary instance
const _key = /** @type {keyof StyleDictionary} */ (key);
this[_key] = val;
});
// Try to detect DTCG if not specified by user in options and tokens is passed imperatively
if (Object.entries(this.tokens).length > 0 && this.usesDtcg === undefined) {
this.usesDtcg = detectDtcgSyntax(this.tokens);
}
const appliedParsers = Object.fromEntries(
Object.entries(this.hooks.parsers ?? {}).filter(([key]) => this.parsers.includes(key)),
);
// grab the inline tokens, ones either defined in the configuration object
// or that already exist from extending another style dictionary instance
// with `tokens` keys
inlineTokens = deepExtend([{}, this.tokens || {}]);
// Update tokens with includes from dependencies
if (this.include) {
if (!Array.isArray(this.include)) throw new Error('include must be an array');
const result = await combineJSON(
this.include,
true,
undefined,
false,
appliedParsers,
this.usesDtcg,
this.volume,
);
includeTokens = result.tokens;
// If it wasn't known yet whether DTCG is used, combineJSON util will have auto-detected it by now
if (this.usesDtcg === undefined) {
this.usesDtcg = result.usesDtcg;
}
}
// Update tokens with current package's source
// These highest precedence
if (this.source) {
if (!Array.isArray(this.source)) throw new Error('source must be an array');
const result = await combineJSON(
this.source,
true,
// TODO: abstract into its own reusable interface, since it is used in deepExtend util as well
/** @param {{ target: Tokens, copy: Tokens, path: string[], key: string }} prop */
function Collision(prop) {
GroupMessages.add(
PROPERTY_VALUE_COLLISIONS,
`Collision detected at: ${prop.path.join('.')}! Original value: ${
prop.target[prop.key]
}, New value: ${prop.copy[prop.key]}`,
);
},
true,
appliedParsers,
this.usesDtcg,
this.volume,
);
sourceTokens = result.tokens;
// If it wasn't known yet whether DTCG is used, combineJSON util will have auto-detected it
if (this.usesDtcg === undefined) {
this.usesDtcg = result.usesDtcg;
}
const propValCollisionCount = GroupMessages.count(PROPERTY_VALUE_COLLISIONS);
if (propValCollisionCount > 0) {
const collisions = GroupMessages.flush(PROPERTY_VALUE_COLLISIONS).join('\n');
let warn = `\nToken collisions detected (${propValCollisionCount}):\n`;
if (this.log.verbosity === verbose) {
warn += `\n${collisions}\n\n`;
} else {
warn += verbosityInfo;
}
if (this.log?.warnings === error) {
throw new Error(warn);
} else if (this.log?.verbosity !== silent && this.log.warnings !== disabled) {
// eslint-disable-next-line no-console
console.log(chalk.rgb(255, 140, 0).bold(warn));
}
}
}
this.options = { ...this.options, usesDtcg: this.usesDtcg };
// Merge inline, include, and source tokens
let preprocessedTokens = /** @type {PreprocessedTokens} */ (
deepExtend([{}, inlineTokens, includeTokens, sourceTokens])
);
preprocessedTokens = await preprocess(
preprocessedTokens,
this.preprocessors,
this.hooks.preprocessors,
this.options,
);
if (this.usesDtcg) {
// this is where they go from type Tokens -> Preprocessed tokens because the prop $type is removed
preprocessedTokens = typeDtcgDelegate(preprocessedTokens);
}
if (this.shouldRunExpansion(this.expand)) {
preprocessedTokens = expandTokens(preprocessedTokens, this.options);
}
this.tokens = preprocessedTokens;
this.allTokens = convertTokenData(this.tokens, { output: 'array', usesDtcg: this.usesDtcg });
this.tokenMap = convertTokenData(this.allTokens, { output: 'map', usesDtcg: this.usesDtcg });
this.hasInitializedResolve(null);
// For chaining
return this;
}
/**
* @param {ExpandConfig} [expandCfg]
* @returns
*/
shouldRunExpansion(expandCfg) {
if (expandCfg !== undefined) {
if (
// run tokens expansion if the config is not false or if it's an object
// and not every prop of this object is false
!(
(typeof expandCfg === 'boolean' && expandCfg === false) ||
(typeof expandCfg === 'object' && Object.values(expandCfg).every((exp) => exp === false))
)
) {
return true;
}
}
return false;
}
/**
* @param {string} platform
* @param {{ cache?: boolean }} [opts]
*/
getPlatformConfig(platform, opts) {
if (!platform || !this.platforms?.[platform]) {
throw new Error(`Please supply a valid platform, "${platform}" does not exist`);
}
if (!this._platformConfigs[platform] || opts?.cache === false) {
this._platformConfigs[platform] = transformConfig(this.platforms[platform], this, platform);
}
return this._platformConfigs[platform];
}
/**
* @param {string} platform
* @param {{ cache?: boolean }} [opts]
*/
async getPlatformTokens(platform, opts) {
if (!this._dictionaries[platform] || opts?.cache === false) {
const dictionary = await this._exportPlatform(platform);
this._dictionaries[platform] = dictionary;
}
return this._dictionaries[platform];
}
/**
* Public wrapper around _exportPlatform, returns only tokens object
* Here for backwards compatibility.
* @deprecated use getPlatformTokens instead
*
* @param {string} platform
* @param {{ cache?: boolean }} [opts]
* @returns {Promise<TransformedTokens>}
*/
async exportPlatform(platform, opts) {
const dictionary = await this.getPlatformTokens(platform, opts);
return dictionary.tokens;
}
/**
* @param {string} platform
* @returns {Promise<Dictionary>}
*/
async _exportPlatform(platform) {
await this.hasInitialized;
const platformConfig = this.getPlatformConfig(platform);
/** @type {PreprocessedTokens | TransformedTokens} */
let tokens = this.tokens;
/** @type {Map<string, Token> | Map<string, TransformedToken>} */
let tokenMap = this.tokenMap;
/** @type {Token[] | TransformedTokens[]} */
let allTokens = /** */ this.allTokens;
// let tokensChanged = false;
if (platformConfig.preprocessors && platformConfig.preprocessors.length > 0) {
tokens = await preprocess(
tokens,
platformConfig.preprocessors,
this.hooks.preprocessors,
platformConfig,
);
// tokensChanged = true;
}
if (this.shouldRunExpansion(platformConfig.expand)) {
tokens = expandTokens(tokens, this.options, platformConfig);
// tokensChanged = true;
}
// TODO: re-enable below when Map is source of truth for transforming/resolving
// // re-convert to Map when preprocessors/expand have ran on the tokens object
// if (tokensChanged) {
// tokenMap = convertTokenData(tokens, { output: 'map', usesDtcg: this.usesDtcg });
// }
/**
* @type {string[]}
* list keeping paths of props with applied value transformations
*/
const transformedPropRefs = [];
/**
* @type {string[]}
* list keeping paths of props that had references in it, and therefore
* could not (yet) have transformed
*/
const deferredPropValueTransforms = [];
const transformationContext = {
transformedPropRefs,
deferredPropValueTransforms,
};
let deferredPropCount = 0;
let finished = false;
while (!finished) {
// We keep up transforming and resolving until all props are resolved
// and every defined transformation was executed. Remember: transformations
// can only be executed, if the value to be transformed, has no references
// in it. So resolving may lead to enable further transformations, and sub
// sequent resolving may enable even more transformations - and so on.
// So we keep this loop running until sub sequent transformations are ineffective.
//
// Take the following example:
//
// color.brand = {
// value: "{color.base.green}"
// }
//
// color.background.button.primary.base = {
// value: "{color.brand.value}",
// color: {
// desaturate: 0.5
// }
// }
//
// color.background.button.primary.hover = {
// value: "{color.background.button.primary.base}",
// color: {
// darken: 0.2
// }
// }
//
// As you can see 'color.background.button.primary.hover' is a variation
// of 'color.background.button.primary.base' which is a variation of
// 'color.base.green'. These transitive references are solved by running
// this loop until all tokens are transformed and resolved.
// We need to transform the object before we resolve the
// variable names because if a value contains concatenated
// values like "1px solid {color.border.base}" we want to
// transform the original value (color.border.base) before
// replacing that value in the string.
// TODO: use tokenMap instead and refactor to flat transform util, this will be much cheaper/faster
tokens = await transformObject(
tokens,
platformConfig,
this.options,
transformationContext,
[],
{},
this.volume,
);
// referenced values, that have not (yet) been transformed should be excluded from resolving
const ignorePathsToResolve = deferredPropValueTransforms.map((p) =>
getName([p, this.usesDtcg ? '$value' : 'value']),
);
// TODO: use tokenMap instead and refactor to flat resolve util, this will be much cheaper/faster
tokens = resolveObject(tokens, {
ignorePaths: ignorePathsToResolve,
usesDtcg: this.usesDtcg,
});
const newDeferredPropCount = deferredPropValueTransforms.length;
// nothing left to transform -> ready
if (newDeferredPropCount === 0) {
finished = true;
// or deferred count doesn't go down, that means there
// is a circular reference -> ready (but errored)
} else if (deferredPropCount === newDeferredPropCount) {
// if we didn't resolve any deferred references then we have a circular reference
// the resolveObject method will find the circular references
// we do this in case there are multiple circular references
// TODO: use tokenMap instead and refactor to flat resolve util, this will be much cheaper/faster
resolveObject(tokens, {
usesDtcg: this.usesDtcg,
});
finished = true;
} else {
// neither of these things, keep going.
deferredPropCount = newDeferredPropCount;
}
}
const refWarningCount = GroupMessages.count(PROPERTY_REFERENCE_WARNINGS);
if (refWarningCount > 0) {
const warnings = GroupMessages.flush(PROPERTY_REFERENCE_WARNINGS).join('\n');
let err = `\nReference Errors:\nSome token references (${refWarningCount}) could not be found.\n`;
if (this.log.verbosity === verbose) {
err += `\n${warnings}\n`;
} else {
err += `${verbosityInfo}\n`;
}
if (this.log.errors?.brokenReferences === throwBrokenReference) {
throw new Error(err);
} else if (this.log.verbosity !== silent) {
console.error(err);
}
}
const unknownPropsWarningCount = GroupMessages.count(UNKNOWN_CSS_FONT_PROPS_WARNINGS);
if (unknownPropsWarningCount > 0) {
const warnings = GroupMessages.flush(UNKNOWN_CSS_FONT_PROPS_WARNINGS).join('\n');
let err = `\nUnknown CSS Font Shorthand properties found for ${unknownPropsWarningCount} tokens, CSS output for Font values will be missing some typography token properties as a result:\n`;
if (this.log.verbosity === verbose) {
err += `\n${warnings}\n`;
} else {
err += `${verbosityInfo}\n`;
}
if (this.log.warnings === error) {
throw new Error(err);
} else if (this.log.warnings !== disabled && this.log.verbosity !== silent) {
// eslint-disable-next-line no-console
console.log(chalk.rgb(255, 140, 0).bold(err));
}
}
// TODO: When the transform / resolve have applied to the tokenMap instead of tokens, use the map and convert back to array / tokens obj
allTokens = convertTokenData(tokens, { output: 'array', usesDtcg: this.usesDtcg });
tokenMap = convertTokenData(allTokens, { output: 'map', usesDtcg: this.usesDtcg });
// TODO: re-enable below when the Map is the source of truth while transforming/resolving
// tokens = convertTokenData(allTokens, { output: 'object', usesDtcg: this.usesDtcg });
return {
tokens,
allTokens: /** @type {TransformedToken[]} */ (allTokens),
tokenMap: /** @type {Map<string,TransformedToken>} */ (tokenMap),
};
}
/**
* This will get the dictionary / platformConfig for specified platform name
* Runs transforms, reference resolutions
* @deprecated use getPlatformConfig / getPlatformTokens instead
* @param {string} platform
* @param {{ cache?: boolean }} [opts]
* @returns
*/
async getPlatform(platform, opts) {
await this.hasInitialized;
const platformConfig = this.getPlatformConfig(platform, opts);
const dictionary = await this.getPlatformTokens(platform, opts);
return {
dictionary,
platformConfig,
};
}
/**
* Runs the format step and collects logs
* This step may also return no output if all has been filtered out
* Or return no destination if no destination was given,
* this can happen if user wants to get the output in JS memory,
* rather than outputted as a file on the filesystem
*
* @param {File} file
* @param {PlatformConfig} platform
* @param {Dictionary} dictionary
* @returns {Promise<{
* logs: Record<"warning" | "success", string[]>
* destination?: string
* output?: unknown
* }>}
*/
async formatFile(file, platform, dictionary) {
await this.hasInitialized;
/** @type {Record<'warning'|'success', string[]>} */
const logs = {
warning: [],
success: [],
};
const { destination } = file || {};
const filter = /** @type {Filter['filter']|undefined} */ (file.filter);
let { format } = file || {};
if (typeof format !== 'function') throw new Error('Please enter a valid file format');
if (destination !== undefined && typeof destination !== 'string')
throw new Error('Please enter a valid destination');
// get if the format is nested, this needs to be done before
// the function is bound
const nested = format.nested;
// to maintain backwards compatibility we bind the format to the file object
format = format.bind(file);
let fullDestination = destination;
// if there is a build path, prepend the full destination with it
if (platform.buildPath) {
fullDestination = platform.buildPath + fullDestination;
}
const filteredTokens = await filterTokens(dictionary, filter, this.options);
const filteredDictionary = {
tokens: filteredTokens.tokens,
allTokens: filteredTokens.allTokens,
tokenMap: filteredTokens.tokenMap,
// keep the unfiltered tokens object for reference resolution
unfilteredTokens: dictionary.tokens,
unfilteredAllTokens: dictionary.allTokens,
unfilteredTokenMap: dictionary.tokenMap,
};
// if tokens object is empty, return without creating a file
if (
Object.hasOwn(filteredTokens, 'tokens') &&
Object.keys(filteredTokens.tokens).length === 0 &&
filteredTokens.tokens.constructor === Object
) {
let warnNoFile = `No tokens for ${destination}. File not created.`;
if (platform.log?.warnings === error) {
throw new Error(warnNoFile);
} else if (platform.log?.verbosity !== silent && platform.log?.warnings !== disabled) {
logs.warning.push(chalk.rgb(255, 140, 0)(warnNoFile));
}
return { logs, destination: fullDestination, output: undefined };
}
/**
* Check for token name Collisions
* @type {Record<string, TransformedToken[]>}
*/
const nameCollisionObj = {};
filteredTokens.allTokens &&
filteredTokens.allTokens.forEach((tokenData) => {
let tokenName = tokenData.name;
if (!nameCollisionObj[tokenName]) {
nameCollisionObj[tokenName] = [];
}
nameCollisionObj[tokenName].push(tokenData);
});
const PROPERTY_NAME_COLLISION_WARNINGS =
GroupMessages.GROUP.PropertyNameCollisionWarnings + ':' + destination;
GroupMessages.clear(PROPERTY_NAME_COLLISION_WARNINGS);
Object.keys(nameCollisionObj).forEach((tokenName) => {
if (nameCollisionObj[tokenName].length > 1) {
let collisions = nameCollisionObj[tokenName]
.map((token) => {
let tokenPathText = chalk.rgb(255, 69, 0)(token.path.join('.'));
let valueText = chalk.rgb(255, 140, 0)(this.usesDtcg ? token.$value : token.value);
return tokenPathText + ' ' + valueText;
})
.join('\n ');
GroupMessages.add(
PROPERTY_NAME_COLLISION_WARNINGS,
`Output name ${chalk
.rgb(255, 69, 0)
.bold(tokenName)} was generated by:\n ${collisions}`,
);
}
});
const tokenNamesCollisionCount = GroupMessages.count(PROPERTY_NAME_COLLISION_WARNINGS);
const formattedContent = await format(
createFormatArgs({
dictionary: filteredDictionary,
platform,
options: this.options,
file,
}),
);
const filteredReferencesCount = GroupMessages.count(FILTER_WARNINGS);
// don't show name collision warnings for nested type formats
// because they are not relevant.
if (
(nested || tokenNamesCollisionCount === 0) &&
filteredReferencesCount === 0 &&
platform.log?.verbosity !== silent
) {
logs.success.push(chalk.bold.green(`✔︎ ${fullDestination}`));
} else {
const warnHeader = `⚠️ ${fullDestination}`;
if (tokenNamesCollisionCount > 0) {
const tokenNamesCollisionWarnings = GroupMessages.fetchMessages(
PROPERTY_NAME_COLLISION_WARNINGS,
).join('\n ');
const title = `While building ${chalk
.rgb(255, 69, 0)
.bold(
destination,
)}, token collisions were found; output may be unexpected. Ignore this warning if intentional.`;
const help = chalk.rgb(
255,
165,
0,
)(
[
'This many-to-one issue is usually caused by some combination of:',
'* conflicting or similar paths/names in token definitions',
'* platform transforms/transformGroups affecting names, especially when removing specificity',
'* overly inclusive file filters',
].join('\n '),
);
const warn =
platform.log?.verbosity === verbose
? `${warnHeader}\n${title}\n ${tokenNamesCollisionWarnings}\n${help}`
: `${warnHeader}\n${title}\n\n${verbosityInfo}`;
if (platform?.log?.warnings === error) {
throw new Error(warn);
} else if (platform.log?.verbosity !== silent && platform.log?.warnings !== disabled) {
logs.warning.push(chalk.rgb(255, 140, 0).bold(warn));
}
}
if (filteredReferencesCount > 0) {
const filteredReferencesWarnings = GroupMessages.flush(FILTER_WARNINGS).join('\n ');
const title = `While building ${chalk
.rgb(255, 69, 0)
.bold(
destination,
)}, filtered out token references were found; output may be unexpected. Ignore this warning if intentional.`;
const help = chalk.rgb(
255,
165,
0,
)(['This is caused when combining a filter and `outputReferences`.'].join('\n '));
const warn =
platform.log?.verbosity === verbose
? `${warnHeader}\n${title}\nHere are the references that are used but not defined in the file:\n ${filteredReferencesWarnings}\n${help}`
: `${warnHeader}\n${title}\n\n${verbosityInfo}`;
if (platform?.log?.warnings === error) {
throw new Error(warn);
} else if (platform.log?.verbosity !== silent && platform.log?.warnings !== disabled) {
logs.warning.push(chalk.rgb(255, 140, 0).bold(warn));
}
}
}
return { logs, output: formattedContent, destination: fullDestination };
}
/**
* @param {string} platform
* @param {{ cache?: boolean }} [opts]
*/
async formatPlatform(platform, opts) {
await this.hasInitialized;
const platformConfig = this.getPlatformConfig(platform, opts);
const dictionary = await this.getPlatformTokens(platform, opts);
if (
platformConfig.buildPath &&
platformConfig.buildPath.slice(-1) !== '/' &&
platformConfig.buildPath.slice(-1) !== '\\'
) {
throw new Error('Build path must end in a trailing slash or you will get weird file names.');
}
if (!platformConfig.files) {
throw new Error(`Cannot format platform ${platform} due to missing "files" property`);
}
const formattedFiles = await Promise.all(
platformConfig.files.map((file) => {
if (file.format) {
return this.formatFile(file, platformConfig, dictionary);
} else {
throw new Error('Please supply a format');
}
}),
);
const logs = formattedFiles.map((formattedFile) => formattedFile.logs);
if (logs) {
if (this.log?.verbosity !== silent) {
// eslint-disable-next-line no-console
console.log('\n' + platform);
}
for (let logObj of logs) {
logObj.success.forEach((success) => {
// eslint-disable-next-line no-console
console.log(success);
});
logObj.warning.forEach((warning) => {
// eslint-disable-next-line no-console
console.log(warning);
});
}
}
return formattedFiles.map(({ output, destination }) => ({ output, destination }));
}
/**
* @param {{ cache?: boolean }} [opts]
* @returns
*/
async formatAllPlatforms(opts) {
await this.hasInitialized;
if (!this.platforms) {
throw new Error('Cannot format platforms due to missing property "platforms" on config');
}
/**
* @param {string} platformKey
*/
const getOutputsForPlatform = async (platformKey) => {
const outputs = await this.formatPlatform(platformKey, opts);
return { platform: platformKey, outputs };
};
const platformOutputs = await Promise.all(
Object.keys(this.platforms).map(getOutputsForPlatform),
);
/**
* Reduce platform outputs array into a keyed object, easier to consume this
*
* @type {Record<string, Array<{output: unknown; destination: string | undefined;}>>}
*/
return platformOutputs.reduce((acc, curr) => ({ ...acc, [curr.platform]: curr.outputs }), {});
}
/**
* @param {string} platform
* @param {{ cache?: boolean }} [opts]
* @returns
*/
async buildPlatform(platform, opts) {
await this.hasInitialized;
const platformConfig = this.getPlatformConfig(platform, opts);
const dictionary = await this.getPlatformTokens(platform, opts);
/**
* @param {string} destination
* @param {string} output
*/
const writeFile = async (destination, output) => {
const dir = dirname(destination);
try {
await this.volume.promises.access(dir);
} catch {
await this.volume.promises.mkdir(dir, { recursive: true });
}
return this.volume.promises.writeFile(destination, output);
};
const files = await this.formatPlatform(platform, opts);
if (files) {
await Promise.all(
files.map(({ destination, output }) => {
if (output && destination) {
if (typeof output !== 'string') {
if (this.log.verbosity !== silent && this.log.warnings !== disabled) {
const warn = `Content type of ${destination} is not a string, so no file was created.`;
if (this.log.warnings === error) {
throw new Error(warn);
}
// eslint-disable-next-line no-console
console.log(chalk.rgb(255, 140, 0).bold(warn));
}
return;
}
return writeFile(destination, output);
}
}),
);
}
await performActions(dictionary, platformConfig, this.options, this.volume);
// For chaining
return this;
}
/**
* @param {{ cache?: boolean }} [opts]
* @returns
*/
async buildAllPlatforms(opts) {
await this.hasInitialized;
if (this.platforms) {
await Promise.all(Object.keys(this.platforms).map((key) => this.buildPlatform(key, opts)));
}
// For chaining
return this;
}
/**
* @param {string} platform
* @param {{ cache?: boolean }} [opts]
* @returns
*/
async cleanPlatform(platform, opts) {
const platformConfig = this.getPlatformConfig(platform, opts);
const dictionary = await this.getPlatformTokens(platform, opts);
// collect logs, cleanFiles happens in parallel but we want to log in sequence
const logs = await cleanFiles(platformConfig, this.volume);
if (logs) {
if (this.log?.verbosity !== silent) {
// eslint-disable-next-line no-console
console.log('\n' + platform);
}
for (let logObj of logs) {
for (let success of logObj.success) {
// eslint-disable-next-line no-console
console.log(success);
}
for (let warning of logObj.warning) {
// eslint-disable-next-line no-console
console.log(warning);
}
}
}
await cleanActions(dictionary, platformConfig, this.options, this.volume);
await cleanDirs(platformConfig, this.volume);
// For chaining
return this;
}
/**
* @param {{ cache?: boolean }} [opts]
* @returns
*/
async cleanAllPlatforms(opts) {
await this.hasInitialized;
if (this.platforms) {
await Promise.all(Object.keys(this.platforms).map((key) => this.cleanPlatform(key, opts)));
}
// For chaining
return this;
}
}