diff --git a/.changeset/happy-numbers-grab.md b/.changeset/happy-numbers-grab.md new file mode 100644 index 000000000..2c218aa46 --- /dev/null +++ b/.changeset/happy-numbers-grab.md @@ -0,0 +1,18 @@ +--- +'style-dictionary': minor +--- + +Expose a new utility called resolveReferences which takes a value containing references, the dictionary object, and resolves the value's references for you. + +```js +import StyleDictionary from 'style-dictionary'; +import { resolveReferences } from 'style-dictionary/utils'; + +const sd = new StyleDictionary({ tokens: { + foo: { value: 'foo' }, + bar: { value: '{foo}' }, + qux: { value: '{bar}' }, +}}); + +console.log(resolveReferences(sd.tokens.qux.value, sd.tokens)); // 'foo' +``` diff --git a/.changeset/sweet-toes-fly.md b/.changeset/sweet-toes-fly.md new file mode 100644 index 000000000..93701febe --- /dev/null +++ b/.changeset/sweet-toes-fly.md @@ -0,0 +1,5 @@ +--- +'style-dictionary': minor +--- + +BREAKING: expose getReferences and usesReference utilities as standalone utils rather than requiring them to be bound to dictionary object. This makes it easier to use. diff --git a/__tests__/utils/reference/getReferences.test.js b/__tests__/utils/reference/getReferences.test.js index 861ca5250..78adcb3da 100644 --- a/__tests__/utils/reference/getReferences.test.js +++ b/__tests__/utils/reference/getReferences.test.js @@ -12,8 +12,7 @@ */ import { expect } from 'chai'; -// `.getReferences` is bound to a dictionary object, so to test it we will -// create a dictionary object and then call `.getReferences` on it. +import getReferences from '../../../lib/utils/references/getReferences.js'; import createDictionary from '../../../lib/utils/createDictionary.js'; const tokens = { @@ -54,26 +53,28 @@ describe('utils', () => { describe('reference', () => { describe('getReferences()', () => { it(`should return an empty array if the value has no references`, () => { - expect(dictionary.getReferences(tokens.color.red.value)).to.eql([]); + expect(getReferences(dictionary, tokens.color.red.value)).to.eql([]); }); it(`should work with a single reference`, () => { - expect(dictionary.getReferences(tokens.color.danger.value)).to.eql([{ value: '#f00' }]); + expect(getReferences(dictionary, tokens.color.danger.value)).to.eql([{ value: '#f00' }]); }); it(`should work with object values`, () => { - expect(dictionary.getReferences(tokens.border.primary.value)).to.eql([ + expect(getReferences(dictionary, tokens.border.primary.value)).to.eql([ { value: '#f00' }, { value: '2px' }, ]); }); it(`should work with objects that have numbers`, () => { - expect(dictionary.getReferences(tokens.border.secondary.value)).to.eql([{ value: '#f00' }]); + expect(getReferences(dictionary, tokens.border.secondary.value)).to.eql([ + { value: '#f00' }, + ]); }); it(`should work with interpolated values`, () => { - expect(dictionary.getReferences(tokens.border.tertiary.value)).to.eql([ + expect(getReferences(dictionary, tokens.border.tertiary.value)).to.eql([ { value: '2px' }, { value: '#f00' }, ]); diff --git a/__tests__/utils/reference/resolveReference.test.js b/__tests__/utils/reference/getValueByPath.test.js similarity index 64% rename from __tests__/utils/reference/resolveReference.test.js rename to __tests__/utils/reference/getValueByPath.test.js index 36f7a8461..eeac141b3 100644 --- a/__tests__/utils/reference/resolveReference.test.js +++ b/__tests__/utils/reference/getValueByPath.test.js @@ -11,7 +11,7 @@ * and limitations under the License. */ import { expect } from 'chai'; -import resolveReference from '../../../lib/utils/references/resolveReference.js'; +import getValueByPath from '../../../lib/utils/references/getValueByPath.js'; const dictionary = { color: { @@ -28,28 +28,28 @@ const dictionary = { arr: ['one', 'two'], }; -describe('resolveReference()', () => { +describe('getValueByPath()', () => { it(`returns undefined for non-strings`, () => { - expect(resolveReference(42, dictionary)).to.be.undefined; + expect(getValueByPath(42, dictionary)).to.be.undefined; }); it(`returns undefined if it does not find the path in the object`, () => { - expect(resolveReference(['color', 'foo'], dictionary)).to.be.undefined; - expect(resolveReference(['color', 'foo', 'bar'], dictionary)).to.be.undefined; + expect(getValueByPath(['color', 'foo'], dictionary)).to.be.undefined; + expect(getValueByPath(['color', 'foo', 'bar'], dictionary)).to.be.undefined; }); it(`returns the part of the object if referenced path exists`, () => { - expect(resolveReference(['color', 'palette', 'neutral', '0', 'value'], dictionary)).to.equal( + expect(getValueByPath(['color', 'palette', 'neutral', '0', 'value'], dictionary)).to.equal( dictionary.color.palette.neutral['0'].value, ); - expect(resolveReference(['color'], dictionary)).to.equal(dictionary.color); + expect(getValueByPath(['color'], dictionary)).to.equal(dictionary.color); }); it(`works with arrays`, () => { - expect(resolveReference(['arr'], dictionary)).to.equal(dictionary.arr); + expect(getValueByPath(['arr'], dictionary)).to.equal(dictionary.arr); }); it(`works with array indices`, () => { - expect(resolveReference(['arr', '0'], dictionary)).to.equal(dictionary.arr[0]); + expect(getValueByPath(['arr', '0'], dictionary)).to.equal(dictionary.arr[0]); }); }); diff --git a/__tests__/utils/reference/resolveReferences.test.js b/__tests__/utils/reference/resolveReferences.test.js new file mode 100644 index 000000000..71a90460b --- /dev/null +++ b/__tests__/utils/reference/resolveReferences.test.js @@ -0,0 +1,223 @@ +/* + * 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 { expect } from 'chai'; +import { fileToJSON } from '../../__helpers.js'; +import { resolveReferences } from '../../../lib/utils/references/resolveReferences.js'; +import GroupMessages from '../../../lib/utils/groupMessages.js'; + +const PROPERTY_REFERENCE_WARNINGS = GroupMessages.GROUP.PropertyReferenceWarnings; + +describe('utils', () => { + describe('references', () => { + describe('resolveReferences', () => { + beforeEach(() => { + GroupMessages.clear(PROPERTY_REFERENCE_WARNINGS); + }); + + it('should do simple references', () => { + const test = resolveReferences('{foo}', fileToJSON('__tests__/__json_files/simple.json')); + expect(test).to.equal('bar'); + }); + + it('should do simple interpolation for both strings and numbers', () => { + const obj = fileToJSON('__tests__/__json_files/interpolation.json'); + expect(resolveReferences(obj.c, obj)).to.equal('test1 value text after'); + expect(resolveReferences(obj.d, obj)).to.equal('text before test1 value'); + expect(resolveReferences(obj.e, obj)).to.equal('text before test1 value text after'); + expect(resolveReferences(obj.f, obj)).to.equal('123 text after'); + expect(resolveReferences(obj.g, obj)).to.equal('text before 123'); + expect(resolveReferences(obj.h, obj)).to.equal('text before 123 text after'); + }); + + it('should do nested references', () => { + const obj = fileToJSON('__tests__/__json_files/nested_references.json'); + expect(resolveReferences(obj.i, obj)).to.equal(2); + expect(resolveReferences(obj.a.b.d, obj)).to.equal(2); + expect(resolveReferences(obj.e.f.h, obj)).to.equal(1); + }); + + it('should handle nested pointers', () => { + const obj = fileToJSON('__tests__/__json_files/nested_pointers.json'); + expect(resolveReferences(obj.b, obj)).to.equal(1); + expect(resolveReferences(obj.c, obj)).to.equal(1); + }); + + it('should handle deep nested pointers', () => { + const obj = fileToJSON('__tests__/__json_files/nested_pointers_2.json'); + expect(resolveReferences(obj.a, obj)).to.equal(1); + expect(resolveReferences(obj.b, obj)).to.equal(1); + expect(resolveReferences(obj.c, obj)).to.equal(1); + expect(resolveReferences(obj.d, obj)).to.equal(1); + expect(resolveReferences(obj.e, obj)).to.equal(1); + expect(resolveReferences(obj.f, obj)).to.equal(1); + }); + + it('should handle deep nested pointers with string interpolation', () => { + const obj = fileToJSON('__tests__/__json_files/nested_pointers_3.json'); + expect(resolveReferences(obj.a, obj)).to.equal('foo bon bee bae boo bla baz bar'); + expect(resolveReferences(obj.b, obj)).to.equal('foo bon bee bae boo bla baz'); + expect(resolveReferences(obj.c, obj)).to.equal('foo bon bee bae boo bla'); + expect(resolveReferences(obj.d, obj)).to.equal('foo bon bee bae boo'); + expect(resolveReferences(obj.e, obj)).to.equal('foo bon bee bae'); + expect(resolveReferences(obj.f, obj)).to.equal('foo bon bee'); + expect(resolveReferences(obj.g, obj)).to.equal('foo bon'); + }); + + it('should handle deep nested pointers and nested references', () => { + const obj = fileToJSON('__tests__/__json_files/nested_pointers_4.json'); + expect(resolveReferences(obj.a.a.a, obj)).to.equal(1); + expect(resolveReferences(obj.b.b.b, obj)).to.equal(1); + expect(resolveReferences(obj.c.c.c, obj)).to.equal(1); + expect(resolveReferences(obj.d.d.d, obj)).to.equal(1); + expect(resolveReferences(obj.e.e.e, obj)).to.equal(1); + expect(resolveReferences(obj.f.f.f, obj)).to.equal(1); + }); + + it('should keep the type of the referenced property', () => { + const obj = fileToJSON('__tests__/__json_files/reference_type.json'); + expect(resolveReferences(obj.d, obj)).to.equal(1); + expect(typeof resolveReferences(obj.d, obj)).to.equal('number'); + expect(typeof resolveReferences(obj.e, obj)).to.equal('object'); + expect(resolveReferences(obj.e, obj)).to.eql({ c: 2 }); + expect(resolveReferences(obj.g, obj)).to.eql([1, 2, 3]); + }); + + it('should handle and evaluate items in an array', () => { + const obj = fileToJSON('__tests__/__json_files/array.json'); + expect(resolveReferences(obj.d[0], obj)).to.equal(2); + expect(resolveReferences(obj.d[1], obj)).to.equal(1); + expect(resolveReferences(obj.e[0].a, obj)).to.equal(1); + expect(resolveReferences(obj.e[1].a, obj)).to.equal(2); + }); + + it("should store warning if pointers don't exist", () => { + 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(GroupMessages.fetchMessages(PROPERTY_REFERENCE_WARNINGS)).to.eql([ + "Reference doesn't exist: tries to reference bar, which is not defined", + "Reference doesn't exist: tries to reference a.b.d, which is not defined", + ]); + }); + + it('should gracefully handle basic circular references', () => { + const obj = fileToJSON('__tests__/__json_files/circular.json'); + expect(resolveReferences(obj.a, obj)).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', () => { + const obj = fileToJSON('__tests__/__json_files/circular_2.json'); + expect(resolveReferences(obj.j, obj)).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', () => { + const obj = fileToJSON('__tests__/__json_files/circular_3.json'); + expect(resolveReferences(obj.c.d.e, obj)).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', () => { + const obj = fileToJSON('__tests__/__json_files/circular_4.json'); + expect(resolveReferences(obj.h.i, obj)).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', () => { + const obj = fileToJSON('__tests__/__json_files/circular_5.json'); + expect(resolveReferences(obj.n, obj)).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']), + ); + }); + + 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( + 'test1 value, test2 value and some extra stuff', + ); + expect(resolveReferences(obj.prop124.value, obj)).to.equal( + 'test1 value, test2 value and test1 value', + ); + expect(resolveReferences(obj.prop15.value, obj)).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(GroupMessages.count(PROPERTY_REFERENCE_WARNINGS)).to.equal(0); + }); + + describe('ignorePaths', () => { + it('should not resolve values containing constiables in ignored paths', () => { + const obj = { + foo: { value: 'bar' }, + bar: { + value: '{foo.value}', + }, + }; + const test = resolveReferences(obj.bar.value, obj, { ignorePaths: ['foo.value'] }); + expect(test).to.equal('{foo.value}'); + }); + }); + + it('should handle spaces', () => { + const obj = { + foo: { value: 'foo' }, + bar: { value: '{ foo.value }' }, + }; + const test = resolveReferences(obj.bar.value, obj); + expect(test).to.equal('foo'); + }); + + it('should collect multiple reference errors', () => { + 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(GroupMessages.count(PROPERTY_REFERENCE_WARNINGS)).to.equal(3); + expect(JSON.stringify(GroupMessages.fetchMessages(PROPERTY_REFERENCE_WARNINGS))).to.equal( + JSON.stringify([ + "Reference doesn't exist: tries to reference b.a, which is not defined", + "Reference doesn't exist: tries to reference b.c, which is not defined", + "Reference doesn't exist: tries to reference d, which is not defined", + ]), + ); + }); + + it('should handle 0', () => { + const obj = { + test: { value: '{zero.value}' }, + zero: { value: 0 }, + }; + const test = resolveReferences(obj.test.value, obj); + expect(GroupMessages.fetchMessages(PROPERTY_REFERENCE_WARNINGS).length).to.equal(0); + expect(test).to.equal(0); + }); + }); + }); +}); diff --git a/__tests__/utils/resolveObject.test.js b/__tests__/utils/resolveObject.test.js index c8f6f8a85..fe6ce76b8 100644 --- a/__tests__/utils/resolveObject.test.js +++ b/__tests__/utils/resolveObject.test.js @@ -19,6 +19,13 @@ const PROPERTY_REFERENCE_WARNINGS = GroupMessages.GROUP.PropertyReferenceWarning describe('utils', () => { describe('resolveObject', () => { + beforeEach(() => { + GroupMessages.clear(PROPERTY_REFERENCE_WARNINGS); + }); + afterEach(() => { + GroupMessages.clear(PROPERTY_REFERENCE_WARNINGS); + }); + it('should error on non-objects', () => { expect(resolveObject.bind(null)).to.throw('Please pass an object in'); expect(resolveObject.bind(null, 'foo')).to.throw('Please pass an object in'); @@ -116,8 +123,6 @@ describe('utils', () => { }); it('should gracefully handle basic circular references', () => { - GroupMessages.clear(PROPERTY_REFERENCE_WARNINGS); - resolveObject(fileToJSON('__tests__/__json_files/circular.json')); expect(GroupMessages.count(PROPERTY_REFERENCE_WARNINGS)).to.equal(1); expect(JSON.stringify(GroupMessages.fetchMessages(PROPERTY_REFERENCE_WARNINGS))).to.equal( @@ -126,8 +131,6 @@ describe('utils', () => { }); it('should gracefully handle basic and nested circular references', () => { - GroupMessages.clear(PROPERTY_REFERENCE_WARNINGS); - resolveObject(fileToJSON('__tests__/__json_files/circular_2.json')); expect(GroupMessages.count(PROPERTY_REFERENCE_WARNINGS)).to.equal(1); expect(JSON.stringify(GroupMessages.fetchMessages(PROPERTY_REFERENCE_WARNINGS))).to.equal( @@ -136,8 +139,6 @@ describe('utils', () => { }); it('should gracefully handle nested circular references', () => { - GroupMessages.clear(PROPERTY_REFERENCE_WARNINGS); - resolveObject(fileToJSON('__tests__/__json_files/circular_3.json')); expect(GroupMessages.count(PROPERTY_REFERENCE_WARNINGS)).to.equal(1); expect(JSON.stringify(GroupMessages.fetchMessages(PROPERTY_REFERENCE_WARNINGS))).to.equal( @@ -146,8 +147,6 @@ describe('utils', () => { }); it('should gracefully handle multiple nested circular references', () => { - GroupMessages.clear(PROPERTY_REFERENCE_WARNINGS); - resolveObject(fileToJSON('__tests__/__json_files/circular_4.json')); expect(GroupMessages.count(PROPERTY_REFERENCE_WARNINGS)).to.equal(1); expect(JSON.stringify(GroupMessages.fetchMessages(PROPERTY_REFERENCE_WARNINGS))).to.equal( @@ -156,8 +155,6 @@ describe('utils', () => { }); it('should gracefully handle down-chain circular references', () => { - GroupMessages.clear(PROPERTY_REFERENCE_WARNINGS); - resolveObject(fileToJSON('__tests__/__json_files/circular_5.json')); expect(GroupMessages.count(PROPERTY_REFERENCE_WARNINGS)).to.equal(1); expect(JSON.stringify(GroupMessages.fetchMessages(PROPERTY_REFERENCE_WARNINGS))).to.equal( @@ -166,8 +163,6 @@ describe('utils', () => { }); it('should correctly replace multiple references without reference errors', function () { - GroupMessages.clear(PROPERTY_REFERENCE_WARNINGS); - const obj = resolveObject(fileToJSON('__tests__/__json_files/not_circular.json')); expect(GroupMessages.count(PROPERTY_REFERENCE_WARNINGS)).to.equal(0); expect(JSON.stringify(obj)).to.equal( @@ -322,8 +317,6 @@ describe('utils', () => { }); it('should collect multiple reference errors', () => { - GroupMessages.clear(PROPERTY_REFERENCE_WARNINGS); - resolveObject(fileToJSON('__tests__/__json_files/multiple_reference_errors.json')); expect(GroupMessages.count(PROPERTY_REFERENCE_WARNINGS)).to.equal(3); expect(JSON.stringify(GroupMessages.fetchMessages(PROPERTY_REFERENCE_WARNINGS))).to.equal( @@ -336,7 +329,6 @@ describe('utils', () => { }); it('should handle 0', () => { - GroupMessages.clear(PROPERTY_REFERENCE_WARNINGS); const test = resolveObject({ test: { value: '{zero.value}' }, zero: { value: 0 }, diff --git a/examples/advanced/variables-in-outputs/README.md b/examples/advanced/variables-in-outputs/README.md index 898b19cd3..8c359bf07 100644 --- a/examples/advanced/variables-in-outputs/README.md +++ b/examples/advanced/variables-in-outputs/README.md @@ -14,7 +14,7 @@ At this point, you can run `npm run build`. This command will generate the outpu #### How does it work -The "build" command uses the `sd.config.js` file as the Style Dictionary configuration. It is configured to use JSON files in the `tokens/` directory as the source files. It adds a custom format directly in the configuration (as opposed to using the `.registerFormat()` method) that uses 2 new methods added onto the internal dictionary object that is passed to formats and actions: `.usesReference()` and `.resolveReference()`. Also, it uses a new configuration on some formats: `keepReferences: true` to include variable references in the output. +The "build" command uses the `sd.config.js` file as the Style Dictionary configuration. It is configured to use JSON files in the `tokens/` directory as the source files. It adds a custom format directly in the configuration (as opposed to using the `.registerFormat()` method) that uses 2 new methods added as exposed utilities: `usesReference()` and `getReferences()`. Also, it uses a new configuration on some formats: `outputReferences: true` to include variable references in the output. #### What to look at @@ -23,6 +23,8 @@ The `sd.config.js` file has everything you need to see. The tokens included in t Here is an example that shows how to get an alias's name within a custom format: ```javascript +import { usesReference, getReferences } from 'style-dictionary/utils'; + //... function({ dictionary }) { return dictionary.allTokens.map(token => { @@ -32,8 +34,8 @@ function({ dictionary }) { // the value has a reference in it. `getReferences()` will return // an array of references to the whole tokens so that you can access their // names or any other attributes. - if (dictionary.usesReference(token.original.value)) { - const refs = dictionary.getReferences(token.original.value); + if (usesReference(token.original.value)) { + const refs = getReferences(dictionary, token.original.value); refs.forEach(ref => { value = value.replace(ref.value, function() { return `${ref.name}`; @@ -49,5 +51,5 @@ The `build/` directory is where all the files are being built to. After Style Di - `build/tokens.js` This file is generated from the custom format in this example. Tokens that are references to other tokens use the variable name instead of raw value. - `build/tokens.json` This file does not use variable references to show that other outputs work as intended. -- `build/tokens.css` This file is generated using the `css/variables` built-in format with the new `keepReferences` configuration. -- `build/tokens.scss` This file is generated using the `scss/variables` built-in format with the new `keepReferences` configuration. +- `build/tokens.css` This file is generated using the `css/variables` built-in format with the new `outputReferences` configuration. +- `build/tokens.scss` This file is generated using the `scss/variables` built-in format with the new `outputReferences` configuration. diff --git a/lib/common/formatHelpers/createPropertyFormatter.js b/lib/common/formatHelpers/createPropertyFormatter.js index 1bbcea98c..a689b6a41 100644 --- a/lib/common/formatHelpers/createPropertyFormatter.js +++ b/lib/common/formatHelpers/createPropertyFormatter.js @@ -11,6 +11,8 @@ * and limitations under the License. */ +import { usesReference, getReferences } from 'style-dictionary/utils'; + const defaultFormatting = { prefix: '', commentStyle: 'long', @@ -159,10 +161,10 @@ export default function createPropertyFormatter({ * This will see if there are references and if there are, replace * the resolved value with the reference's name. */ - if (outputReferences && dictionary.usesReference(prop.original.value)) { + if (outputReferences && usesReference(prop.original.value)) { // Formats that use this function expect `value` to be a string // or else you will get '[object Object]' in the output - const refs = dictionary.getReferences(prop.original.value); + const refs = getReferences(dictionary, prop.original.value); // 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 01c4629c1..8351293bf 100644 --- a/lib/common/formatHelpers/sortByReference.js +++ b/lib/common/formatHelpers/sortByReference.js @@ -11,6 +11,8 @@ * and limitations under the License. */ +import { usesReference, getReferences } from 'style-dictionary/utils'; + /** * A function that returns a sorting function to be used with Array.sort that * will sort the allTokens array based on references. This is to make sure @@ -39,11 +41,11 @@ export default function sortByReference(dictionary) { // If token a uses a reference and token b doesn't, b might come before a // read on.. - if (a.original && dictionary.usesReference(a.original.value)) { + if (a.original && usesReference(a.original.value)) { // Both a and b have references, we need to see if the reference each other - if (b.original && dictionary.usesReference(b.original.value)) { - const aRefs = dictionary.getReferences(a.original.value); - const bRefs = dictionary.getReferences(b.original.value); + if (b.original && usesReference(b.original.value)) { + const aRefs = getReferences(dictionary, a.original.value); + const bRefs = getReferences(dictionary, b.original.value); aRefs.forEach((aRef) => { // a references b, we want b to come first diff --git a/lib/common/formats.js b/lib/common/formats.js index 49d8cd80d..951f0893d 100644 --- a/lib/common/formats.js +++ b/lib/common/formats.js @@ -611,8 +611,7 @@ declare const ${moduleName}: ${JSON.stringify(treeWalker(dictionary.tokens), nul * ``` */ 'android/resources': function ({ dictionary, options, file }) { - const template = _template(androidResources); - return template({ dictionary, file, options, fileHeader }); + return androidResources({ dictionary, file, options, fileHeader }); }, /** diff --git a/lib/common/templates/android/resources.template.js b/lib/common/templates/android/resources.template.js index 4ec3b4c31..2dff05ac3 100644 --- a/lib/common/templates/android/resources.template.js +++ b/lib/common/templates/android/resources.template.js @@ -1,50 +1,61 @@ -export default ` -<% -// -// 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. - -var resourceType = file.resourceType || null; - -var resourceMap = file.resourceMap || { - size: 'dimen', - color: 'color', - string: 'string', - content: 'string', - time: 'integer', - number: 'integer' -}; +/* + * 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. + */ -function propToType(prop) { - if (resourceType) { - return resourceType; - } - if (resourceMap[prop.attributes.category]) { - return resourceMap[prop.attributes.category]; +import { usesReference, getReferences } from 'style-dictionary/utils'; + +export default (opts) => { + const { file, fileHeader, dictionary } = opts; + + const resourceType = file.resourceType || null; + + const resourceMap = file.resourceMap || { + size: 'dimen', + color: 'color', + string: 'string', + content: 'string', + time: 'integer', + number: 'integer', + }; + + function propToType(prop) { + if (resourceType) { + return resourceType; + } + if (resourceMap[prop.attributes.category]) { + return resourceMap[prop.attributes.category]; + } + return 'string'; } - return 'string'; -} - -function propToValue(prop) { - if (file.options && file.options.outputReferences && dictionary.usesReference(prop.original.value)) { - return \`@\${propToType(prop)}/\${dictionary.getReferences(prop.original.value)[0].name}\`; - } else { - return prop.value; + + function propToValue(prop) { + if (file.options && file.options.outputReferences && usesReference(prop.original.value)) { + return `@${propToType(prop)}/${getReferences(dictionary, prop.original.value)[0].name}`; + } else { + return prop.value; + } } -} %> -<%= fileHeader({file, commentStyle: 'xml'}) %> + + return ` + +${fileHeader({ file, commentStyle: 'xml' })} - <% dictionary.allTokens.forEach(function(prop) { - %><<%= propToType(prop) %> name="<%= prop.name %>"><%= propToValue(prop) %>><% if (prop.comment) { %><% } %> - <% }); %> + ${dictionary.allTokens + .map( + (prop) => + `<${propToType(prop)} name="${prop.name}">${propToValue(prop)}${ + prop.comment ? `` : '' + }`, + ) + .reduce((acc, curr) => acc + `${curr}\n `, '')} `; +}; diff --git a/lib/register/format.js b/lib/register/format.js index 40b11d925..4aea45155 100644 --- a/lib/register/format.js +++ b/lib/register/format.js @@ -24,8 +24,6 @@ * @param {Object} args.dictionary - The transformed and resolved dictionary object * @param {Object} args.dictionary.tokens - Object structure of the tokens that has been transformed and references resolved. * @param {Array} args.dictionary.allTokens - Flattened array of all the tokens. This makes it easy to output a list, like a list of SCSS variables. - * @param {function(value): Boolean} args.dictionary.usesReference - Use this function to see if a token's value uses a reference. This is the same function style dictionary uses internally to detect a reference. - * @param {function(value): Value} args.dictionary.getReferences - Use this function to get the tokens that it references. You can use this to output a reference in your custom format. For example: `dictionary.getReferences(token.original.value) // returns an array of the referenced token objects` * @param {Object} args.platform - The platform configuration this format is being called in. * @param {Object} args.file - The file configuration this format is being called in. * @param {Object} args.options - Merged options object that combines platform level configuration and file level configuration. File options take precedence. diff --git a/lib/utils/createDictionary.js b/lib/utils/createDictionary.js index 29e8636f9..0bac703a8 100644 --- a/lib/utils/createDictionary.js +++ b/lib/utils/createDictionary.js @@ -12,16 +12,12 @@ */ import flattenTokens from './flattenTokens.js'; -import getReferences from './references/getReferences.js'; -import usesReference from './references/usesReference.js'; /** * * @typedef Dictionary * @property {Object} $tokens * @property {Array} allTokens - * @property {Dictionary.getReferences} getReferences - * @property {Dictionary.usesReference} usesReference */ /** @@ -35,7 +31,5 @@ export default function createDictionary({ tokens }) { return { tokens, allTokens, - getReferences, - usesReference, }; } diff --git a/lib/utils/createFormatArgs.js b/lib/utils/createFormatArgs.js index bc136327b..8522afa1a 100644 --- a/lib/utils/createFormatArgs.js +++ b/lib/utils/createFormatArgs.js @@ -14,7 +14,7 @@ import deepExtend from './deepExtend.js'; export default function createFormatArgs({ dictionary, platform, file = {} }) { - const { allTokens, tokens, usesReference, getReferences } = dictionary; + const { allTokens, tokens } = dictionary; // This will merge platform and file-level configuration // where the file configuration takes precedence const { options } = platform; @@ -22,8 +22,6 @@ export default function createFormatArgs({ dictionary, platform, file = {} }) { return { dictionary, - usesReference, - getReferences, allTokens, tokens, platform, diff --git a/lib/utils/index.js b/lib/utils/index.js new file mode 100644 index 000000000..478779cd6 --- /dev/null +++ b/lib/utils/index.js @@ -0,0 +1,19 @@ +/* + * 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 usesReference from './references/usesReference.js'; +import getReferences from './references/getReferences.js'; +import { resolveReferences } from './references/resolveReferences.js'; + +// Public style-dictionary/utils API +export { usesReference, getReferences, resolveReferences }; diff --git a/lib/utils/references/getReferences.js b/lib/utils/references/getReferences.js index 27517007f..218cb9fd8 100644 --- a/lib/utils/references/getReferences.js +++ b/lib/utils/references/getReferences.js @@ -13,7 +13,7 @@ import getPath from './getPathFromName.js'; import createReferenceRegex from './createReferenceRegex.js'; -import resolveReference from './resolveReference.js'; +import getValueByPath from './getValueByPath.js'; import GroupMessages from '../groupMessages.js'; /** @@ -27,12 +27,12 @@ import GroupMessages from '../groupMessages.js'; * ``` * * @memberof Dictionary + * @param {Object} dictionary the dictionary to search in * @param {string} value the value that contains a reference * @param {object[]} references array of token's references because a token's value can contain multiple references due to string interpolation * @returns {any} */ -export default function getReferences(value, references = []) { - // `this` is the dictionary object passed to formats and actions +export default function getReferences(dictionary, value, references = []) { const regex = createReferenceRegex({}); // this will update the references array with the referenced tokens it finds. @@ -43,11 +43,11 @@ export default function getReferences(value, references = []) { // Find what the value is referencing const pathName = getPath(variable); - let ref = resolveReference(pathName, this.tokens); + let ref = getValueByPath(pathName, dictionary.tokens); if (!ref) { // fall back on _tokens as it is unfiltered - ref = resolveReference(pathName, this._tokens); + ref = getValueByPath(pathName, dictionary._tokens); // and warn the user about this GroupMessages.add(GroupMessages.GROUP.FilteredOutputReferences, variable); } @@ -56,7 +56,7 @@ export default function getReferences(value, references = []) { if (typeof value === 'string') { // function inside .replace runs multiple times if there are multiple matches - value.replace(regex, findReference.bind(this)); + value.replace(regex, findReference); } // If the token's value is an object, run the replace reference @@ -65,11 +65,11 @@ export default function getReferences(value, references = []) { if (typeof value === 'object') { for (const key in value) { if (value.hasOwnProperty(key) && typeof value[key] === 'string') { - value[key].replace(regex, findReference.bind(this)); + value[key].replace(regex, findReference); } // if it is an object, we go further down the rabbit hole if (value.hasOwnProperty(key) && typeof value[key] === 'object') { - this.getReferences(value[key], references); + getReferences(dictionary, value[key], references); } } } diff --git a/lib/utils/references/resolveReference.js b/lib/utils/references/getValueByPath.js similarity index 94% rename from lib/utils/references/resolveReference.js rename to lib/utils/references/getValueByPath.js index a390f2177..4640dc4c2 100644 --- a/lib/utils/references/resolveReference.js +++ b/lib/utils/references/getValueByPath.js @@ -11,7 +11,7 @@ * and limitations under the License. */ -export default function resolveReference(path, obj) { +export default function getValueByPath(path, obj) { let ref = obj; if (!Array.isArray(path)) { diff --git a/lib/utils/references/resolveReferences.js b/lib/utils/references/resolveReferences.js new file mode 100644 index 000000000..95c51dcd4 --- /dev/null +++ b/lib/utils/references/resolveReferences.js @@ -0,0 +1,161 @@ +/* + * 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 GroupMessages from '../groupMessages.js'; +import getPathFromName from './getPathFromName.js'; +import getName from './getName.js'; +import getValueByPath from './getValueByPath.js'; +import usesReference from './usesReference.js'; +import createReferenceRegex from './createReferenceRegex.js'; +import defaults from './defaults.js'; + +const PROPERTY_REFERENCE_WARNINGS = GroupMessages.GROUP.PropertyReferenceWarnings; + +/** + * Utility to resolve references inside a string value + * @param {string} value + * @param {Object} dictionary + * @param {Object} opts + * @returns {string} + */ +export function resolveReferences( + value, + dictionary, + { + regex, + ignorePaths = [], + current_context = [], + separator = defaults.separator, + opening_character = defaults.opening_character, + closing_character = defaults.closing_character, + // for internal usage + stack = [], + foundCirc = {}, + firstIteration = true, + } = {}, +) { + const reg = regex ?? createReferenceRegex({ opening_character, closing_character, separator }); + let to_ret = value; + let ref; + + // When we know the current context: + // the key associated with the value that we are resolving the reference for + // Then we can push this to the stack to improve our circular reference warnings + // by starting them with the key + if (firstIteration && current_context.length > 0) { + stack.push(getName(current_context)); + } + + // Replace the reference inline, but don't replace the whole string because + // references can be part of the value such as "1px solid {color.border.light}" + value.replace(reg, function (match, variable) { + variable = variable.trim(); + + // Find what the value is referencing + const pathName = getPathFromName(variable, { separator }); + + const refHasValue = pathName[pathName.length - 1] === 'value'; + + if (refHasValue && ignorePaths.indexOf(variable) !== -1) { + return value; + } else if (!refHasValue && ignorePaths.indexOf(`${variable}.value`) !== -1) { + return value; + } + + stack.push(variable); + + ref = getValueByPath(pathName, dictionary); + + // If the reference doesn't end in 'value' + // and + // the reference points to someplace that has a `value` attribute + // we should take the '.value' of the reference + // per the W3C draft spec where references do not have .value + // https://design-tokens.github.io/community-group/format/#aliases-references + if (!refHasValue && ref && ref.hasOwnProperty('value')) { + ref = ref.value; + } + + if (typeof ref !== 'undefined') { + if (typeof ref === 'string' || typeof ref === 'number') { + to_ret = value.replace(match, ref); + + // Recursive, therefore we can compute multi-layer variables like a = b, b = c, eventually a = c + if (usesReference(to_ret, reg)) { + const reference = to_ret.slice(1, -1); + + // Compare to found circular references + if (foundCirc.hasOwnProperty(reference)) { + // If the current reference is a member of a circular reference, do nothing + } else if (stack.indexOf(reference) !== -1) { + // If the current stack already contains the current reference, we found a new circular reference + // chop down only the circular part, save it to our circular reference info, and spit out an error + + // Get the position of the existing reference in the stack + const stackIndexReference = stack.indexOf(reference); + + // Get the portion of the stack that starts at the circular reference and brings you through until the end + const circStack = stack.slice(stackIndexReference); + + // For all the references in this list, add them to the list of references that end up in a circular reference + circStack.forEach(function (key) { + foundCirc[key] = true; + }); + + // Add our found circular reference to the end of the cycle + circStack.push(reference); + + // Add circ reference info to our list of warning messages + GroupMessages.add( + PROPERTY_REFERENCE_WARNINGS, + 'Circular definition cycle: ' + circStack.join(', '), + ); + } else { + to_ret = resolveReferences(to_ret, dictionary, { + regex: reg, + ignorePaths, + current_context, + separator, + stack, + foundCirc, + firstIteration: false, + }); + } + } + // if evaluated value is a number and equal to the reference, we want to keep the type + if (typeof ref === 'number' && ref.toString() === to_ret) { + to_ret = ref; + } + } else { + // if evaluated value is not a string or number, we want to keep the type + to_ret = ref; + } + } else { + // User might have passed current_context option which is path (arr) pointing to key + // that this value is associated with, helpful for debugging + const context = getName(current_context, { separator }); + GroupMessages.add( + PROPERTY_REFERENCE_WARNINGS, + `Reference doesn't exist:${ + context ? ` ${context}` : '' + } tries to reference ${variable}, which is not defined`, + ); + to_ret = ref; + } + stack.pop(variable); + + return to_ret; + }); + + return to_ret; +} diff --git a/lib/utils/resolveObject.js b/lib/utils/resolveObject.js index e345be12a..5672e7771 100644 --- a/lib/utils/resolveObject.js +++ b/lib/utils/resolveObject.js @@ -11,162 +11,61 @@ * and limitations under the License. */ -import GroupMessages from './groupMessages.js'; -import usesReference from './references/usesReference.js'; -import getName from './references/getName.js'; -import getPath from './references/getPathFromName.js'; import createReferenceRegex from './references/createReferenceRegex.js'; -import resolveReference from './references/resolveReference.js'; +import { resolveReferences } from './references/resolveReferences.js'; -const PROPERTY_REFERENCE_WARNINGS = GroupMessages.GROUP.PropertyReferenceWarnings; - -let current_context = []; // To maintain the context to be able to test for circular definitions const defaults = { ignoreKeys: ['original'], - ignorePaths: [], }; -let updated_object, regex, options; - -export default function resolveObject(object, opts) { - options = Object.assign({}, defaults, opts); - updated_object = structuredClone(object); // This object will be edited - - regex = createReferenceRegex(options); +export default function resolveObject(object, _opts = {}) { + const foundCirc = {}; + const opts = { ...defaults, ..._opts }; + const current_context = []; + const clone = structuredClone(object); // This object will be edited + opts.regex = createReferenceRegex(opts); if (typeof object === 'object') { - current_context = []; - return traverseObj(updated_object); + return traverseObj(clone, clone, opts, current_context, foundCirc); } else { throw new Error('Please pass an object in'); } } -function traverseObj(obj) { - let key; - - for (key in obj) { - if (!obj.hasOwnProperty(key)) { +/** + * @param {Object} slice - slice within the full object + * @param {Object} fullObj - the full object + * @param {Object} opts - options such as regex, ignoreKeys, ignorePaths, etc. + * @param {string[]} current_context - keeping track of the token group context that we're in + */ +function traverseObj(slice, fullObj, opts, current_context, foundCirc) { + for (const key in slice) { + if (!slice.hasOwnProperty(key)) { continue; } + const value = slice[key]; // We want to check for ignoredKeys, this is to // skip over attributes that should not be // mutated, like a copy of the original property. - if (options.ignoreKeys && options.ignoreKeys.indexOf(key) !== -1) { + if (opts.ignoreKeys && opts.ignoreKeys.indexOf(key) !== -1) { continue; } current_context.push(key); - if (typeof obj[key] === 'object') { - traverseObj(obj[key]); + if (typeof value === 'object') { + traverseObj(value, fullObj, opts, current_context, foundCirc); } else { - if (typeof obj[key] === 'string' && obj[key].indexOf('{') > -1) { - obj[key] = compile_value(obj[key], [getName(current_context)]); + if (typeof value === 'string' && value.indexOf('{') > -1) { + slice[key] = resolveReferences(value, fullObj, { + ...opts, + current_context, + foundCirc, + }); } } current_context.pop(); } - return obj; -} - -let foundCirc = {}; -function compile_value(value, stack) { - let to_ret = value, - ref; - - // Replace the reference inline, but don't replace the whole string because - // references can be part of the value such as "1px solid {color.border.light}" - value.replace(regex, function (match, variable) { - variable = variable.trim(); - - // Find what the value is referencing - const pathName = getPath(variable, options); - const context = getName(current_context, options); - const refHasValue = pathName[pathName.length - 1] === 'value'; - - if (refHasValue && options.ignorePaths.indexOf(variable) !== -1) { - return value; - } else if (!refHasValue && options.ignorePaths.indexOf(`${variable}.value`) !== -1) { - return value; - } - - stack.push(variable); - - ref = resolveReference(pathName, updated_object); - - // If the reference doesn't end in 'value' - // and - // the reference points to someplace that has a `value` attribute - // we should take the '.value' of the reference - // per the W3C draft spec where references do not have .value - // https://design-tokens.github.io/community-group/format/#aliases-references - if (!refHasValue && ref && ref.hasOwnProperty('value')) { - ref = ref.value; - } - - if (typeof ref !== 'undefined') { - if (typeof ref === 'string' || typeof ref === 'number') { - to_ret = value.replace(match, ref); - - // Recursive, therefore we can compute multi-layer variables like a = b, b = c, eventually a = c - if (usesReference(to_ret, regex)) { - const reference = to_ret.slice(1, -1); - - // Compare to found circular references - if (foundCirc.hasOwnProperty(reference)) { - // If the current reference is a member of a circular reference, do nothing - } else if (stack.indexOf(reference) !== -1) { - // If the current stack already contains the current reference, we found a new circular reference - // chop down only the circular part, save it to our circular reference info, and spit out an error - - // Get the position of the existing reference in the stack - const stackIndexReference = stack.indexOf(reference); - - // Get the portion of the stack that starts at the circular reference and brings you through until the end - const circStack = stack.slice(stackIndexReference); - - // For all the references in this list, add them to the list of references that end up in a circular reference - circStack.forEach(function (key) { - foundCirc[key] = true; - }); - - // Add our found circular reference to the end of the cycle - circStack.push(reference); - - // Add circ reference info to our list of warning messages - GroupMessages.add( - PROPERTY_REFERENCE_WARNINGS, - 'Circular definition cycle: ' + circStack.join(', '), - ); - } else { - to_ret = compile_value(to_ret, stack); - } - } - // if evaluated value is a number and equal to the reference, we want to keep the type - if (typeof ref === 'number' && ref.toString() === to_ret) { - to_ret = ref; - } - } else { - // if evaluated value is not a string or number, we want to keep the type - to_ret = ref; - } - } else { - GroupMessages.add( - PROPERTY_REFERENCE_WARNINGS, - "Reference doesn't exist: " + - context + - ' tries to reference ' + - variable + - ', which is not defined', - ); - to_ret = ref; - } - stack.pop(variable); - - return to_ret; - }); - - return to_ret; + return fullObj; } diff --git a/package.json b/package.json index dbc6b2c45..7e15cc3de 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,8 @@ "./fs": { "node": "./fs-node.js", "default": "./fs.js" - } + }, + "./utils": "./lib/utils/index.js" }, "bin": { "style-dictionary": "./bin/style-dictionary.js" diff --git a/types/Dictionary.d.ts b/types/Dictionary.d.ts index 8a249048f..74ec1b936 100644 --- a/types/Dictionary.d.ts +++ b/types/Dictionary.d.ts @@ -16,6 +16,4 @@ import { TransformedToken, TransformedTokens } from './TransformedToken'; export interface Dictionary { allTokens: TransformedToken[]; tokens: TransformedTokens; - usesReference: (value: any) => boolean; - getReferences: (value: any) => TransformedToken[]; }