diff --git a/.changeset/gentle-rocks-compete.md b/.changeset/gentle-rocks-compete.md new file mode 100644 index 000000000..9ba187e30 --- /dev/null +++ b/.changeset/gentle-rocks-compete.md @@ -0,0 +1,5 @@ +--- +'style-dictionary': minor +--- + +Add support for native .TS token & config file processing. diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 712bed72e..23a138082 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -42,3 +42,19 @@ jobs: - name: Performance tests run: npm run test:perf + verify-strip-types: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node 22.6.0 + uses: actions/setup-node@v4 + with: + node-version: 22.6.0 + cache: 'npm' + + - name: Install Dependencies + run: npm ci + + - name: Node Strip types tests + run: npm run test:strip-types diff --git a/__tests__/StyleDictionary.test.js b/__tests__/StyleDictionary.test.js index f5c6033a7..36917ddd2 100644 --- a/__tests__/StyleDictionary.test.js +++ b/__tests__/StyleDictionary.test.js @@ -115,8 +115,9 @@ describe('StyleDictionary class', () => { }); describe('method signature', () => { - it('should accept a string as a path to a JSON file', () => { + it('should accept a string as a path to a JSON file', async () => { const StyleDictionaryExtended = new StyleDictionary('__tests__/__configs/test.json'); + await StyleDictionaryExtended.hasInitialized; expect(StyleDictionaryExtended).to.have.nested.property('platforms.web'); }); diff --git a/__tests__/__configs/test.ts b/__tests__/__configs/test.ts new file mode 100644 index 000000000..0330bb3f1 --- /dev/null +++ b/__tests__/__configs/test.ts @@ -0,0 +1,103 @@ +// reference the compiled file ahead of time +// usually you would use 'style-dictionary/types' here but that only works after emitting D.TS files, so we use direct path here +import type { Config } from '../../types/Config.d.ts'; + +const cfg: Config = { + source: ['__tests__/__json_files/*.ts'], + platforms: { + web: { + transformGroup: 'web', + prefix: 'smop', + buildPath: '__tests__/__output/web/', + files: [ + { + destination: '_icons.css', + format: 'scss/icons', + }, + { + destination: '_variables.css', + format: 'scss/variables', + }, + { + destination: '_styles.js', + format: 'javascript/module', + }, + ], + }, + scss: { + transformGroup: 'scss', + prefix: 'smop', + buildPath: '__tests__/__output/scss/', + files: [ + { + destination: '_icons.scss', + format: 'scss/icons', + }, + { + destination: '_variables.scss', + format: 'scss/variables', + }, + ], + }, + less: { + transformGroup: 'less', + prefix: 'smop', + buildPath: '__tests__/__output/less/', + files: [ + { + destination: '_icons.less', + format: 'less/icons', + }, + { + destination: '_variables.less', + format: 'less/variables', + }, + ], + }, + android: { + transformGroup: 'android', + buildPath: '__tests__/__output/', + files: [ + { + destination: 'android/colors.xml', + format: 'android/colors', + }, + { + destination: 'android/font_dimen.xml', + format: 'android/fontDimens', + }, + { + destination: 'android/dimens.xml', + format: 'android/dimens', + }, + ], + actions: ['android/copyImages'], + }, + ios: { + transformGroup: 'ios', + buildPath: '__tests__/__output/ios/', + files: [ + { + destination: 'style_dictionary.plist', + format: 'ios/plist', + }, + { + destination: 'style_dictionary.h', + format: 'ios/macros', + }, + ], + }, + 'react-native': { + transformGroup: 'react-native', + buildPath: '__tests__/__output/react-native/', + files: [ + { + destination: 'style_dictionary.js', + format: 'javascript/es6', + }, + ], + }, + }, +} + +export default cfg; diff --git a/__tests__/__json_files/shallow/5.topojson b/__tests__/__json_files/shallow/5.topojson new file mode 100644 index 000000000..7f4ace054 --- /dev/null +++ b/__tests__/__json_files/shallow/5.topojson @@ -0,0 +1,7 @@ +{ + "jsonCA": 5, + // some comment + "d": { + "jsonCe": 1 + } +} diff --git a/__tests__/__json_files/tokens.ts b/__tests__/__json_files/tokens.ts new file mode 100644 index 000000000..b51b038f2 --- /dev/null +++ b/__tests__/__json_files/tokens.ts @@ -0,0 +1,10 @@ +export default { + colors: { + $type: "color", + red: { + 500: { + $value: '#ff0000' + } + } + } +} \ No newline at end of file diff --git a/__tests__/strip-types-test.js b/__tests__/strip-types-test.js new file mode 100644 index 000000000..72e65003a --- /dev/null +++ b/__tests__/strip-types-test.js @@ -0,0 +1,21 @@ +import assert from 'node:assert'; +import StyleDictionary from 'style-dictionary'; + +// Just a quick and dirty smoke test to check that the experimental strip type flag allows using TS tokens files + +// this config also uses ".ts" tokens paths +const sd = new StyleDictionary('__tests__/__configs/test.ts'); +await sd.hasInitialized; + +assert.deepEqual(sd.tokens, { + colors: { + red: { + 500: { + $type: 'color', + $value: '#ff0000', + filePath: '__tests__/__json_files/tokens.ts', + isSource: true, + }, + }, + }, +}); diff --git a/__tests__/utils/__snapshots__/loadFile.test.snap.js b/__tests__/utils/__snapshots__/loadFile.test.snap.js new file mode 100644 index 000000000..c65961c3f --- /dev/null +++ b/__tests__/utils/__snapshots__/loadFile.test.snap.js @@ -0,0 +1,20 @@ +/* @web/test-runner snapshot v1 */ +export const snapshots = {}; + +snapshots["utils loadFile should support custom json extensions by warning about unrecognized file extension, using JSON5 parser as fallback"] = +`Unrecognized file extension: .topojson. Using JSON5 parser as a default. Alternatively, create a custom parser to handle this filetype https://styledictionary.com/reference/hooks/parsers/`; +/* end snapshot utils loadFile should support custom json extensions by warning about unrecognized file extension, using JSON5 parser as fallback */ + +snapshots["utils loadFile should throw error if it tries to import TS files with unsupported Node env"] = +`Failed to load or parse JSON or JS Object: + +Could not import TypeScript file: __tests__/__json_files/tokens.ts + +Executing typescript files during runtime is only possible via +- NodeJS >= 22.6.0 with '--experimental-strip-types' flag +- Deno +- Bun + +If you are not able to satisfy the above requirements, consider transpiling the TypeScript file to plain JavaScript before running the Style Dictionary build process.`; +/* end snapshot utils loadFile should throw error if it tries to import TS files with unsupported Node env */ + diff --git a/__tests__/utils/combineJSON.test.js b/__tests__/utils/combineJSON.test.js index 28d38b616..68cf2f33b 100644 --- a/__tests__/utils/combineJSON.test.js +++ b/__tests__/utils/combineJSON.test.js @@ -75,13 +75,6 @@ describe('utils', () => { }); }); - it('should fail on invalid JSON', async () => { - await expectThrowsAsync( - () => combineJSON(['__tests__/__json_files/broken/*.json']), - "Failed to load or parse JSON or JS Object: JSON5: invalid character '!' at 2:18", - ); - }); - it('should fail if there is a collision and it is passed a collision function', async () => { await expectThrowsAsync( () => @@ -95,18 +88,6 @@ describe('utils', () => { ); }); - it('should support json5', async () => { - const { tokens } = await combineJSON(['__tests__/__json_files/shallow/*.json5']); - expect(tokens).to.have.property('json5A', 5); - expect(tokens.d).to.have.property('json5e', 1); - }); - - it('should support jsonc', async () => { - const { tokens } = await combineJSON(['__tests__/__json_files/shallow/*.jsonc']); - expect(tokens).to.have.property('jsonCA', 5); - expect(tokens.d).to.have.property('jsonCe', 1); - }); - describe('custom parsers', () => { it('should support yaml.parse', async () => { const parsers = { diff --git a/__tests__/utils/loadFile.test.js b/__tests__/utils/loadFile.test.js new file mode 100644 index 000000000..06b891740 --- /dev/null +++ b/__tests__/utils/loadFile.test.js @@ -0,0 +1,61 @@ +/* + * 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 { expectThrowsAsync } from '../__helpers.js'; +import { loadFile } from '../../lib/utils/loadFile.js'; +import { stubMethod, restore } from 'hanbi'; +import { isNode } from '../../lib/utils/isNode.js'; + +describe('utils', () => { + describe('loadFile', () => { + it('should fail on invalid JSON', async () => { + await expectThrowsAsync( + () => loadFile('__tests__/__json_files/broken/broken.json'), + "Failed to load or parse JSON or JS Object:\n\nJSON5: invalid character '!' at 2:18", + ); + }); + + it('should support json5', async () => { + const tokens = await loadFile('__tests__/__json_files/shallow/3.json5'); + expect(tokens).to.have.property('json5A', 5); + expect(tokens.d).to.have.property('json5e', 1); + }); + + it('should support jsonc', async () => { + const tokens = await loadFile('__tests__/__json_files/shallow/4.jsonc'); + expect(tokens).to.have.property('jsonCA', 5); + expect(tokens.d).to.have.property('jsonCe', 1); + }); + + it('should throw error if it tries to import TS files with unsupported Node env', async () => { + if (isNode) { + let err; + try { + await loadFile('__tests__/__json_files/tokens.ts'); + } catch (e) { + err = e; + } + await expect(err.message).to.matchSnapshot(); + } + }); + + it('should support custom json extensions by warning about unrecognized file extension, using JSON5 parser as fallback', async () => { + const stub = stubMethod(console, 'warn'); + const tokens = await loadFile('__tests__/__json_files/shallow/5.topojson'); + expect(tokens).to.have.property('jsonCA', 5); + expect(tokens.d).to.have.property('jsonCe', 1); + await expect([...stub.calls][0].args[0]).to.matchSnapshot(); + restore(); + }); + }); +}); diff --git a/docs/src/content/docs/reference/api.mdx b/docs/src/content/docs/reference/api.mdx index ca214d266..94e711b7c 100644 --- a/docs/src/content/docs/reference/api.mdx +++ b/docs/src/content/docs/reference/api.mdx @@ -10,14 +10,14 @@ import { Badge } from '@astrojs/starlight/components'; Create a new StyleDictionary instance. -| Param | Type | Description | -| ------------------- | ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `config` | [`Config`](/reference/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` | `Object` | Options object when creating a new StyleDictionary instance. | -| `options.init` | `boolean` | `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` | `'silent'\|'default'\|'verbose'` | Verbosity of logs, overrides `log.verbosity` set in SD config or platform config. | -| `options.warnings` | `'error'\|'warn'\|'disabled'` | Whether to throw warnings as errors, warn or disable warnings, overrides `log.verbosity` set in SD config or platform config. | -| `options.volume` | `import('memfs').IFs \| typeof import('node:fs')` | FileSystem Volume to use as an alternative to the default FileSystem, handy if you need to isolate or "containerise" StyleDictionary files | +| Param | Type | Description | +| ------------------- | ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `config` | [`Config`](/reference/config) | Configuration options to build your style dictionary. If you pass a string, it will be used as a path to a JSON or JavaScript ESM (default export) config file. TypeScript file is natively supported as well with Bun, Deno or NodeJS >= 22.6.0 + `--experimental-strip-types` flag. Alternatively, you can also pass an object with the configuration. | +| `options` | `Object` | Options object when creating a new StyleDictionary instance. | +| `options.init` | `boolean` | `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` | `'silent'\|'default'\|'verbose'` | Verbosity of logs, overrides `log.verbosity` set in SD config or platform config. | +| `options.warnings` | `'error'\|'warn'\|'disabled'` | Whether to throw warnings as errors, warn or disable warnings, overrides `log.verbosity` set in SD config or platform config. | +| `options.volume` | `import('memfs').IFs \| typeof import('node:fs')` | FileSystem Volume to use as an alternative to the default FileSystem, handy if you need to isolate or "containerise" StyleDictionary files | Example: @@ -116,14 +116,14 @@ type extend = (config: Config | string, options: Options) ⇒ Promise= 22.6.0 + `--experimental-strip-types` flag. Alternatively, can also pass an object with the configuration. | +| `options` | `Object` | | +| `options.verbosity` | `'silent'\|'default'\|'verbose'` | Verbosity of logs, overrides `log.verbosity` set in SD config or platform config. | +| `options.warnings` | `'error'\|'warn'\|'disabled'` | Whether to throw warnings as errors, warn or disable warnings, overrides `log.verbosity` set in SD config or platform config. | +| `options.volume` | `import('memfs').IFs \| typeof import('node:fs')` | Pass a custom Volume to use instead of filesystem shim itself. Only possible in browser or in Node if you're explicitly using `memfs` as filesystem shim (by calling `setFs()` function and setting it to the `memfs` module) | +| `options.mutateOriginal` | `boolean` | Private option, do not use | Example: diff --git a/docs/src/content/docs/reference/config.md b/docs/src/content/docs/reference/config.md index 2f8e26e6f..ce71f6e46 100644 --- a/docs/src/content/docs/reference/config.md +++ b/docs/src/content/docs/reference/config.md @@ -142,20 +142,20 @@ You would then change your npm script or CLI command to run that file with Node: ## Properties -| Property | Type | Description | -| :-------------- | :-------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `log` | `Log` | [Configure logging behavior](/reference/logging) to either reduce/silence logs or to make them more verbose for debugging purposes. | -| `source` | `string[]` | An array of file path [globs](https://github.com/isaacs/node-glob) to design token files. Style Dictionary will do a deep merge of all of the token files, allowing you to organize your files however you want. | -| `include` | `string[]` | An array of file path [globs](https://github.com/isaacs/node-glob) to design token files that contain default styles. Style Dictionary uses this as a base collection of design tokens. The tokens found using the "source" attribute will overwrite tokens found using include. | -| `tokens` | `Object` | The tokens object is a way to include inline design tokens as opposed to using the `source` and `include` arrays. | -| `expand` | `ExpandConfig` | Configures whether and how composite (object-value) tokens will be expanded into separate tokens. `false` by default. Supports either `boolean`, `ExpandFilter` function or an Object containing a `typesMap` property and optionally an `include` OR `exclude` property. | -| `platforms` | `Record` | An object containing [platform](#platform) config objects that describe how the Style Dictionary should build for that platform. You can add any arbitrary attributes on this object that will get passed to formats and actions (more on these in a bit). This is useful for things like build paths, name prefixes, variable names, etc. | -| `hooks` | `Hooks` object | Object that contains all configured custom hooks: `preprocessors`. Note: `parsers`, `transforms`, `transformGroups`, `formats`, `fileHeaders`, `filters`, `actions` will be moved under property this later. Can be used to define hooks inline as an alternative to using `register` methods. | -| `parsers` | `string[]` | Names of custom [file parsers](/reference/hooks/parsers) to run on input files | -| `preprocessors` | `string[]` | Which [preprocessors](/reference/hooks/preprocessors) (by name) to run on the full token dictionary, before any transforms run, can be registered using `.registerPreprocessor`. You can also configure this on the platform config level if you need to run it on the dictionary only for specific platforms. | -| `transform` | `Record` | Custom [transforms](/reference/hooks/transforms) you can include inline rather than using `.registerTransform`. The keys in this object will be the transform's name, the value should be an object with `type` | -| `format` | `Record` | Custom [formats](/reference/hooks/formats) you can include inline in the configuration rather than using `.registerFormat`. The keys in this object will be for format's name and value should be the format function. | -| `usesDtcg` | `boolean` | Whether the tokens are using [DTCG Format](https://tr.designtokens.org/format/) or not. Usually you won't need to configure this, as style-dictionary will auto-detect this format. | +| Property | Type | Description | +| :-------------- | :-------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `log` | `Log` | [Configure logging behavior](/reference/logging) to either reduce/silence logs or to make them more verbose for debugging purposes. | +| `source` | `string[]` | An array of file path [globs](https://github.com/isaacs/node-glob) to design token files. Style Dictionary will do a deep merge of all of the token files, allowing you to organize your files however you want. Supports JSON, JSON5, JavaScript ESM (default export object) token files. TypeScript file is natively supported as well with Bun, Deno or NodeJS >= 22.6.0 + `--experimental-strip-types` flag | +| `include` | `string[]` | An array of file path [globs](https://github.com/isaacs/node-glob) to design token files that contain default styles. Style Dictionary uses this as a base collection of design tokens. The tokens found using the "source" attribute will overwrite tokens found using include. | +| `tokens` | `Object` | The tokens object is a way to include inline design tokens as opposed to using the `source` and `include` arrays. | +| `expand` | `ExpandConfig` | Configures whether and how composite (object-value) tokens will be expanded into separate tokens. `false` by default. Supports either `boolean`, `ExpandFilter` function or an Object containing a `typesMap` property and optionally an `include` OR `exclude` property. | +| `platforms` | `Record` | An object containing [platform](#platform) config objects that describe how the Style Dictionary should build for that platform. You can add any arbitrary attributes on this object that will get passed to formats and actions (more on these in a bit). This is useful for things like build paths, name prefixes, variable names, etc. | +| `hooks` | `Hooks` object | Object that contains all configured custom hooks: `preprocessors`. Note: `parsers`, `transforms`, `transformGroups`, `formats`, `fileHeaders`, `filters`, `actions` will be moved under property this later. Can be used to define hooks inline as an alternative to using `register` methods. | +| `parsers` | `string[]` | Names of custom [file parsers](/reference/hooks/parsers) to run on input files | +| `preprocessors` | `string[]` | Which [preprocessors](/reference/hooks/preprocessors) (by name) to run on the full token dictionary, before any transforms run, can be registered using `.registerPreprocessor`. You can also configure this on the platform config level if you need to run it on the dictionary only for specific platforms. | +| `transform` | `Record` | Custom [transforms](/reference/hooks/transforms) you can include inline rather than using `.registerTransform`. The keys in this object will be the transform's name, the value should be an object with `type` | +| `format` | `Record` | Custom [formats](/reference/hooks/formats) you can include inline in the configuration rather than using `.registerFormat`. The keys in this object will be for format's name and value should be the format function. | +| `usesDtcg` | `boolean` | Whether the tokens are using [DTCG Format](https://tr.designtokens.org/format/) or not. Usually you won't need to configure this, as style-dictionary will auto-detect this format. | ### Log diff --git a/lib/StyleDictionary.js b/lib/StyleDictionary.js index 52ed5119e..2c67ba42e 100644 --- a/lib/StyleDictionary.js +++ b/lib/StyleDictionary.js @@ -11,8 +11,7 @@ * and limitations under the License. */ -import JSON5 from 'json5'; -import { extname, dirname } from 'path-unified'; +import { dirname } from 'path-unified'; import { fs } from 'style-dictionary/fs'; import chalk from 'chalk'; @@ -29,7 +28,6 @@ import { deepmerge } from './utils/deepmerge.js'; import { expandTokens } from './utils/expandObjectTokens.js'; import { convertTokenData } from './utils/convertTokenData.js'; -import { resolve } from './resolve.js'; import { Register } from './Register.js'; import transformObject from './transform/object.js'; import transformConfig from './transform/config.js'; @@ -38,7 +36,7 @@ import filterTokens from './filterTokens.js'; import cleanFiles from './cleanFiles.js'; import cleanDirs from './cleanDirs.js'; import cleanActions from './cleanActions.js'; -import { isNode } from './utils/isNode.js'; +import { loadFile } from './utils/loadFile.js'; /** * @typedef {import('../types/Volume.d.ts').Volume} Volume @@ -219,22 +217,7 @@ export default class StyleDictionary extends Register { // Overloaded method, can accept a string as a path that points to a JS or // JSON file or a plain object. Potentially refactor. if (typeof config === 'string') { - // 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, this.volume.__custom_fs__); - if (['json', 'json5', 'jsonc'].includes(ext)) { - options = JSON5.parse( - /** @type {string} */ (this.volume.readFileSync(cfgFilePath, 'utf-8')), - ); - } else { - let _filePath = cfgFilePath; - if (isNode && process?.platform === 'win32') { - // Windows FS compatibility. If in browser, we use an FS shim which doesn't require this Windows workaround - _filePath = new URL(`file:///${_filePath}`).href; - } - options = (await import(/* @vite-ignore */ /* webpackIgnore: true */ _filePath)).default; - } + options = /** @type {Config} */ (await loadFile(config, this.volume)); } else { options = config; } diff --git a/lib/utils/combineJSON.js b/lib/utils/combineJSON.js index 82e3a883c..f63fd6c1e 100644 --- a/lib/utils/combineJSON.js +++ b/lib/utils/combineJSON.js @@ -11,14 +11,13 @@ * and limitations under the License. */ -import JSON5 from 'json5'; import { globSync } from '@bundled-es-modules/glob'; -import { extname } from 'path-unified'; import { fs } from 'style-dictionary/fs'; import { resolve } from '../resolve.js'; import deepExtend from './deepExtend.js'; import { detectDtcgSyntax } from './detectDtcgSyntax.js'; import { isNode } from './isNode.js'; +import { loadFile } from './loadFile.js'; /** * @typedef {import('../../types/Volume.d.ts').Volume} Volume @@ -87,39 +86,21 @@ export default async function combineJSON( const filePath = files[i]; const resolvedPath = resolve(filePath, vol?.__custom_fs__); let file_content = null; - try { - for (const { pattern, parser } of Object.values(parsers)) { - if (filePath.match(pattern)) { - file_content = await parser({ - contents: /** @type {string} */ (volume.readFileSync(resolvedPath, 'utf-8')), - filePath: resolvedPath, - }); - } - } - // If there is no file_content then no custom parser ran on that file - if (!file_content) { - if (['.js', '.mjs'].includes(extname(filePath))) { - let resolvedPath = resolve(filePath, vol?.__custom_fs__); - if (isNode && process?.platform === 'win32') { - // Windows FS compatibility. If in browser, we use an FS shim which doesn't require this Windows workaround - resolvedPath = new URL(`file:///${resolvedPath}`).href; - } - file_content = (await import(/* @vite-ignore */ /* webpackIgnore: true */ resolvedPath)) - .default; - } else { - file_content = JSON5.parse( - /** @type {string} */ (volume.readFileSync(resolvedPath, 'utf-8')), - ); - } - } - } catch (e) { - if (e instanceof Error) { - e.message = 'Failed to load or parse JSON or JS Object: ' + e.message; - throw e; + for (const { pattern, parser } of Object.values(parsers)) { + if (filePath.match(pattern)) { + file_content = await parser({ + contents: /** @type {string} */ (volume.readFileSync(resolvedPath, 'utf-8')), + filePath: resolvedPath, + }); } } + // If there is no file_content then no custom parser ran on that file + if (!file_content) { + file_content = /** @type {Tokens} */ (await loadFile(filePath, vol)); + } + if (file_content) { if (usesDtcg === undefined) { usesDtcg = detectDtcgSyntax(file_content); diff --git a/lib/utils/loadFile.js b/lib/utils/loadFile.js new file mode 100644 index 000000000..b6bc0f174 --- /dev/null +++ b/lib/utils/loadFile.js @@ -0,0 +1,89 @@ +import JSON5 from 'json5'; +import { extname } from 'path-unified'; +import { fs } from 'style-dictionary/fs'; +import { resolve } from '../resolve.js'; +import { isNode } from './isNode.js'; + +/** + * @typedef {import('../../types/Volume.d.ts').Volume} Volume + * @typedef {import('../../types/Config.d.ts').Config} Config + * @typedef {import('../../types/DesignToken.d.ts').DesignTokens} DesignTokens + */ + +/** + * @param {string} filePath + * @param {Volume} [vol] - Filesystem volume to use + */ +export async function loadFile(filePath, vol) { + const volume = vol ?? fs; + let resolvedPath = resolve(filePath, vol?.__custom_fs__); + + /** @type {DesignTokens | Config | undefined} */ + let file_content; + let errMessage = `Failed to load or parse JSON or JS Object:\n\n`; + + switch (extname(filePath)) { + case '.js': + case '.mjs': + case '.ts': { + resolvedPath = resolve(filePath, vol?.__custom_fs__); + + if (isNode && process?.platform === 'win32') { + // Windows FS compatibility. If in browser, we use an FS shim which doesn't require this Windows workaround + resolvedPath = new URL(`file:///${resolvedPath}`).href; + } + try { + file_content = (await import(/* @vite-ignore */ /* webpackIgnore: true */ resolvedPath)) + .default; + } catch (e) { + if (e instanceof Error) { + if ('.ts' === extname(filePath)) { + // Add to the error message some info about experimental strip-types NodeJS flag + errMessage += `Could not import TypeScript file: ${filePath} + +Executing typescript files during runtime is only possible via +- NodeJS >= 22.6.0 with '--experimental-strip-types' flag +- Deno +- Bun + +If you are not able to satisfy the above requirements, consider transpiling the TypeScript file to plain JavaScript before running the Style Dictionary build process.`; + } else { + errMessage = e.message; + } + } + throw new Error(`${errMessage}`); + } + break; + } + case '.json': + case '.jsonc': + case '.json5': { + try { + file_content = JSON5.parse( + /** @type {string} */ (volume.readFileSync(resolvedPath, 'utf-8')), + ); + } catch (e) { + if (e instanceof Error) { + errMessage += e.message; + } + throw new Error(errMessage); + } + break; + } + default: { + // Use json parser fallback by default + // Other file types like .hjson, .topojson, or .ndjson, might be handled with dedicated parsers in the future. + // This warning is here to hint users that their file is parsed with the standard JSON5 parser and not any dedicated parser. + console.warn( + `Unrecognized file extension: ${extname( + filePath, + )}. Using JSON5 parser as a default. Alternatively, create a custom parser to handle this filetype https://styledictionary.com/reference/hooks/parsers/`, + ); + file_content = JSON5.parse( + /** @type {string} */ (volume.readFileSync(resolvedPath, 'utf-8')), + ); + } + } + + return file_content; +} diff --git a/package.json b/package.json index 5aef0f706..7822472b1 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "test:node": "mocha -r mocha-hooks.mjs './__integration__/**/*.test.js' './__tests__/**/*.test.js' './__node_tests__/**/*.test.js'", "test:perf": "mocha -r mocha-hooks.mjs './__perf_tests__/**/*.test.js'", "test:perf:debug": "web-test-runner --config wtr-perf.config.mjs --watch", + "test:strip-types": "node --experimental-strip-types __tests__/strip-types-test.js", "install-cli": "npm install -g $(npm pack)", "release": "npm run build && changeset publish", "prepare": "husky install",