diff --git a/.changeset/chilly-numbers-work.md b/.changeset/chilly-numbers-work.md new file mode 100644 index 000000000..b04733bba --- /dev/null +++ b/.changeset/chilly-numbers-work.md @@ -0,0 +1,21 @@ +--- +'style-dictionary': minor +--- + +Allow not throwing fatal errors on broken token references/aliases, but `console.error` instead. +`resolveReferences` and `getReferences` both allow passing `throwOnBrokenReferences` option, setting this to false will prevent a fatal error. +You can also configure this on global/platform `log` property: + +```json +{ + "log": { + "errors": { + "brokenReferences": "console" + } + } +} +``` + +This setting defaults to `"error"` when not configured. + +Some minor grammatical improvements to some of the error logs were also done. diff --git a/__integration__/__snapshots__/customFormats.test.snap.js b/__integration__/__snapshots__/customFormats.test.snap.js index 060c21614..4e4a4bad9 100644 --- a/__integration__/__snapshots__/customFormats.test.snap.js +++ b/__integration__/__snapshots__/customFormats.test.snap.js @@ -557,7 +557,10 @@ snapshots["integration custom formats inline custom with new args should match s ], "log": { "warnings": "warn", - "verbosity": "default" + "verbosity": "default", + "errors": { + "brokenReferences": "throw" + } }, "transforms": [ { @@ -945,7 +948,10 @@ snapshots["integration custom formats inline custom with new args should match s }, "log": { "warnings": "warn", - "verbosity": "default" + "verbosity": "default", + "errors": { + "brokenReferences": "throw" + } }, "usesDtcg": false, "otherOption": "Test", @@ -1503,7 +1509,10 @@ snapshots["integration custom formats register custom format with new args shoul ], "log": { "warnings": "warn", - "verbosity": "default" + "verbosity": "default", + "errors": { + "brokenReferences": "throw" + } }, "transforms": [ { @@ -1891,7 +1900,10 @@ snapshots["integration custom formats register custom format with new args shoul }, "log": { "warnings": "warn", - "verbosity": "default" + "verbosity": "default", + "errors": { + "brokenReferences": "throw" + } }, "usesDtcg": false, "otherOption": "Test", diff --git a/__integration__/logging/__snapshots__/file.test.snap.js b/__integration__/logging/__snapshots__/file.test.snap.js index b7dd01229..ec93c2e86 100644 --- a/__integration__/logging/__snapshots__/file.test.snap.js +++ b/__integration__/logging/__snapshots__/file.test.snap.js @@ -439,3 +439,9 @@ color.core.blue.0 This is caused when combining a filter and \`outputReferences\`.`; /* end snapshot integration logging file filtered references should throw detailed error of filtered references through "verbose" verbosity and log level set to error */ +snapshots["integration logging file empty tokens should not warn user about empty tokens with silent log verbosity"] = +` +css +No tokens for empty.css. File not created.`; +/* end snapshot integration logging file empty tokens should not warn user about empty tokens with silent log verbosity */ + diff --git a/__tests__/StyleDictionary.test.js b/__tests__/StyleDictionary.test.js index b20c8dadf..0e5db372d 100644 --- a/__tests__/StyleDictionary.test.js +++ b/__tests__/StyleDictionary.test.js @@ -19,6 +19,7 @@ import { resolve } from '../lib/resolve.js'; import GroupMessages from '../lib/utils/groupMessages.js'; import flattenTokens from '../lib/utils/flattenTokens.js'; import formats from '../lib/common/formats.js'; +import { restore, stubMethod } from 'hanbi'; function traverseObj(obj, fn) { for (let key in obj) { @@ -52,6 +53,7 @@ const test_props = { // extend method is called by StyleDictionary constructor, therefore we're basically testing both things here describe('StyleDictionary class', () => { beforeEach(() => { + restore(); clearOutput(); }); @@ -344,6 +346,54 @@ describe('StyleDictionary class', () => { }); }); + describe('reference errors', () => { + it('should throw an error by default if broken references are encountered', async () => { + const sd = new StyleDictionary({ + tokens: { + foo: { + value: '{bar}', + type: 'other', + }, + }, + platforms: { + css: {}, + }, + }); + + await expect(sd.exportPlatform('css')).to.eventually.be.rejectedWith(` +Reference Errors: +Some token references (1) could not be found. +Use log.verbosity "verbose" or use CLI option --verbose for more details. +`); + }); + + it('should only log an error if broken references are encountered and log.errors.brokenReferences is set to console-', async () => { + const stub = stubMethod(console, 'error'); + const sd = new StyleDictionary({ + log: { + errors: { + brokenReferences: 'console', + }, + }, + tokens: { + foo: { + value: '{bar}', + type: 'other', + }, + }, + platforms: { + css: {}, + }, + }); + await sd.exportPlatform('css'); + expect(stub.firstCall.args[0]).to.equal(` +Reference Errors: +Some token references (1) could not be found. +Use log.verbosity "verbose" or use CLI option --verbose for more details. +`); + }); + }); + describe('expand object value tokens', () => { it('should not expand object value tokens by default', async () => { const input = { diff --git a/__tests__/transform/tokenSetup.test.js b/__tests__/transform/tokenSetup.test.js index c4a7426d4..6ebf79dd6 100644 --- a/__tests__/transform/tokenSetup.test.js +++ b/__tests__/transform/tokenSetup.test.js @@ -16,15 +16,17 @@ import tokenSetup from '../../lib/transform/tokenSetup.js'; describe('transform', () => { describe('tokenSetup', () => { it('should error if property is not an object', () => { - expect(tokenSetup.bind(null, null, 'foo', [])).to.throw('Property object must be an object'); + expect(tokenSetup.bind(null, null, 'foo', [])).to.throw( + 'Token object must be of type "object"', + ); }); it('should error if name in not a string', () => { - expect(tokenSetup.bind(null, {}, null, [])).to.throw('Name must be a string'); + expect(tokenSetup.bind(null, {}, null, [])).to.throw('Token name must be a string'); }); it('should error path is not an array', () => { - expect(tokenSetup.bind(null, {}, 'name', null)).to.throw('Path must be an array'); + expect(tokenSetup.bind(null, {}, 'name', null)).to.throw('Token path must be an array'); }); it('should work if all the args are proper', () => { diff --git a/__tests__/utils/reference/getReferences.test.js b/__tests__/utils/reference/getReferences.test.js index aebc01224..6750719f6 100644 --- a/__tests__/utils/reference/getReferences.test.js +++ b/__tests__/utils/reference/getReferences.test.js @@ -12,7 +12,8 @@ */ import { expect } from 'chai'; -import { _getReferences, getReferences } from '../../../lib/utils/references/getReferences.js'; +import { restore, stubMethod } from 'hanbi'; +import { getReferences } from '../../../lib/utils/references/getReferences.js'; const tokens = { color: { @@ -50,38 +51,63 @@ describe('utils', () => { describe('reference', () => { describe('getReferences()', () => { describe('public API', () => { + beforeEach(() => { + restore(); + }); + it('should not collect errors but rather throw immediately when using public API', () => { expect(() => getReferences('{foo.bar}', tokens)).to.throw( - `tries to reference foo.bar, which is not defined.`, + `Tries to reference foo.bar, which is not defined.`, + ); + }); + + it('should not collect errors but rather throw immediately when using public API', () => { + const stub = stubMethod(console, 'error'); + getReferences('{foo.bar}', tokens, { throwOnBrokenReferences: false }); + expect(stub.firstCall.args[0]).to.equal( + `Tries to reference foo.bar, which is not defined.`, + ); + }); + + it('should allow warning immediately when references are filtered out', async () => { + const stub = stubMethod(console, 'warn'); + const clonedTokens = structuredClone(tokens); + delete clonedTokens.color.red; + getReferences('{color.red}', clonedTokens, { + unfilteredTokens: tokens, + warnImmediately: true, + }); + expect(stub.firstCall.args[0]).to.equal( + `Filtered out token references were found: color.red`, ); }); }); it(`should return an empty array if the value has no references`, () => { - expect(_getReferences(tokens.color.red.value, tokens)).to.eql([]); + expect(getReferences(tokens.color.red.value, tokens)).to.eql([]); }); it(`should work with a single reference`, () => { - expect(_getReferences(tokens.color.danger.value, tokens)).to.eql([ + expect(getReferences(tokens.color.danger.value, tokens)).to.eql([ { ref: ['color', 'red'], value: '#f00' }, ]); }); it(`should work with object values`, () => { - expect(_getReferences(tokens.border.primary.value, tokens)).to.eql([ + expect(getReferences(tokens.border.primary.value, tokens)).to.eql([ { ref: ['color', 'red'], value: '#f00' }, { ref: ['size', 'border'], value: '2px' }, ]); }); it(`should work with objects that have numbers`, () => { - expect(_getReferences(tokens.border.secondary.value, tokens)).to.eql([ + expect(getReferences(tokens.border.secondary.value, tokens)).to.eql([ { ref: ['color', 'red'], value: '#f00' }, ]); }); it(`should work with interpolated values`, () => { - expect(_getReferences(tokens.border.tertiary.value, tokens)).to.eql([ + expect(getReferences(tokens.border.tertiary.value, tokens)).to.eql([ { ref: ['size', 'border'], value: '2px' }, { ref: ['color', 'red'], value: '#f00' }, ]); diff --git a/__tests__/utils/reference/resolveReferences.test.js b/__tests__/utils/reference/resolveReferences.test.js index 66074092f..6105a03f4 100644 --- a/__tests__/utils/reference/resolveReferences.test.js +++ b/__tests__/utils/reference/resolveReferences.test.js @@ -11,6 +11,7 @@ * and limitations under the License. */ import { expect } from 'chai'; +import { restore, stubMethod } from 'hanbi'; import { fileToJSON } from '../../__helpers.js'; import { _resolveReferences as resolveReferences, @@ -25,6 +26,7 @@ describe('utils', () => { describe('resolveReferences', () => { beforeEach(() => { GroupMessages.clear(PROPERTY_REFERENCE_WARNINGS); + restore(); }); describe('public API', () => { @@ -40,6 +42,23 @@ describe('utils', () => { `tries to reference d, which is not defined.`, ); }); + + it('should only console.error if throwOnBrokenReferences is disabled', async () => { + const stub = stubMethod(console, 'error'); + publicResolveReferences('{foo.bar}', {}, { throwOnBrokenReferences: false }); + expect(stub.firstCall.args[0]).to.equal( + `tries to reference foo.bar, which is not defined.`, + ); + }); + + it('should gracefully handle basic circular references if throwOnBrokenReferences is disabled', () => { + const stub = stubMethod(console, 'error'); + const obj = fileToJSON('__tests__/__json_files/circular.json'); + expect(publicResolveReferences(obj.a, obj, { throwOnBrokenReferences: false })).to.equal( + '{b}', + ); + expect(stub.firstCall.args[0]).to.equal('Circular definition cycle: b, c, d, a, b'); + }); }); it('should do simple references', () => { @@ -118,55 +137,55 @@ describe('utils', () => { expect(resolveReferences(obj.e[1].a, obj)).to.equal(2); }); - it("should store warning if pointers don't exist", () => { + it('should collect reference errors when warnImmediately is set to false', () => { const obj = fileToJSON('__tests__/__json_files/non_existent.json'); - expect(resolveReferences(obj.foo, obj)).to.be.undefined; - expect(resolveReferences(obj.error, obj)).to.be.undefined; + expect(resolveReferences(obj.foo, obj, { warnImmediately: false })).to.be.undefined; + expect(resolveReferences(obj.error, obj, { warnImmediately: false })).to.be.undefined; expect(GroupMessages.fetchMessages(PROPERTY_REFERENCE_WARNINGS)).to.eql([ 'tries to reference bar, which is not defined.', 'tries to reference a.b.d, which is not defined.', ]); }); - it('should gracefully handle basic circular references', () => { + it('should gracefully handle basic circular references, collect warnings when warnImmediately is set to false', () => { const obj = fileToJSON('__tests__/__json_files/circular.json'); - expect(resolveReferences(obj.a, obj)).to.equal('{b}'); + expect(resolveReferences(obj.a, obj, { warnImmediately: false })).to.equal('{b}'); expect(GroupMessages.count(PROPERTY_REFERENCE_WARNINGS)).to.equal(1); expect(JSON.stringify(GroupMessages.fetchMessages(PROPERTY_REFERENCE_WARNINGS))).to.equal( JSON.stringify(['Circular definition cycle: b, c, d, a, b']), ); }); - it('should gracefully handle basic and nested circular references', () => { + it('should gracefully handle basic and nested circular references, collect warnings when warnImmediately is set to false', () => { const obj = fileToJSON('__tests__/__json_files/circular_2.json'); - expect(resolveReferences(obj.j, obj)).to.equal('{a.b.c}'); + expect(resolveReferences(obj.j, obj, { warnImmediately: false })).to.equal('{a.b.c}'); expect(GroupMessages.count(PROPERTY_REFERENCE_WARNINGS)).to.equal(1); expect(JSON.stringify(GroupMessages.fetchMessages(PROPERTY_REFERENCE_WARNINGS))).to.equal( JSON.stringify(['Circular definition cycle: a.b.c, j, a.b.c']), ); }); - it('should gracefully handle nested circular references', () => { + it('should gracefully handle nested circular references, collect warnings when warnImmediately is set to false', () => { const obj = fileToJSON('__tests__/__json_files/circular_3.json'); - expect(resolveReferences(obj.c.d.e, obj)).to.equal('{a.b}'); + expect(resolveReferences(obj.c.d.e, obj, { warnImmediately: false })).to.equal('{a.b}'); expect(GroupMessages.count(PROPERTY_REFERENCE_WARNINGS)).to.equal(1); expect(JSON.stringify(GroupMessages.fetchMessages(PROPERTY_REFERENCE_WARNINGS))).to.equal( JSON.stringify(['Circular definition cycle: a.b, c.d.e, a.b']), ); }); - it('should gracefully handle multiple nested circular references', () => { + it('should gracefully handle multiple nested circular references, collect warnings when warnImmediately is set to false', () => { const obj = fileToJSON('__tests__/__json_files/circular_4.json'); - expect(resolveReferences(obj.h.i, obj)).to.equal('{a.b.c.d}'); + expect(resolveReferences(obj.h.i, obj, { warnImmediately: false })).to.equal('{a.b.c.d}'); expect(GroupMessages.count(PROPERTY_REFERENCE_WARNINGS)).to.equal(1); expect(JSON.stringify(GroupMessages.fetchMessages(PROPERTY_REFERENCE_WARNINGS))).to.equal( JSON.stringify(['Circular definition cycle: a.b.c.d, e.f.g, h.i, a.b.c.d']), ); }); - it('should gracefully handle down-chain circular references', () => { + it('should gracefully handle down-chain circular references, collect warnings when warnImmediately is set to false', () => { const obj = fileToJSON('__tests__/__json_files/circular_5.json'); - expect(resolveReferences(obj.n, obj)).to.equal('{l}'); + expect(resolveReferences(obj.n, obj, { warnImmediately: false })).to.equal('{l}'); expect(GroupMessages.count(PROPERTY_REFERENCE_WARNINGS)).to.equal(1); expect(JSON.stringify(GroupMessages.fetchMessages(PROPERTY_REFERENCE_WARNINGS))).to.equal( JSON.stringify(['Circular definition cycle: l, m, l']), @@ -175,18 +194,22 @@ describe('utils', () => { it('should correctly resolve multiple references without reference errors', function () { const obj = fileToJSON('__tests__/__json_files/not_circular.json'); - expect(resolveReferences(obj.prop8.value, obj)).to.equal(5); - expect(resolveReferences(obj.prop12.value, obj)).to.equal( + expect(resolveReferences(obj.prop8.value, obj, { warnImmediately: false })).to.equal(5); + expect(resolveReferences(obj.prop12.value, obj, { warnImmediately: false })).to.equal( 'test1 value, test2 value and some extra stuff', ); - expect(resolveReferences(obj.prop124.value, obj)).to.equal( + expect(resolveReferences(obj.prop124.value, obj, { warnImmediately: false })).to.equal( 'test1 value, test2 value and test1 value', ); - expect(resolveReferences(obj.prop15.value, obj)).to.equal( + expect(resolveReferences(obj.prop15.value, obj, { warnImmediately: false })).to.equal( 'test1 value, 5 and some extra stuff', ); - expect(resolveReferences(obj.prop156.value, obj)).to.equal('test1 value, 5 and 6'); - expect(resolveReferences(obj.prop1568.value, obj)).to.equal('test1 value, 5, 6 and 5'); + expect(resolveReferences(obj.prop156.value, obj, { warnImmediately: false })).to.equal( + 'test1 value, 5 and 6', + ); + expect(resolveReferences(obj.prop1568.value, obj, { warnImmediately: false })).to.equal( + 'test1 value, 5, 6 and 5', + ); expect(GroupMessages.count(PROPERTY_REFERENCE_WARNINGS)).to.equal(0); }); @@ -212,11 +235,11 @@ describe('utils', () => { expect(test).to.equal('foo'); }); - it('should collect multiple reference errors', () => { + it('should collect multiple reference errors when warnImmediately is set to false', () => { const obj = fileToJSON('__tests__/__json_files/multiple_reference_errors.json'); - expect(resolveReferences(obj.a.b, obj)).to.be.undefined; - expect(resolveReferences(obj.a.c, obj)).to.be.undefined; - expect(resolveReferences(obj.a.d, obj)).to.be.undefined; + expect(resolveReferences(obj.a.b, obj, { warnImmediately: false })).to.be.undefined; + expect(resolveReferences(obj.a.c, obj, { warnImmediately: false })).to.be.undefined; + expect(resolveReferences(obj.a.d, obj, { warnImmediately: false })).to.be.undefined; expect(GroupMessages.count(PROPERTY_REFERENCE_WARNINGS)).to.equal(3); expect(JSON.stringify(GroupMessages.fetchMessages(PROPERTY_REFERENCE_WARNINGS))).to.equal( JSON.stringify([ @@ -232,7 +255,7 @@ describe('utils', () => { test: { value: '{zero.value}' }, zero: { value: 0 }, }; - const test = resolveReferences(obj.test.value, obj); + const test = resolveReferences(obj.test.value, obj, { warnImmediately: false }); expect(GroupMessages.fetchMessages(PROPERTY_REFERENCE_WARNINGS).length).to.equal(0); expect(test).to.equal(0); }); diff --git a/docs/src/content/docs/reference/Utils/references.md b/docs/src/content/docs/reference/Utils/references.md index 44bcc6fc5..ea4b4dae0 100644 --- a/docs/src/content/docs/reference/Utils/references.md +++ b/docs/src/content/docs/reference/Utils/references.md @@ -53,10 +53,11 @@ resolveReferences('solid {spacing.2} {colors.black}', sd.tokens); // alternative resolveReferences('solid {spacing.2} {colors.black}', sd.tokens, { usesDtcg: true }); // Assumes DTCG spec format, with $ prefix ($value, $type) ``` -:::note -You can pass a third `options` argument where you can pass some configuration options for how references are resolved -Most notable option for public usage is `usesDtcg`, if set to true, the `resolveReferences` utility will assume DTCG syntax (`$value` props). -::: +You can pass a third `options` argument where you can pass some configuration options for how references are resolved: + +- `usesDtcg` boolean, if set to true, the `resolveReferences` utility will assume DTCG syntax (`$value` props) +- `throwOnBrokenReferences` boolean, if set to true, it will `console.error` for reference errors instead of fatally throw +- `warnImmediately` boolean, `true` by default. You should only set this to `false` if you know that this utility is used used inside of the Transform lifecycle hook of Style Dictionary, allowing the errors to be grouped and only thrown at the end of the transform step (end of [exportPlatform](/reference/api#exportplatform) method). ## getReferences @@ -97,10 +98,12 @@ getReferences('solid {spacing.2} {colors.black}', sd.tokens, { usesDtcg: true }) */ ``` -:::note -You can pass a third `options` argument where you can pass some configuration options for how references are resolved -Most notable option for public usage is `usesDtcg`, if set to true, the `resolveReferences` utility will assume DTCG syntax (`$value` props) -::: +You can pass a third `options` argument where you can pass some configuration options for how references are resolved: + +- `usesDtcg` boolean, if set to true, the `resolveReferences` utility will assume DTCG syntax (`$value` props) +- `throwOnBrokenReferences` boolean, if set to true, it will `console.error` for reference errors instead of fatally throw +- `unfilteredTokens`, assuming the second `tokens` argument is your filtered `tokens` object where [filters](/reference/hooks/filters) have already done its work, you'll likely want to pass the unfiltered set in case the reference you're trying to find no longer exist in the filtered set, but you still want to get the reference values. This is useful when you're writing your own custom format with an `outputReferences` feature and you want to prevent outputting refs that no longer exist in the filtered set. +- `warnImmediately` boolean, `true` by default. You should only set this to `false` if you know that this utility is used inside of the Format lifecycle hook of Style Dictionary, allowing the errors to be grouped and only thrown at the end of the format step. ### Complicated example diff --git a/docs/src/content/docs/reference/logging.md b/docs/src/content/docs/reference/logging.md index ac58d0e21..cbf607ab0 100644 --- a/docs/src/content/docs/reference/logging.md +++ b/docs/src/content/docs/reference/logging.md @@ -12,17 +12,22 @@ const sd = new StyleDictionary({ log: { warnings: 'warn', // 'warn' | 'error' | 'disabled' verbosity: 'default', // 'default' | 'silent' | 'verbose' + errors: { + brokenReferences: 'throw', // 'throw' | 'console' + }, }, }); ``` > `log` can also be set on platform specific configuration -| Param | Type | Description | -| --------------- | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `log` | `Object` | | -| `log.warnings` | `'warn' \| 'error' \| 'disabled'` | Whether warnings should be logged as warnings, thrown as errors or disabled entirely. Defaults to 'warn' | -| `log.verbosity` | `'default' \|'silent' \| 'verbose'` | How verbose logs should be, default value is 'default'. 'silent' means no logs at all apart from fatal errors. 'verbose' means detailed error messages for debugging | +| Param | Type | Description | +| ----------------------------- | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `log` | `Object` | | +| `log.warnings` | `'warn' \| 'error' \| 'disabled'` | Whether warnings should be logged as warnings, thrown as errors or disabled entirely. Defaults to 'warn' | +| `log.verbosity` | `'default' \|'silent' \| 'verbose'` | How verbose logs should be, default value is 'default'. 'silent' means no logs at all apart from fatal errors. 'verbose' means detailed error messages for debugging | +| `log.errors` | `Object` | How verbose logs should be, default value is 'default'. 'silent' means no logs at all apart from fatal errors. 'verbose' means detailed error messages for debugging | +| `log.errors.brokenReferences` | `'throw' \| 'console'` | Whether broken references in tokens should throw a fatal error or only a `console.error` without exiting the process. | There are five types of warnings that will be thrown as errors instead of being logged as warnings when `log.warnings` is set to `error`: diff --git a/docs/starlight-config.ts b/docs/starlight-config.ts index 7e89b5a54..eb86774c5 100644 --- a/docs/starlight-config.ts +++ b/docs/starlight-config.ts @@ -18,11 +18,11 @@ export default { 'Export your Design Tokens to any platform. iOS, Android, CSS, JS, HTML, sketch files, style documentation, or anything you can think of. Forward-compatible with Design Token Community Group spec.', logo: { src: './src/assets/logo.png', alt: 'Style-Dictionary logo, Pascal the chameleon.' }, editLink: { - baseUrl: 'https://github.com/amzn/style-dictionary/edit/v4/src/content/docs/', + baseUrl: 'https://github.com/amzn/style-dictionary/edit/v4/docs/src/content/docs/', }, favicon: '/favicon.png', social: { - github: 'https://github.com/amzn/style-dictionary', + github: 'https://github.com/amzn/style-dictionary/tree/v4', slack: 'https://join.slack.com/t/tokens-studio/shared_invite/zt-1p8ea3m6t-C163oJcN9g3~YZTKRgo2hg', }, diff --git a/lib/StyleDictionary.js b/lib/StyleDictionary.js index 8da4f691c..367c56914 100644 --- a/lib/StyleDictionary.js +++ b/lib/StyleDictionary.js @@ -59,6 +59,7 @@ import cleanActions from './cleanActions.js'; 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; /** * Style Dictionary module @@ -118,6 +119,9 @@ export default class StyleDictionary extends Register { this.log = { warnings: 'warn', verbosity: 'default', + errors: { + brokenReferences: 'throw', + }, }; /** @type {string[]} */ this.source = []; @@ -503,7 +507,11 @@ export default class StyleDictionary extends Register { err += `${verbosityInfo}\n`; } - throw new Error(err); + if (this.log.errors?.brokenReferences === 'throw') { + throw new Error(err); + } else { + console.error(err); + } } const unknownPropsWarningCount = GroupMessages.count(UNKNOWN_CSS_FONT_PROPS_WARNINGS); @@ -669,9 +677,7 @@ export default class StyleDictionary extends Register { }), ); - const filteredReferencesCount = GroupMessages.count( - GroupMessages.GROUP.FilteredOutputReferences, - ); + const filteredReferencesCount = GroupMessages.count(FILTER_WARNINGS); // don't show name collision warnings for nested type formats // because they are not relevant. @@ -716,9 +722,7 @@ export default class StyleDictionary extends Register { } if (filteredReferencesCount > 0) { - const filteredReferencesWarnings = GroupMessages.flush( - GroupMessages.GROUP.FilteredOutputReferences, - ).join('\n '); + const filteredReferencesWarnings = GroupMessages.flush(FILTER_WARNINGS).join('\n '); const title = `While building ${chalk .rgb(255, 69, 0) .bold( diff --git a/lib/cleanFiles.js b/lib/cleanFiles.js index 230de0c64..3fc9f29dc 100644 --- a/lib/cleanFiles.js +++ b/lib/cleanFiles.js @@ -38,7 +38,7 @@ export default async function cleanFiles(platform, vol) { if (file.format) { return cleanFile(file, platform, vol); } else { - throw new Error('Please supply a template or format'); + throw new Error('Please supply a format'); } }), ); diff --git a/lib/common/formatHelpers/createPropertyFormatter.js b/lib/common/formatHelpers/createPropertyFormatter.js index 10fc39c85..a7938cb31 100644 --- a/lib/common/formatHelpers/createPropertyFormatter.js +++ b/lib/common/formatHelpers/createPropertyFormatter.js @@ -10,7 +10,7 @@ * CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions * and limitations under the License. */ -import { _getReferences } from '../../utils/references/getReferences.js'; +import { getReferences } from '../../utils/references/getReferences.js'; import usesReferences from '../../utils/references/usesReferences.js'; /** @@ -186,7 +186,12 @@ export default function createPropertyFormatter({ if (shouldOutputRef) { // Formats that use this function expect `value` to be a string // or else you will get '[object Object]' in the output - const refs = _getReferences(originalValue, tokens, { unfilteredTokens }, []); + const refs = getReferences( + originalValue, + tokens, + { unfilteredTokens, warnImmediately: false }, + [], + ); // original can either be an object value, which requires transitive value transformation in web CSS formats // or a different (primitive) type, meaning it can be stringified. diff --git a/lib/common/formatHelpers/sortByReference.js b/lib/common/formatHelpers/sortByReference.js index 37b603240..4bdd87b6c 100644 --- a/lib/common/formatHelpers/sortByReference.js +++ b/lib/common/formatHelpers/sortByReference.js @@ -12,7 +12,7 @@ */ import usesReferences from '../../utils/references/usesReferences.js'; -import { _getReferences } from '../../utils/references/getReferences.js'; +import { getReferences } from '../../utils/references/getReferences.js'; /** * @typedef {import('../../../types/DesignToken.d.ts').TransformedTokens} Tokens @@ -57,11 +57,13 @@ export default function sortByReference(tokens, opts) { if (a.original && usesReferences(a.original.value)) { // Both a and b have references, we need to see if they reference each other if (b.original && usesReferences(b.original.value)) { - const aRefs = _getReferences(a.original.value, tokens, { + const aRefs = getReferences(a.original.value, tokens, { unfilteredTokens: opts?.unfilteredTokens, + warnImmediately: false, }); - const bRefs = _getReferences(b.original.value, tokens, { + const bRefs = getReferences(b.original.value, tokens, { unfilteredTokens: opts?.unfilteredTokens, + warnImmediately: false, }); aRefs.forEach((aRef) => { diff --git a/lib/common/templates/android/resources.template.js b/lib/common/templates/android/resources.template.js index e73bb3c38..6caf9eaaa 100644 --- a/lib/common/templates/android/resources.template.js +++ b/lib/common/templates/android/resources.template.js @@ -71,7 +71,10 @@ export default (opts) => { let originalValue = options.usesDtcg ? token.original.$value : token.original.value; if (file?.options && file.options.outputReferences && usesReferences(originalValue)) { return `@${tokenToType(token, options)}/${ - getReferences(originalValue, tokens, { usesDtcg: options.usesDtcg })[0].name + getReferences(originalValue, tokens, { + usesDtcg: options.usesDtcg, + warnImmediately: false, + })[0].name }`; } else { return options.usesDtcg ? token.$value : token.value; diff --git a/lib/transform/tokenSetup.js b/lib/transform/tokenSetup.js index 6f93c0dfb..fe61c5569 100644 --- a/lib/transform/tokenSetup.js +++ b/lib/transform/tokenSetup.js @@ -30,9 +30,9 @@ import isPlainObject from 'is-plain-obj'; * @returns {TransformedToken} - A new object that is setup and ready to go. */ export default function tokenSetup(token, name, path) { - if (!token && !isPlainObject(token)) throw new Error('Property object must be an object'); - if (!name || !(typeof name === 'string')) throw new Error('Name must be a string'); - if (!path || !Array.isArray(path)) throw new Error('Path must be an array'); + if (!token && !isPlainObject(token)) throw new Error('Token object must be of type "object"'); + if (!name || !(typeof name === 'string')) throw new Error('Token name must be a string'); + if (!path || !Array.isArray(path)) throw new Error('Token path must be an array'); let to_ret = token; diff --git a/lib/utils/convertToBase64.js b/lib/utils/convertToBase64.js index 0628716d2..69c786d2b 100644 --- a/lib/utils/convertToBase64.js +++ b/lib/utils/convertToBase64.js @@ -23,7 +23,7 @@ import { resolve } from '../resolve.js'; * @returns {String} */ export default function convertToBase64(filePath, vol = fs) { - if (typeof filePath !== 'string') throw new Error('filePath name must be a string'); + if (typeof filePath !== 'string') throw new Error('Token filePath name must be a string'); const body = /** @type {string} */ ( vol.readFileSync(resolve(filePath, vol.__custom_fs__), 'utf-8') diff --git a/lib/utils/references/getName.js b/lib/utils/references/getName.js index 4278a35c3..1cd344135 100644 --- a/lib/utils/references/getName.js +++ b/lib/utils/references/getName.js @@ -26,7 +26,7 @@ import defaults from './defaults.js'; export default function getName(path, opts = {}) { const options = { ...defaults, ...opts }; if (!Array.isArray(path)) { - throw new Error('Getting name for path failed. Path must be an array'); + throw new Error('Getting name for path failed. Token path must be an array of strings'); } return path.join(options.separator); } diff --git a/lib/utils/references/getPathFromName.js b/lib/utils/references/getPathFromName.js index 38afbdc28..29f0fd878 100644 --- a/lib/utils/references/getPathFromName.js +++ b/lib/utils/references/getPathFromName.js @@ -23,7 +23,7 @@ import defaults from './defaults.js'; export default function getPathFromName(pathName, separator) { const sep = separator ?? defaults.separator; if (typeof pathName !== 'string') { - throw new Error('Getting path from name failed. Name must be a string'); + throw new Error('Getting path from name failed. Token name must be a string'); } return pathName.split(sep); } diff --git a/lib/utils/references/getReferences.js b/lib/utils/references/getReferences.js index 48f20066f..564591331 100644 --- a/lib/utils/references/getReferences.js +++ b/lib/utils/references/getReferences.js @@ -23,19 +23,7 @@ const FILTER_WARNINGS = GroupMessages.GROUP.FilteredOutputReferences; * @typedef {import('../../StyleDictionary.js').default} Dictionary * @typedef {import('../../../types/DesignToken.d.ts').TransformedTokens} Tokens * @typedef {import('../../../types/DesignToken.d.ts').TransformedToken} Token - * - * Public API wrapper around the function below this one - * - * @memberof Dictionary - * @param {string|Object} value the value that contains a reference - * @param {Tokens} tokens the dictionary to search in - * @param {GetReferencesOptions} [opts] - * @param {Token[]} [references] array of token's references because a token's value can contain multiple references due to string interpolation - * @returns {Token[]} */ -export function getReferences(value, tokens, opts = {}, references = []) { - return _getReferences(value, tokens, opts, references, true); -} /** * This is a helper function that is added to the dictionary object that @@ -49,48 +37,61 @@ export function getReferences(value, tokens, opts = {}, references = []) { * * @param {string|Object} value the value that contains a reference * @param {Tokens} tokens the dictionary to search in - * @param {GetReferencesOptions & { unfilteredTokens?: Tokens }} [opts] + * @param {GetReferencesOptions} [opts] * @param {Token[]} [references] array of token's references because a token's value can contain multiple references due to string interpolation - * @param {boolean} [throwImmediately] * @returns {Token[]} */ -export function _getReferences( - value, - tokens, - opts = {}, - references = [], - throwImmediately = false, -) { - const { usesDtcg } = opts; +export function getReferences(value, tokens, opts = {}, references = []) { + const { + usesDtcg, + separator, + throwOnBrokenReferences = true, + warnImmediately = true, + unfilteredTokens, + } = opts; const regex = createReferenceRegex(opts); /** * this will update the references array with the referenced tokens it finds. - * @param {string} match + * @param {string} _ * @param {string} variable */ - function findReference(match, variable) { + function findReference(_, variable) { // remove 'value' to access the whole token object variable = variable.trim().replace(`.${usesDtcg ? '$' : ''}value`, ''); // Find what the value is referencing - const pathName = getPathFromName(variable, opts.separator ?? defaults.separator); + const pathName = getPathFromName(variable, separator ?? defaults.separator); let ref = getValueByPath(pathName, tokens); - if (ref === undefined && opts.unfilteredTokens) { - if (!throwImmediately) { - // warn the user about this + let unfilteredWarning; + if (ref === undefined && unfilteredTokens) { + // warn the user about this + if (warnImmediately) { + unfilteredWarning = `Filtered out token references were found: ${variable}`; + } else { + // we collect the warning and warn later in the process GroupMessages.add(FILTER_WARNINGS, variable); } // fall back on unfilteredTokens as it is unfiltered - ref = getValueByPath(pathName, opts.unfilteredTokens); + ref = getValueByPath(pathName, unfilteredTokens); } if (ref !== undefined) { references.push({ ...ref, ref: pathName }); - } else if (throwImmediately) { - throw new Error(`tries to reference ${variable}, which is not defined.`); + // not undefined anymore which means that if unfilteredWarning was set earlier, + // the missing ref is due to it being filtered out + if (unfilteredWarning) { + console.warn(unfilteredWarning); + } + } else { + const errMessage = `Tries to reference ${variable}, which is not defined.`; + if (throwOnBrokenReferences) { + throw new Error(errMessage); + } else { + console.error(errMessage); + } } return ''; } @@ -112,7 +113,7 @@ export function _getReferences( } // if it is an object, we go further down the rabbit hole if (typeof value[key] === 'object') { - _getReferences(value[key], tokens, opts, references); + getReferences(value[key], tokens, opts, references); } } } diff --git a/lib/utils/references/outputReferencesFilter.js b/lib/utils/references/outputReferencesFilter.js index cc14ecdb0..e16414e81 100644 --- a/lib/utils/references/outputReferencesFilter.js +++ b/lib/utils/references/outputReferencesFilter.js @@ -17,6 +17,7 @@ export function outputReferencesFilter(token, { dictionary, usesDtcg }) { const refs = getReferences(originalValue, dictionary.tokens, { unfilteredTokens: dictionary.unfilteredTokens, usesDtcg, + warnImmediately: false, }); return refs.every((ref) => { // check whether every ref can be found in the filtered set of tokens diff --git a/lib/utils/references/outputReferencesTransformed.js b/lib/utils/references/outputReferencesTransformed.js index 6f772327f..d7274876c 100644 --- a/lib/utils/references/outputReferencesTransformed.js +++ b/lib/utils/references/outputReferencesTransformed.js @@ -22,6 +22,7 @@ export function outputReferencesTransformed(token, { dictionary, usesDtcg }) { value === resolveReferences(originalValue, dictionary.unfilteredTokens ?? dictionary.tokens, { usesDtcg, + warnImmediately: false, }) ); } diff --git a/lib/utils/references/resolveReferences.js b/lib/utils/references/resolveReferences.js index 53704b6c4..e64795cd2 100644 --- a/lib/utils/references/resolveReferences.js +++ b/lib/utils/references/resolveReferences.js @@ -36,7 +36,9 @@ const PROPERTY_REFERENCE_WARNINGS = GroupMessages.GROUP.PropertyReferenceWarning * @returns {string|number|undefined} */ export function resolveReferences(value, tokens, opts) { - return _resolveReferences(value, tokens, { ...opts, throwImmediately: true }); + // when using this public API / util, we always throw warnings immediately rather than + // putting them in the GroupMessages PROPERTY_REFERENCE_WARNINGS to collect and throw later on. + return _resolveReferences(value, tokens, opts); } /** @@ -54,14 +56,15 @@ export function _resolveReferences( separator = defaults.separator, opening_character = defaults.opening_character, closing_character = defaults.closing_character, - ignorePaths = [], usesDtcg = false, + throwOnBrokenReferences = true, + warnImmediately = true, // for internal usage + ignorePaths = [], current_context = [], stack = [], foundCirc = {}, firstIteration = true, - throwImmediately = false, } = {}, ) { const reg = regex ?? createReferenceRegex({ opening_character, closing_character, separator }); @@ -90,8 +93,6 @@ export function _resolveReferences( const pathName = getPathFromName(variable, separator); const refHasValue = valProp === pathName[pathName.length - 1]; - - // FIXME: shouldn't these two "refHasValue" conditions be reversed?? if (refHasValue && ignorePaths.indexOf(variable) !== -1) { return ''; } else if (!refHasValue && ignorePaths.indexOf(`${variable}.${valProp}`) !== -1) { @@ -142,8 +143,12 @@ export function _resolveReferences( // Add circ reference info to our list of warning messages const warning = `Circular definition cycle: ${circStack.join(', ')}`; - if (throwImmediately) { - throw new Error(warning); + if (warnImmediately) { + if (throwOnBrokenReferences) { + throw new Error(warning); + } else { + console.error(warning); + } } else { GroupMessages.add( PROPERTY_REFERENCE_WARNINGS, @@ -155,6 +160,8 @@ export function _resolveReferences( regex: reg, ignorePaths, usesDtcg, + throwOnBrokenReferences, + warnImmediately, current_context, separator, stack, @@ -178,8 +185,12 @@ export function _resolveReferences( const warning = `${ context ? `${context} ` : '' }tries to reference ${variable}, which is not defined.`; - if (throwImmediately) { - throw new Error(warning); + if (warnImmediately) { + if (throwOnBrokenReferences) { + throw new Error(warning); + } else { + console.error(warning); + } } else { GroupMessages.add(PROPERTY_REFERENCE_WARNINGS, warning); } diff --git a/lib/utils/resolveObject.js b/lib/utils/resolveObject.js index efb329f68..cdd0b2051 100644 --- a/lib/utils/resolveObject.js +++ b/lib/utils/resolveObject.js @@ -74,6 +74,8 @@ function traverseObj(slice, fullObj, opts, current_context, foundCirc) { if (/** @type {string} */ (prop).indexOf('{') > -1) { const ref = _resolveReferences(prop, fullObj, { ...opts, + // we're in transform hook phase, we collect warnings and throw a grouped error later + warnImmediately: false, current_context, foundCirc, }); diff --git a/package-lock.json b/package-lock.json index 700724c90..c63f12074 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "style-dictionary", - "version": "4.0.0-prerelease.32", + "version": "4.0.0-prerelease.35", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "style-dictionary", - "version": "4.0.0-prerelease.32", + "version": "4.0.0-prerelease.35", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/types/Config.d.ts b/types/Config.d.ts index 367190e1e..9e98d1bcd 100644 --- a/types/Config.d.ts +++ b/types/Config.d.ts @@ -49,25 +49,31 @@ export interface RegexOptions { export interface GetReferencesOptions extends RegexOptions { usesDtcg?: boolean; + throwOnBrokenReferences?: boolean; unfilteredTokens?: DesignTokens; + warnImmediately?: boolean; } export interface ResolveReferencesOptions extends RegexOptions { - ignorePaths?: string[]; usesDtcg?: boolean; + throwOnBrokenReferences?: boolean; + warnImmediately?: boolean; } export interface ResolveReferencesOptionsInternal extends ResolveReferencesOptions { + ignorePaths?: string[]; current_context?: string[]; stack?: string[]; foundCirc?: Record; firstIteration?: boolean; - throwImmediately?: boolean; } export interface LogConfig { warnings?: 'warn' | 'error' | 'disabled'; verbosity?: 'default' | 'silent' | 'verbose'; + errors?: { + brokenReferences?: 'throw' | 'console'; + }; } export type ExpandFilter = (