Skip to content

Commit

Permalink
feat: allow passing Filesystem volumes to SD instance / extend method
Browse files Browse the repository at this point in the history
  • Loading branch information
jorenbroekema committed Mar 22, 2024
1 parent 220bc88 commit 42c8345
Show file tree
Hide file tree
Showing 27 changed files with 320 additions and 72 deletions.
47 changes: 47 additions & 0 deletions .changeset/small-apes-train.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
'style-dictionary': minor
---

Allow passing a custom FileSystem Volume to your Style-Dictionary instances, to ensure input/output files are read/written from/to that specific volume.
Useful in case you want multiple Style-Dictionary instances that are isolated from one another in terms of inputs/outputs.

```js
import { Volume } from 'memfs';

const vol = new Volume();

const sd = new StyleDictionary({
tokens: {
colors: {
red: {
value: "#FF0000",
type: "color"
}
}
},
platforms: {
css: {
transformGroup: "css",
files: [{
destination: "variables.css",
format: "css/variables"
}]
}
}
}, { volume: vol });

await sd.buildAllPlatforms();

vol.readFileSync('/variables.css');
/**
* :root {
* --colors-red: #FF0000;
* }
*/
```

This also works when using extend:

```js
const extendedSd = await sd.extend(cfg, { volume: vol })
```
62 changes: 62 additions & 0 deletions __tests__/buildAllPlatforms.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
*/
import { expect } from 'chai';
import StyleDictionary from 'style-dictionary';
import { fs } from 'style-dictionary/fs';
import { clearOutput, fileExists } from './__helpers.js';
import memfs from '@bundled-es-modules/memfs';

