diff --git a/.circleci/config.yml b/.circleci/config.yml index be9dac8..b11fca7 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -17,7 +17,7 @@ executors: node: working_directory: ~/repo docker: - - image: circleci/node:14.18 + - image: circleci/node:14.18 # ------------------------------ # Commands @@ -26,15 +26,15 @@ executors: commands: checkout-and-restore-cache: steps: - - checkout - - restore_cache: - key: &cache-key << pipeline.parameters.cache-version >>-node14-dependencies-{{ arch }}-{{ checksum "./yarn.lock" }} + - checkout + - restore_cache: + key: &cache-key << pipeline.parameters.cache-version >>-node14-dependencies-{{ arch }}-{{ checksum "./yarn.lock" }} set-git-write-access: steps: - - run: - name: Setup git origin with write access - command: git remote set-url origin "https://$GITHUB_TOKEN@github.com/$CIRCLE_PROJECT_USERNAME/$CIRCLE_PROJECT_REPONAME" + - run: + name: Setup git origin with write access + command: git remote set-url origin "https://$GITHUB_TOKEN@github.com/$CIRCLE_PROJECT_USERNAME/$CIRCLE_PROJECT_REPONAME" # ------------------------------ # Jobs @@ -44,57 +44,56 @@ jobs: dependencies: executor: node steps: - - checkout-and-restore-cache - - run: - name: Install dependencies - command: yarn --prefer-offline --pure-lockfile --ignore-engines - - save_cache: - paths: - - ./node_modules - key: *cache-key + - checkout-and-restore-cache + - run: + name: Install dependencies + command: yarn --prefer-offline --pure-lockfile --ignore-engines + - save_cache: + paths: + - ./node_modules + key: *cache-key unit-test: executor: node steps: - - checkout-and-restore-cache - - run: - name: Run tests - command: yarn test + - checkout-and-restore-cache + - run: + name: Run tests + command: yarn test build: executor: node steps: - - checkout-and-restore-cache - - run: - name: Build sources - command: yarn build - - persist_to_workspace: - root: ~/repo - paths: - - dist - + - checkout-and-restore-cache + - run: + name: Build sources + command: yarn build + - persist_to_workspace: + root: ~/repo + paths: + - dist + dry-release: executor: node steps: - - checkout-and-restore-cache - - attach_workspace: - at: ~/repo - - set-git-write-access - - run: - name: Create release - command: yarn semantic-release --dry-run --branch ${CIRCLE_BRANCH} + - checkout-and-restore-cache + - attach_workspace: + at: ~/repo + - set-git-write-access + - run: + name: Create release + command: yarn semantic-release --dry-run --branch ${CIRCLE_BRANCH} release: executor: node steps: - - checkout-and-restore-cache - - attach_workspace: - at: ~/repo - - set-git-write-access - - run: - name: Create release - command: yarn semantic-release - + - checkout-and-restore-cache + - attach_workspace: + at: ~/repo + - set-git-write-access + - run: + name: Create release + command: yarn semantic-release # ------------------------------ # Workflow @@ -103,29 +102,29 @@ jobs: workflows: main-workflow: jobs: - - dependencies - - unit-test: - requires: - - dependencies - - build: - requires: - - dependencies - - dry-release: - context: NpmReleasePublish - requires: - - unit-test - - build - - hold-release: - type: approval - filters: - branches: - only: main - - release: - context: NpmReleasePublish - requires: - - unit-test - - build - - hold-release - filters: - branches: - only: main \ No newline at end of file + - dependencies + - unit-test: + requires: + - dependencies + - build: + requires: + - dependencies + - dry-release: + context: NpmReleasePublish + requires: + - unit-test + - build + - hold-release: + type: approval + filters: + branches: + only: main + - release: + context: NpmReleasePublish + requires: + - unit-test + - build + - hold-release + filters: + branches: + only: main diff --git a/.gitignore b/.gitignore index a547bf3..bce9e9f 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,6 @@ dist-ssr *.njsproj *.sln *.sw? + +/*.d.ts +!/interfaces.d.ts \ No newline at end of file diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..9f31556 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,2 @@ +CHANGELOG.md +dist/ \ No newline at end of file diff --git a/.prettierrc.json b/.prettierrc.json index 0810850..6fc6d66 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -1,6 +1,6 @@ { - "singleQuote": true, - "trailingComma": "all", - "semi": true, - "printWidth": 100 -} \ No newline at end of file + "singleQuote": true, + "trailingComma": "all", + "semi": true, + "printWidth": 100 +} diff --git a/README.md b/README.md index 1521513..d5f2959 100644 --- a/README.md +++ b/README.md @@ -33,8 +33,12 @@ In the root of your sources (eg: `./src`) create a `themthem-interfaces.d.ts` an /// interface GlobalDesignTokenBox { - colors: ['black', 'white', 'my-custom-color']; - sizes: ['sm', 'md', 'lg', 'xl'] + // This is a Values object and show that 'black', 'white' and 'my-custom-color' are values + palette: { $values: ['black', 'white', 'my-custom-color'] }; + tokens: { + colors: { $values: ['base', 'accent'] }; + sizes: { $values: ['sm', 'md', 'lg', 'xl'] }; + }; } ``` @@ -46,99 +50,224 @@ To define component token you need to augment `ComponentDesignTokenBox`. It's re /// interface ComponentDesignTokenBox { - Input: ['background-color', 'color', 'border-color', 'border-size'] + Input: { + // This is a Values object and show that 'color' is a value + $values: ['color']; + border: { + // This is a Modifiers object and show that 'size' + // can have a "$default" value and a "$focus" value + size: { + $modifiers: ['focus']; + }; + color: { + $modifiers: ['focus']; + }; + }; + }; } ``` +All the final tokens of your Token Box must be either a Value object or a Modifiers object. + ## API All the functions of the API is based on `GlobalDesignTokenBox` and `ComponentDesignTokenBox` by default. So the example on this section will all be based on the augmentation done in the [Usage](#usage) section. -### `cssVariable(type, key, token, options?)` +### Global theme + +#### `gVar(path)` -Generate a CSS Variable based on your theme. +Generate a CSS variable usage (`var(--variable)`) based on your `GlobalDesignTokenBox`. -| Parameter | Type | Description | Default value | -|---|---|---|---| -| type | `'global' \| 'component'` | The type of token you want. `'global'` for GlobalDesignTokens or `'component'` for ComponentDesignToken || -| key | `keyof Themthem[typeof type]` | The global design token category or component name || -| token | `Themthem[typeof type][typeof key][number]` | The token token inside the category/component || -| options | `{ bare?: boolean } \| undefined` | Whether you want the bare css variable or it's usage | `{ bare: false }` | +| Parameter | Type | Description | Default value | +| --------- | -------- | ---------------------- | ------------- | +| path | `string` | The path to your token | | -`@returns {string} The bare CSS variable or CSS variable usage` +`@returns {string} The CSS variable usage of your token` ```ts -import { cssVariable } from 'themthem'; +import { gVar } from 'themthem/global'; -cssVariable('global', 'color', 'black', { bare: true }); // "--global-color-black" -cssVariable('global', 'color', 'black'); // "var(--global-color-black)" +gVar('palette.black'); // "var(--global-palette-black)" +gVar('tokens.colors.accent'); // "var(--global-tokens-colors-accent)" ``` -### generateGlobalCSSVariables(config) +#### `gIdentifier(path)` + +Generate a CSS variable identifier (`--variable`) based on your `GlobalDesignTokenBox`. + +| Parameter | Type | Description | Default value | +| --------- | -------- | ---------------------- | ------------- | +| path | `string` | The path to your token | | + +`@returns {string} The CSS variable identifier of your token` + +```ts +import { gIdentifier } from 'themthem/global'; + +gIdentifier('palette.black'); // "--global-palette-black" +gIdentifier('tokens.colors.accent'); // "--global-tokens-colors-accent" +``` + +#### `generateVars(config)` Generate CSS variables assignments for you global design tokens based on your theme. -| Parameter | Type | Description | Default value | -|---|---|---|---| -| config | Object | A 2-level deep object assigning values to your global design tokens || +| Parameter | Type | Description | Default value | +| --------- | ------ | ------------------------------------------------------------- | ------------- | +| config | Object | A config object assigning values to your global design tokens | | `@returns {string[]} An array of CSS variables declarations` ```ts -import { generateGlobalCSSVariables } from 'themthem'; - -const globalVariablesAssignments = generateGlobalCSSVariables({ +import { generateVars } from 'themthem/global'; + +const globalVars = generateVars({ + palette: { + black: '#000', + white: '#fff', + 'my-custom-color': '#298af3', + }, + tokens: { colors: { - black: '#000', - white: '#fff', - 'my-custom-color': '#298af3' + base: gVar('palette.black'), + accent: gVar('palette.my-custom-color'), }, sizes: { - sm: '4px', - md: '8px', - lg: '12px', - xl: '20px', - } + sm: '4px', + md: '8px', + lg: '12px', + xl: '20px', + }, + }, +}); + +// Returns: +// +// [ +// '--global-palette-black: #000;', +// '--global-palette-white: #fff;', +// '--global-palette-my-custom-color: #298af3;', +// '--global-tokens-colors-base: var(--global-palette-black);', +// '--global-tokens-colors-accent: var(--global-palette-my-custom-color);', +// '--global-tokens-sizes-sm: 4px;', +// '--global-tokens-sizes-md: 8px;', +// '--global-tokens-sizes-lg: 12px;', +// '--global-tokens-sizes-xl: 20px;', +// ] +``` + +#### `createGenerator(path)` + +Create a function which lets you generate CSS variables assignments for a part of your global design tokens. + +| Parameter | Type | Description | Default value | +| --------- | -------- | ------------------------------------------------------- | ------------- | +| path | `string` | The path to the part of the theme you want to configure | | + +`@returns {(config): string[]} A function which generates CSS variables assignments for the specified part of your global design` + +```ts +import { createGenerator } from 'themthem/global'; + +const generateColorsVars = createGenerator('tokens.colors'); + +const lightThemeColorsTokens = generateColorsVars({ + base: gVar('palette.black'), + accent: gVar('palette.my-custom-color'), }); +// Returns: +// // [ -// '--global-colors-black: #000;', -// '--global-colors-white: #fff;', -// '--global-colors-my-custom-color: #298af3;', -// '--global-sizes-sm: 4px;', -// '--global-sizes-md: 8px;', -// '--global-sizes-lg: 12px;', -// '--global-sizes-xl: 20px;' +// '--global-tokens-colors-base: var(--global-palette-black);', +// '--global-tokens-colors-accent: var(--global-palette-my-custom-color);', +// ] + +const darkThemeColorsTokens = generateColorsVars({ + base: gVar('palette.white'), + accent: gVar('palette.my-custom-color'), +}); + +// Returns: +// +// [ +// '--global-tokens-colors-base: var(--global-palette-white);', +// '--global-tokens-colors-accent: var(--global-palette-my-custom-color);', // ] ``` -### createCSSVariableGenerator(component) +### Component Theme + +#### `cVar(path)` + +Generate a CSS variable usage (`var(--variable)`) based on your `ComponentDesignTokenBox`. + +| Parameter | Type | Description | Default value | +| --------- | -------- | ---------------------- | ------------- | +| path | `string` | The path to your token | | + +`@returns {string} The CSS Variable usage of your token` + +```ts +import { cVar } from 'themthem/component'; + +cVar('Input.border.color'); // "var(--component-Input-border-color)" +cVar('Input.border.color.$focus'); // "var(--component-Input-border-color__focus)" +``` + +#### `cIdentifier(path)` + +Generate a CSS variable identifier (`--variable`) based on your `ComponentDesignTokenBox`. + +| Parameter | Type | Description | Default value | +| --------- | -------- | ---------------------- | ------------- | +| path | `string` | The path to your token | | + +`@returns {string} The CSS variable identifier of your token` + +```ts +import { cIdentifier } from 'themthem/component'; + +cIdentifier('Input.border.color'); // "--component-Input-border-color" +cIdentifier('Input.border.color.$focus'); // "--component-Input-border-color__focus" +``` + +#### `createGenerator(path)` Create a function which lets you generate CSS variables assignments for a component. -| Parameter | Type | Description | Default value | -|---|---|---|---| -| component | `keyof Themthem['component']` | The name of the component || +| Parameter | Type | Description | Default value | +| --------- | ----------------------------- | ------------------------- | ------------- | +| path | `string` | The path to the part of the component you want to configure | | `@returns {(config): string[]} A function which generates CSS variables assignments for the specified component` ```ts -import { createCSSVariablesGenerator } from 'themthem'; +import { createGenerator } from 'themthem/component'; -const generateInputCSSVariables = createCSSVariablesGenerator('Input'); +const generateInputVars = createGenerator('Input'); -const inputVariablesAssignments = generateInputCSSVariables({ - 'background-color': cssVariable('global', 'colors', 'white'), - 'color': 'black', - 'border-color': '#333', - 'border-size': '1px' +const inputVars = generateInputVars({ + border: { + // Input.border.color -> { $modifiers: ['focus'] } + color: { + $default: gVar('palette.black'), + $focus: gVar('tokens.colors.accent'), + }, + // Input.border.size -> { $modifiers: ['focus'] } + size: { + $default: '1px', + $focus: '2px', + }, + }, }); // [ -// '--input-background-color: var(--global-colors-white);', -// '--input-color: black;', -// '--input-border-color: #333;', -// '--input-border-size: 1px;', +// '--component-Input-border-color: var(--global-palette-black);', +// '--component-Input-border-color__focus: var(--global-tokens-colors-accent);', +// '--component-Input-border-size: 1px;', +// '--component-Input-border-size__focus: 2px;', // ] -``` \ No newline at end of file +``` diff --git a/package.json b/package.json index 023fb60..1ba5093 100644 --- a/package.json +++ b/package.json @@ -18,20 +18,32 @@ "url": "https://github.com/ChibiBlasphem/themthem.git" }, "types": "./dist/index.d.ts", - "main": "./dist/themthem.umd.js", + "main": "./dist/themthem.cjs.js", "module": "./dist/themthem.es.js", "files": [ - "./dist", - "./interfaces.d.ts" + "dist", + "*.d.ts" ], "exports": { ".": { + "types": "./dist/index.d.ts", "import": "./dist/themthem.es.js", - "require": "./dist/themthem.umd.js" + "require": "./dist/themthem.cjs.js" + }, + "./component": { + "types": "./dist/component.d.ts", + "import": "./dist/component.es.js", + "require": "./dist/component.cjs.js" + }, + "./global": { + "types": "./dist/global.d.ts", + "import": "./dist/global.es.js", + "require": "./dist/global.cjs.js" } }, "scripts": { - "build": "tsc --noEmit && vite build && tsc --emitDeclarationOnly && node ./scripts/typescript-replace.js", + "build": "tsc --noEmit && vite build && tsc -p tsconfig.build.json --emitDeclarationOnly", + "postbuild": "node ./scripts/make-dts", "test": "vitest --run --passWithNoTests", "prepare": "husky install" }, diff --git a/scripts/make-dts.js b/scripts/make-dts.js new file mode 100644 index 0000000..aef156a --- /dev/null +++ b/scripts/make-dts.js @@ -0,0 +1,13 @@ +const fs = require('fs'); +const path = require('path'); +const package = require('../package.json'); + +Object.keys(package.exports) + .map((entry) => entry.replace(/^\.\/?/, '')) + .filter(Boolean) + .forEach((entry) => { + fs.writeFileSync( + path.resolve(__dirname, '../', `${entry}.d.ts`), + `export * from './dist/${entry}';`, + ); + }); diff --git a/src/component.ts b/src/component.ts new file mode 100644 index 0000000..3046d6f --- /dev/null +++ b/src/component.ts @@ -0,0 +1,2 @@ +export { createComponentCSSVariablesGenerator as createGenerator } from './css-variables-generator'; +export { cVar, cIdentifier } from './helpers'; diff --git a/src/css-variable-generator.test.ts b/src/css-variable-generator.test.ts deleted file mode 100644 index 019c1df..0000000 --- a/src/css-variable-generator.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - createCSSVariablesGenerator, - generateGlobalCSSVariables, -} from './css-variable-generator'; - -interface TestThemthem { - global: { - foo: ['bar']; - bar: ['baz']; - }; - component: { - Foo: ['bar']; - Baz: ['baz']; - }; -} - -describe('generateGlobalCSSVariables', () => { - it('should returns an array of css variable affections', () => { - expect( - generateGlobalCSSVariables({ - foo: { - bar: 'value', - }, - bar: { - baz: 'other-value', - }, - }), - ).toEqual(['--global-foo-bar: value;', '--global-bar-baz: other-value;']); - }); -}); - -describe('createCSSVariableGenerator', () => { - it('should return a function', () => { - const result = createCSSVariablesGenerator<'Foo', TestThemthem>('Foo'); - expect(result).toBeInstanceOf(Function); - }); - - it('should returns an array of css variable affections', () => { - const generateCSSVariables = createCSSVariablesGenerator< - 'Foo', - TestThemthem - >('Foo'); - - expect( - generateCSSVariables({ - bar: 'value', - }), - ).toEqual(['--Foo-bar: value;']); - }); -}); diff --git a/src/css-variable-generator.ts b/src/css-variable-generator.ts deleted file mode 100644 index 78fc2be..0000000 --- a/src/css-variable-generator.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { DTBoxKey, DesignToken, Themthem } from './token-box'; -import { cssVariable } from './helpers'; -import { getKeys } from './utils'; - -export function createCSSVariablesGenerator< - Key extends DTBoxKey<'component', ThemeInterface> & string, - ThemeInterface extends Themthem = Themthem, ->(component: Key) { - return function generateCSSVariables(config: { - [Token in DesignToken<'component', Key, ThemeInterface>]+?: string; - }) { - const variables: string[] = []; - for (const key of getKeys(config)) { - variables.push( - `${cssVariable<'component', Key, typeof key, ThemeInterface>( - 'component', - component, - key, - { bare: true }, - )}: ${config[key]};`, - ); - } - return variables; - }; -} - -export function generateGlobalCSSVariables< - ThemeInterface extends Themthem = Themthem, ->(config: { - [Key in DTBoxKey<'global', ThemeInterface> & string]+?: { - [Token in DesignToken<'global', Key, ThemeInterface>]+?: string; - }; -}) { - const variables: string[] = []; - for (const key of getKeys(config)) { - const configValue = config[key]!; - for (const token of getKeys(configValue)) { - const variable = configValue[token]; - variables.push( - `${cssVariable('global', key as never, token as never, { - bare: true, - })}: ${variable};`, - ); - } - } - return variables; -} diff --git a/src/css-variables-generator.test.ts b/src/css-variables-generator.test.ts new file mode 100644 index 0000000..a693e96 --- /dev/null +++ b/src/css-variables-generator.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect } from 'vitest'; +import { + createComponentCSSVariablesGenerator, + createGlobalCSSVariablesGenerator, + generateGlobalCSSVariables, +} from './css-variables-generator'; +import { gVar } from './helpers'; + +interface TestThemthem { + global: { + foo: { $values: ['bar'] }; + bar: { + $values: ['baz']; + color: { + $modifiers: ['hover', 'disabled']; + }; + }; + }; + component: { + Foo: { + $values: ['bar']; + color: { + $modifiers: ['hover', 'disabled']; + }; + }; + Baz: { $values: ['baz'] }; + }; +} + +interface UsageTheme { + global: { + $values: ['uniqueToken']; + palette: { $values: ['black', 'white', 'my-custom-color'] }; + tokens: { + colors: { $values: ['default', 'accent'] }; + sizes: { $values: ['sm', 'md', 'lg', 'xl'] }; + }; + }; + component: {}; +} + +describe('generateGlobalCSSVariables', () => { + it('should returns an array of css variable affections', () => { + expect( + generateGlobalCSSVariables({ + foo: { + bar: 'value', + }, + bar: { + baz: 'other-value', + color: { + $default: 'default-color', + $hover: 'hover-color', + $disabled: 'disabled-color', + }, + }, + }), + ).toEqual([ + '--global-foo-bar: value;', + '--global-bar-baz: other-value;', + '--global-bar-color: default-color;', + '--global-bar-color__hover: hover-color;', + '--global-bar-color__disabled: disabled-color;', + ]); + + const expected = [ + '--global-palette-black: #000;', + '--global-palette-white: #fff;', + '--global-palette-my-custom-color: #298af3;', + '--global-tokens-colors-default: var(--global-palette-black);', + '--global-tokens-colors-accent: var(--global-palette-my-custom-color);', + '--global-tokens-sizes-sm: 4px;', + '--global-tokens-sizes-md: 8px;', + '--global-tokens-sizes-lg: 12px;', + '--global-tokens-sizes-xl: 20px;', + '--global-uniqueToken: #333;', + ]; + + expect( + generateGlobalCSSVariables({ + palette: { + black: '#000', + white: '#fff', + 'my-custom-color': '#298af3', + }, + tokens: { + colors: { + default: gVar('palette.black'), + accent: gVar('palette.my-custom-color'), + }, + sizes: { + sm: '4px', + md: '8px', + lg: '12px', + xl: '20px', + }, + }, + uniqueToken: '#333', + }), + ).toEqual(expected); + }); +}); + +describe('createGlobalCSSVariableGenerator', () => { + it('should returns a variable generator scoped to the path specified', () => { + const fn = createGlobalCSSVariablesGenerator('foo'); + expect(fn).toBeInstanceOf(Function); + + expect(fn({ bar: 'value' })).toEqual(['--global-foo-bar: value;']); + }); +}); + +describe('createComponentCSSVariablesGenerator', () => { + it('should returns a variable generator scoped to the path specified', () => { + const fnComponent = createComponentCSSVariablesGenerator('Foo'); + expect(fnComponent).toBeInstanceOf(Function); + + expect( + fnComponent({ + bar: 'value', + color: { $default: 'default', $hover: 'hover', $disabled: 'disabled' }, + }), + ).toEqual([ + '--component-Foo-bar: value;', + '--component-Foo-color: default;', + '--component-Foo-color__hover: hover;', + '--component-Foo-color__disabled: disabled;', + ]); + + const fnOnPath = createComponentCSSVariablesGenerator('Foo.color'); + expect(fnOnPath).toBeInstanceOf(Function); + + expect(fnOnPath({ $hover: 'hover', $disabled: 'disabled' })).toEqual([ + '--component-Foo-color__hover: hover;', + '--component-Foo-color__disabled: disabled;', + ]); + }); +}); diff --git a/src/css-variables-generator.ts b/src/css-variables-generator.ts new file mode 100644 index 0000000..a03e819 --- /dev/null +++ b/src/css-variables-generator.ts @@ -0,0 +1,113 @@ +import type { PartialTokenPath, Themthem, TokenPath } from './token-box'; +import { cIdentifier, gIdentifier } from './helpers'; +import { getKeys } from './utils'; + +type BoxValues = { + [k in '$values' extends keyof Box + ? Box['$values'] extends string[] + ? Box['$values'][number] + : never + : never]+?: string; +}; + +type BoxModifiers = { + [k in '$modifiers' extends keyof Box + ? Box['$modifiers'] extends string[] + ? `$${Box['$modifiers'][number]}` | '$default' + : never + : never]+?: string; +}; + +export type GeneratorConfig = BoxValues & + BoxModifiers & { + [k in keyof (Omit extends object + ? Omit + : never)]+?: GeneratorConfig; + }; + +export type CSSVariableGenerator = (config: GeneratorConfig) => string[]; + +export type TokenBoxFromPath< + Path extends string, + Box extends object, +> = Path extends `${infer Segment}.${infer Rest}` + ? Segment extends keyof Box + ? Box[Segment] extends object + ? TokenBoxFromPath + : never + : never + : Path extends keyof Box + ? Box[Path] + : never; + +function generateAssignments( + path: string, + box: Record | string, +): { path: string; value: string }[] { + if (typeof box === 'string') { + return [{ path, value: box }]; + } + + const assignments = []; + for (const key of getKeys(box)) { + assignments.push( + ...generateAssignments(path ? (key !== '' ? `${path}.${key}` : path) : key, box[key]), + ); + } + return assignments; +} + +export function generateGlobalCSSVariables( + config: GeneratorConfig, +): string[] { + const assignments = generateAssignments('', config); + const stringAssignments: string[] = []; + + for (const { path, value } of assignments) { + stringAssignments.push(`${gIdentifier(path as TokenPath)}: ${value};`); + } + + return stringAssignments; +} + +export function createGlobalCSSVariablesGenerator< + TI extends Themthem = Themthem, + Path extends PartialTokenPath = PartialTokenPath, +>( + path: Path, +): CSSVariableGenerator< + TokenBoxFromPath extends object ? TokenBoxFromPath : never +> { + return (config) => { + const assignments = generateAssignments(path, config); + const stringAssignments: string[] = []; + + for (const { path, value } of assignments) { + stringAssignments.push(`${gIdentifier(path as TokenPath)}: ${value};`); + } + + return stringAssignments; + }; +} + +export function createComponentCSSVariablesGenerator< + TI extends Themthem = Themthem, + Path extends PartialTokenPath = PartialTokenPath, +>( + path: Path, +): CSSVariableGenerator< + TokenBoxFromPath extends object + ? TokenBoxFromPath + : never +> { + return (config) => { + const assignments = generateAssignments(path, config); + const stringAssignments: string[] = []; + + for (const { path, value } of assignments) { + stringAssignments.push(`${cIdentifier(path as TokenPath)}: ${value};`); + } + + return stringAssignments; + }; +} diff --git a/src/global.ts b/src/global.ts new file mode 100644 index 0000000..7237de3 --- /dev/null +++ b/src/global.ts @@ -0,0 +1,5 @@ +export { + createGlobalCSSVariablesGenerator as createGenerator, + generateGlobalCSSVariables as generateVars, +} from './css-variables-generator'; +export { gVar, gIdentifier } from './helpers'; diff --git a/src/helpers.test.ts b/src/helpers.test.ts index 8d2a009..842df63 100644 --- a/src/helpers.test.ts +++ b/src/helpers.test.ts @@ -1,48 +1,72 @@ import { describe, it, expect } from 'vitest'; -import { cssVariable } from './helpers'; +import { gIdentifier, gVar, cIdentifier, cVar } from './helpers'; interface TestThemthem { global: { - foo: ['bar']; - bar: ['baz']; + foo: { $values: ['bar'] }; + bar: { $values: ['baz'] }; + baz: { + test: { $values: ['foo'] }; + }; }; component: { - Foo: ['bar']; - Baz: ['baz']; + Foo: { $values: ['bar'] }; + Bar: { $values: ['baz'] }; + Baz: { + test: { $values: ['foo'] }; + foo: { + $modifiers: ['mod1', 'mod2']; + }; + }; }; } -describe('cssVariable', () => { - it('should print the css variable usage by default', () => { - expect( - cssVariable<'global', 'foo', 'bar', TestThemthem>('global', 'foo', 'bar'), - ).toBe('var(--global-foo-bar)'); - expect( - cssVariable<'global', 'foo', 'bar', TestThemthem>( - 'global', - 'foo', - 'bar', - {}, - ), - ).toBe('var(--global-foo-bar)'); - expect( - cssVariable<'global', 'foo', 'bar', TestThemthem>( - 'global', - 'foo', - 'bar', - { bare: false }, - ), - ).toBe('var(--global-foo-bar)'); +describe('global', () => { + describe('globalCSSVariable', () => { + it('should return the variable usage formed from the path', () => { + expect(gVar('foo.bar')).toBe('var(--global-foo-bar)'); + expect(gVar('bar.baz')).toBe('var(--global-bar-baz)'); + expect(gVar('baz.test.foo')).toBe('var(--global-baz-test-foo)'); + expect(gVar('baz.test.foo', '22px')).toBe( + 'var(--global-baz-test-foo, 22px)', + ); + }); }); - it('should print the bare css variable when bare options is true', () => { - expect( - cssVariable<'global', 'foo', 'bar', TestThemthem>( - 'global', - 'foo', - 'bar', - { bare: true }, - ), - ).toBe('--global-foo-bar'); + describe('globalCSSVariableIdentifier', () => { + it('should return the variable usage formed from the path', () => { + expect(gIdentifier('foo.bar')).toBe('--global-foo-bar'); + expect(gIdentifier('baz.test.foo')).toBe( + '--global-baz-test-foo', + ); + }); + }); +}); + +describe('component', () => { + describe('componentCSSVariable', () => { + it('should return the variable usage formed from the path', () => { + expect(cVar('Foo.bar')).toBe('var(--component-Foo-bar)'); + expect(cVar('Bar.baz')).toBe('var(--component-Bar-baz)'); + expect(cVar('Baz.test.foo')).toBe( + 'var(--component-Baz-test-foo)', + ); + expect(cVar('Baz.foo.$mod1')).toBe( + 'var(--component-Baz-foo__mod1)', + ); + expect(cVar('Baz.foo')).toBe('var(--component-Baz-foo)'); + expect(cVar('Baz.test.foo', '10px')).toBe( + 'var(--component-Baz-test-foo, 10px)', + ); + }); + }); + + describe('componentCSSVariableIdentifier', () => { + it('should return the variable usage formed from the path', () => { + expect(cIdentifier('Foo.bar')).toBe('--component-Foo-bar'); + expect(cIdentifier('Baz.test.foo')).toBe( + '--component-Baz-test-foo', + ); + }); }); }); diff --git a/src/helpers.ts b/src/helpers.ts index 961e62e..ad8148b 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -1,57 +1,76 @@ -import { DTBoxType, DTBoxKey, DesignToken, Themthem } from './token-box'; +import type { Themthem, TokenPath } from './token-box'; -export type ThemthemVariable< - Type extends DTBoxType, - Key extends DTBoxKey & string, - Token extends DesignToken, - ThemeInterface extends Themthem = Themthem, -> = Type extends 'global' ? `--global-${Key}-${Token}` : `--${Key}-${Token}`; +export type Segments = Keys extends `${infer Key}.${infer RestKeys}` + ? RestKeys extends `$default` + ? Key + : RestKeys extends `$${infer R}` + ? `${Key}__${Segments}` + : `${Key}-${Segments}` + : Keys; + +export type ThemthemGlobalVariableName< + Box extends object, + Keys extends TokenPath, +> = `--global-${Segments}`; + +export type ThemthemComponentVariableName< + Box extends object, + Keys extends TokenPath, +> = `--component-${Segments}`; + +export type DefaultValuePart = DefaultValue extends + | '' + | undefined + ? '' + : `, ${DefaultValue}`; export type ThemthemVariableUsage< - Type extends DTBoxType, - Key extends DTBoxKey & string, - Token extends DesignToken, - ThemeInterface extends Themthem = Themthem, -> = `var(${ThemthemVariable})`; - -export function cssVariable< - Type extends DTBoxType, - Key extends DTBoxKey & string, - Token extends DesignToken, - ThemeInterface extends Themthem = Themthem, ->( - type: Type, - key: Key, - token: Token, - options: { bare: true }, -): ThemthemVariable; -export function cssVariable< - Type extends DTBoxType, - Key extends DTBoxKey & string, - Token extends DesignToken, - ThemeInterface extends Themthem = Themthem, + Variable extends string, + DefaultValue extends string | undefined, +> = `var(${Variable}${DefaultValuePart})`; + +function variabilize(path: Path): Segments { + return path.split('.').reduce((acc, s) => { + return s === '$default' ? acc : s.startsWith('$') ? `${acc}__${s.slice(1)}` : `${acc}-${s}`; + }) as Segments; +} + +export function gIdentifier< + TI extends Themthem = Themthem, + Path extends TokenPath = TokenPath, +>(path: Path): ThemthemGlobalVariableName { + return `--global-${variabilize(path)}`; +} + +export function gVar< + TI extends Themthem = Themthem, + Path extends TokenPath = TokenPath, + DefaultValue extends string = string, >( - type: Type, - key: Key, - token: Token, - options?: { bare?: false }, -): ThemthemVariableUsage; -export function cssVariable< - Type extends DTBoxType, - Key extends DTBoxKey & string, - Token extends DesignToken, - ThemeInterface extends Themthem = Themthem, + path: Path, + defaultValue?: DefaultValue, +): ThemthemVariableUsage, DefaultValue> { + return `var(${gIdentifier(path)}${ + (defaultValue ? `, ${defaultValue}` : '') as DefaultValuePart + })`; +} + +export function cIdentifier< + TI extends Themthem = Themthem, + Path extends TokenPath = TokenPath, +>(path: Path): ThemthemComponentVariableName { + return `--component-${variabilize(path)}`; +} + +export function cVar< + TI extends Themthem = Themthem, + Path extends TokenPath = TokenPath, + DefaultValue extends string = string, >( - type: Type, - key: Key, - token: Token, - { bare = false }: { bare?: boolean } = {}, -): - | ThemthemVariable - | ThemthemVariableUsage { - const variable = ( - type === 'global' ? `--global-${key}-${token}` : `--${key}-${token}` - ) as ThemthemVariable; - - return bare ? variable : `var(${variable})`; + path: Path, + defaultValue?: DefaultValue, +): ThemthemVariableUsage, DefaultValue> { + return `var(${cIdentifier(path)}${ + (defaultValue ? `, ${defaultValue}` : '') as DefaultValuePart + })`; } diff --git a/src/index.ts b/src/index.ts index 8f76fd6..b11018e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,3 @@ export * from './helpers'; -export * from './css-variable-generator'; +export * from './css-variables-generator'; export * from './token-box'; diff --git a/src/token-box.ts b/src/token-box.ts index 24dcf22..b898718 100644 --- a/src/token-box.ts +++ b/src/token-box.ts @@ -5,29 +5,41 @@ export interface Themthem { component: ComponentDesignTokenBox; } -export type DTBoxType = - keyof ThemeInterface; +export type SimpleBox = Omit; -export type DTBox< - Type extends DTBoxType, - ThemeInterface extends Themthem = Themthem, -> = ThemeInterface[Type]; +export type TokenPath = + | ('$values' extends keyof Box + ? Box['$values'] extends string[] + ? Box['$values'][number] + : never + : never) + | ('$modifiers' extends keyof Box + ? Box['$modifiers'] extends string[] + ? `$${Box['$modifiers'][number]}` + : never + : never) + | (Box extends object + ? { + [Key in keyof SimpleBox]: Key extends string + ? SimpleBox[Key] extends object + ? TokenPath[Key]> extends `$${infer Modifier}` + ? Key | `${Key}.$${Modifier}` + : `${Key}.${TokenPath[Key]>}` + : never + : never; + }[keyof SimpleBox] + : never); -export type DTBoxKey< - Type extends DTBoxType, - ThemeInterface extends Themthem = Themthem, -> = keyof DTBox; +type Prepend = [Prefix] extends [never] + ? `${Path}` + : `${Prefix}.${Path}`; -export type DesignTokens< - Type extends DTBoxType, - Key extends DTBoxKey, - ThemeInterface extends Themthem = Themthem, -> = DTBox[Key]; - -export type DesignToken< - Type extends DTBoxType, - Key extends DTBoxKey, - ThemeInterface extends Themthem = Themthem, -> = DesignTokens extends string[] - ? DesignTokens[number] - : never; +export type PartialTokenPath = Box extends any[] + ? Prefix + : + | Prefix + | { + [Key in keyof Omit & string]: Box[Key] extends object + ? PartialTokenPath> + : Prepend; + }[keyof Omit & string]; diff --git a/src/type-tests/PartialTokenPath.ts b/src/type-tests/PartialTokenPath.ts new file mode 100644 index 0000000..7484335 --- /dev/null +++ b/src/type-tests/PartialTokenPath.ts @@ -0,0 +1,22 @@ +import { Expect, Equal } from './utils'; +import { PartialTokenPath } from '../token-box'; + +type interface1 = { test: { $values: ['a'] } }; +type interface2 = { test: { foo: { $values: ['bar', 'baz'] } } }; +type interface3 = { test: { foo: { $values: ['bar'] } }; test2: { bar: { $values: ['baz'] } } }; +type interface4 = { test: { foo: { $modifiers: ['mod1', 'mod2'] } } }; +type interface5 = { test: { foo: { $modifiers: ['mod1', 'mod2'] }; bar: { $values: ['baz'] } } }; + +type expected1 = 'test'; +type expected2 = 'test' | 'test.foo'; +type expected3 = 'test' | 'test.foo' | 'test2' | 'test2.bar'; +type expected4 = 'test' | 'test.foo'; +type expected5 = 'test' | 'test.foo' | 'test.bar'; + +export type cases = [ + Expect, expected1>>, + Expect, expected2>>, + Expect, expected3>>, + Expect, expected4>>, + Expect, expected5>>, +]; diff --git a/src/type-tests/TokenPath.ts b/src/type-tests/TokenPath.ts new file mode 100644 index 0000000..b9cf688 --- /dev/null +++ b/src/type-tests/TokenPath.ts @@ -0,0 +1,22 @@ +import { Expect, Equal } from './utils'; +import { TokenPath } from '../token-box'; + +type interface1 = { test: { $values: ['a'] } }; +type interface2 = { test: { foo: { $values: ['bar', 'baz'] } } }; +type interface3 = { test: { foo: { $values: ['bar'] } }; test2: { bar: { $values: ['baz'] } } }; +type interface4 = { test: { foo: { $modifiers: ['mod1', 'mod2'] } } }; +type interface5 = { test: { foo: { $modifiers: ['mod1', 'mod2'] }; bar: { $values: ['baz'] } } }; + +type expected1 = 'test.a'; +type expected2 = 'test.foo.bar' | 'test.foo.baz'; +type expected3 = 'test.foo.bar' | 'test2.bar.baz'; +type expected4 = 'test.foo' | 'test.foo.$mod1' | 'test.foo.$mod2'; +type expected5 = 'test.foo' | 'test.foo.$mod1' | 'test.foo.$mod2' | 'test.bar.baz'; + +export type cases = [ + Expect, expected1>>, + Expect, expected2>>, + Expect, expected3>>, + Expect, expected4>>, + Expect, expected5>>, +]; diff --git a/src/type-tests/utils.ts b/src/type-tests/utils.ts new file mode 100644 index 0000000..28564f5 --- /dev/null +++ b/src/type-tests/utils.ts @@ -0,0 +1,10 @@ +export type Expect = T; +export type ExpectTrue = T; +export type ExpectFalse = T; +export type IsTrue = T; +export type IsFalse = T; + +export type NotEqual = true extends Equal ? false : true; +export type Equal = (() => T extends X ? 1 : 2) extends () => T extends Y ? 1 : 2 + ? true + : false; diff --git a/src/utils.ts b/src/utils.ts index c7b0795..157b264 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,3 +1,3 @@ -export function getKeys(o: O): (keyof O)[] { +export function getKeys(o: O): (keyof O)[] { return Object.keys(o) as (keyof O)[]; } diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..f312220 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "baseUrl": "./src", + "outDir": "./dist", + "target": "ESNext", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ESNext", "DOM"], + "moduleResolution": "Node", + "strict": true, + "sourceMap": true, + "resolveJsonModule": true, + "isolatedModules": true, + "esModuleInterop": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "skipLibCheck": true, + "declaration": true + }, + "include": ["src"], + "exclude": ["src/*.test.ts", "src/type-tests/*"] +} diff --git a/vite.config.ts b/vite.config.ts index b80c85b..9f2775a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,13 +1,26 @@ /// +import { resolve } from 'path'; import { defineConfig } from 'vite'; export default defineConfig({ build: { lib: { - entry: './src/index.ts', - name: 'Themthem', - fileName: (format) => `themthem.${format}.js`, + entry: resolve(__dirname, 'src/index.ts'), + formats: ['cjs', 'es'], + }, + rollupOptions: { + input: { + themthem: resolve(__dirname, 'src/index.ts'), + component: resolve(__dirname, 'src/component.ts'), + global: resolve(__dirname, 'src/global.ts'), + }, + output: { + preserveModules: true, + entryFileNames: ({ name: filename }) => { + return `${filename}.[format].js`; + }, + }, }, }, });