describe('buildAllPlatforms', () => {
beforeEach(() => {
Expand All @@ -36,4 +38,64 @@ describe('buildAllPlatforms', () => {
expect(fileExists('__tests__/__output/web/_icons.css')).to.be.true;
expect(fileExists('__tests__/__output/android/colors.xml')).to.be.true;
});

it('should work with volume override', async () => {
const vol1 = new memfs.Volume();
vol1.mkdirSync('__tests__/__tokens', { recursive: true });
vol1.writeFileSync(
'__tests__/__tokens/colors.json',
fs.readFileSync('__tests__/__tokens/colors.json', 'utf-8'),
'utf-8',
);
const sd1 = new StyleDictionary(
{
source: ['__tests__/__tokens/colors.json'],
platforms: {
web: {
transformGroup: 'css',
buildPath: '__tests__/__output/css/',
files: [
{
destination: 'vars1.css',
format: 'css/variables',
},
],
},
},
},
{ volume: vol1 },
);
await sd1.buildAllPlatforms();

const vol2 = new memfs.Volume();
vol2.mkdirSync('__tests__/__tokens', { recursive: true });
vol2.writeFileSync(
'__tests__/__tokens/colors.json',
fs.readFileSync('__tests__/__tokens/colors.json', 'utf-8'),
'utf-8',
);
const sd2 = new StyleDictionary(
{
source: ['__tests__/__tokens/colors.json'],
platforms: {
web: {
transformGroup: 'css',
buildPath: '__tests__/__output/css/',
files: [
{
destination: 'vars2.css',
format: 'css/variables',
},
],
},
},
},
{ volume: vol2 },
);
await sd2.buildAllPlatforms();
expect(fileExists('__tests__/__output/css/vars1.css', vol1)).to.be.true;
expect(fileExists('__tests__/__output/css/vars2.css', vol1)).to.be.false;
expect(fileExists('__tests__/__output/css/vars1.css', vol2)).to.be.false;
expect(fileExists('__tests__/__output/css/vars2.css', vol2)).to.be.true;
});
});
1 change: 0 additions & 1 deletion __tests__/cleanDir.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@ describe('cleanDir', () => {
await cleanFile(
{ destination: 'test.txt', format },
{ buildPath: '__tests__/__output/extradir1/extradir2/' },
{},
);
await cleanDir(
{ destination: 'test.txt', format },
Expand Down
2 changes: 1 addition & 1 deletion __tests__/cleanFile.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ describe('cleanFile', () => {

it('should delete a file properly', () => {
buildFile({ destination: 'test.txt', format }, { buildPath: '__tests__/__output/' }, {}, {});
cleanFile({ destination: 'test.txt', format }, { buildPath: '__tests__/__output/' }, {});
cleanFile({ destination: 'test.txt', format }, { buildPath: '__tests__/__output/' });
expect(fileExists('__tests__/__output/test.txt')).to.be.false;
});

Expand Down
60 changes: 55 additions & 5 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@
Create a new StyleDictionary instance.

| Param | Type | Description |
| ------------ | ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| config | [<code>Config</code>](#Config) | Configuration options to build your style dictionary. If you pass a string, it will be used as a path to a JSON config file. You can also pass an object with the configuration. |
| options | <code>Object</code> | Options object when creating a new StyleDictionary instance. |
| options.init | <code>Boolean</code> | `true` by default but can be disabled to delay initializing the dictionary. You can then call `sdInstance.init()` yourself, e.g. for testing or error handling purposes. |
| Param | Type | Description |
| ----------------- | ------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| config | [<code>Config</code>](#Config) | Configuration options to build your style dictionary. If you pass a string, it will be used as a path to a JSON config file. You can also pass an object with the configuration. |
| options | <code>Object</code> | Options object when creating a new StyleDictionary instance. |
| options.init | <code>Boolean</code> | `true` by default but can be disabled to delay initializing the dictionary. You can then call `sdInstance.init()` yourself, e.g. for testing or error handling purposes. |
| options.verbosity | <code>String</code> | `"verbose" \| "silent" \| "default"`, used by CLI to ensure log verbosity is set, which takes precedence over anything configured in the Style Dictionary user config |
| options.volume | <code>import('memfs').IFs \| typeof import('node:fs')</code> | Volume instance used as the filesystem for this Style-Dictionary instance. Allows isolating Style-Dictionary input/outputs to a volume |

**Example**

Expand All @@ -36,6 +38,48 @@ const sdTwo = new StyleDictionary({
});
```

Using volume option:

```js
import { Volume } from 'memfs';

const vol = new Volume();

const sd = new StyleDictionary(
{
tokens: {
colors: {
red: {
value: '#FF0000',
type: 'color',
},
},
},
platforms: {
css: {
transformGroup: 'css',
files: [
{
destination: 'variables.css',
format: 'css/variables',
},
],
},
},
},
{ volume: vol },
);

await sd.buildAllPlatforms();

vol.readFileSync('/variables.css');
/**
* :root {
* --colors-red: #FF0000;
* }
*/
```

## init

> StyleDictionary.init() ⇒ [<code>Promise<style-dictionary></code>](#module_style-dictionary)
Expand Down Expand Up @@ -78,6 +122,12 @@ const sdExtended = await sd.extend({
});
```

Volume option also works when using extend:

```js
const extendedSd = await sd.extend(cfg, { volume: vol });
```

---

## buildAllPlatforms
Expand Down
37 changes: 27 additions & 10 deletions lib/StyleDictionary.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import cleanDirs from './cleanDirs.js';
import cleanActions from './cleanActions.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
Expand Down Expand Up @@ -70,6 +71,7 @@ export default class StyleDictionary extends Register {
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import#browser_compatibility
static VERSION = '<? version placeholder ?>';
static formatHelpers = formatHelpers;
static volume = fs;

/** @returns {Config} */
get options() {
Expand Down Expand Up @@ -97,9 +99,9 @@ export default class StyleDictionary extends Register {

/**
* @param {Config | string} config
* @param {{ init?: boolean, verbosity?: Verbosity }} ctorOpts
* @param {{ init?: boolean, verbosity?: Verbosity, volume?: Volume }} ctorOpts
*/
constructor(config = {}, { init = true, verbosity = undefined } = {}) {
constructor(config = {}, { init = true, verbosity, volume } = {}) {
super();
this.config = config;
this.options = {};
Expand All @@ -120,6 +122,12 @@ export default class StyleDictionary extends Register {
this.include = [];
/** @type {Record<string, PlatformConfig>} */
this.platforms = {};
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,
Expand Down Expand Up @@ -153,15 +161,17 @@ export default class StyleDictionary extends Register {
* @param {Config | string} [config]
* @param {boolean} [mutateOriginal]
* @param {Verbosity} [verbosity]
* @param {Volume} [volume]
* @returns {Promise<StyleDictionary>}
*/
async extend(config = this.config, mutateOriginal = false, verbosity) {
async extend(config = this.config, mutateOriginal = false, verbosity, volume) {
// by default, if extend is called it means extending the current instance
// with a new instance without mutating the original
if (!mutateOriginal) {
const newSD = new StyleDictionary(deepmerge(this.options, config), {
init: false,
verbosity,
volume,
});
return newSD.init(verbosity);
}
Expand All @@ -181,9 +191,11 @@ export default class StyleDictionary extends Register {
// get ext name without leading .
const ext = extname(config).replace(/^\./, '');
// import path in Node has to be relative to cwd, in browser to root
const cfgFilePath = resolve(config);
const cfgFilePath = resolve(config, this.volume.__custom_fs__);
if (['json', 'json5', 'jsonc'].includes(ext)) {
options = JSON5.parse(/** @type {string} */ (fs.readFileSync(cfgFilePath, 'utf-8')));
options = JSON5.parse(
/** @type {string} */ (this.volume.readFileSync(cfgFilePath, 'utf-8')),
);
} else {
let _filePath = cfgFilePath;
if (typeof window !== 'object' && process?.platform === 'win32') {
Expand Down Expand Up @@ -238,6 +250,7 @@ export default class StyleDictionary extends Register {
false,
this.parsers,
this.usesDtcg,
this.volume,
);

includeTokens = result.tokens;
Expand Down Expand Up @@ -267,6 +280,7 @@ export default class StyleDictionary extends Register {
true,
this.parsers,
this.usesDtcg,
this.volume,
);

sourceTokens = result.tokens;
Expand Down Expand Up @@ -376,6 +390,9 @@ export default class StyleDictionary extends Register {
platformConfig,
this.options,
transformationContext,
[],
{},
this.volume,
);

// referenced values, that have not (yet) been transformed should be excluded from resolving
Expand Down Expand Up @@ -454,8 +471,8 @@ export default class StyleDictionary extends Register {
*/
async buildPlatform(platform) {
const { dictionary, platformConfig } = await this.getPlatform(platform);
await buildFiles(dictionary, platformConfig, this.options);
await performActions(dictionary, platformConfig, this.options);
await buildFiles(dictionary, platformConfig, this.options, this.volume);
await performActions(dictionary, platformConfig, this.options, this.volume);
// For chaining
return this;
}
Expand All @@ -476,9 +493,9 @@ export default class StyleDictionary extends Register {
async cleanPlatform(platform) {
const { dictionary, platformConfig } = await this.getPlatform(platform);
// We clean files first, then actions, ...and then directories?
await cleanFiles(platformConfig);
await cleanActions(dictionary, platformConfig, this.options);
await cleanDirs(platformConfig);
await cleanFiles(platformConfig, this.volume);
await cleanActions(dictionary, platformConfig, this.options, this.volume);
await cleanDirs(platformConfig, this.volume);
// For chaining
return this;
}
Expand Down
8 changes: 5 additions & 3 deletions lib/buildFile.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import GroupMessages from './utils/groupMessages.js';
import createFormatArgs from './utils/createFormatArgs.js';

/**
* @typedef {import('../types/Volume.d.ts').Volume} Volume
* @typedef {import('../types/DesignToken.d.ts').Dictionary} Dictionary
* @typedef {import('../types/DesignToken.d.ts').TransformedToken} TransformedToken
* @typedef {import('../types/Config.d.ts').PlatformConfig} PlatformConfig
Expand All @@ -37,8 +38,9 @@ import createFormatArgs from './utils/createFormatArgs.js';
* @param {PlatformConfig} platform
* @param {Dictionary} dictionary
* @param {Config} options
* @param {Volume} [vol]
*/
export default async function buildFile(file, platform = {}, dictionary, options) {
export default async function buildFile(file, platform = {}, dictionary, options, vol = fs) {
// eslint-disable-next-line no-console
const consoleLog = platform?.log?.verbosity === 'silent' ? () => {} : console.log;
const verbosityLog = `Use --verbose or log.verbosity: 'verbose' option for more details`;
Expand All @@ -63,7 +65,7 @@ export default async function buildFile(file, platform = {}, dictionary, options
}

const dir = dirname(fullDestination);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
if (!vol.existsSync(dir)) vol.mkdirSync(dir, { recursive: true });

const filteredTokens = await filterTokens(dictionary, filter, options);
const filteredDictionary = Object.assign({}, dictionary, {
Expand Down Expand Up @@ -134,7 +136,7 @@ export default async function buildFile(file, platform = {}, dictionary, options
}),
);

await fs.promises.writeFile(fullDestination, formattedContent);
await vol.promises.writeFile(fullDestination, formattedContent);

const filteredReferencesCount = GroupMessages.count(GroupMessages.GROUP.FilteredOutputReferences);

Expand Down
Loading

0 comments on commit 42c8345

Please sign in to comment.