diff --git a/.changeset/clear-buckets-shout.md b/.changeset/clear-buckets-shout.md new file mode 100644 index 0000000000..6b5058ede0 --- /dev/null +++ b/.changeset/clear-buckets-shout.md @@ -0,0 +1,5 @@ +--- +"@digdir/designsystemet": patch +--- + +**Deprecated:** Commands `token create` and `token build`. Use `designsystemet` with new `output` field in `designsystemet.config.json` to configure outputs. diff --git a/.changeset/honest-pans-lose.md b/.changeset/honest-pans-lose.md new file mode 100644 index 0000000000..c9f2731fd0 --- /dev/null +++ b/.changeset/honest-pans-lose.md @@ -0,0 +1,5 @@ +--- +"@digdir/designsystemet": minor +--- + +**CLI:** New `output` field for defining outputs in config diff --git a/.changeset/solid-turkeys-buy.md b/.changeset/solid-turkeys-buy.md new file mode 100644 index 0000000000..0b133a1755 --- /dev/null +++ b/.changeset/solid-turkeys-buy.md @@ -0,0 +1,5 @@ +--- +"@digdir/designsystemet": patch +--- + +**Deprecated:** `outDir` and `clean` are deprecated in the Config schema, and replaced by `output[].dir` and `output[].cleanDir` for the respective output type. diff --git a/.changeset/sour-turtles-begin.md b/.changeset/sour-turtles-begin.md new file mode 100644 index 0000000000..e279944702 --- /dev/null +++ b/.changeset/sour-turtles-begin.md @@ -0,0 +1,5 @@ +--- +"@digdir/designsystemet": minor +--- + +**CLI:** Each output in the new `output` field cleans its `dir` before generating files. Set `cleanDir` to `false` on an output to keep existing files. diff --git a/apps/themebuilder/app/_components/token-modal/use-token-modal.ts b/apps/themebuilder/app/_components/token-modal/use-token-modal.ts index 5d679b8529..6e7640c8ff 100644 --- a/apps/themebuilder/app/_components/token-modal/use-token-modal.ts +++ b/apps/themebuilder/app/_components/token-modal/use-token-modal.ts @@ -1,6 +1,7 @@ -import type { - CssColor, - ExternalConfigSchemaInput, +import { + type CssColor, + defaultBorderRadius, + type ExternalConfigSchemaInput, } from '@digdir/designsystemet/internal'; import pkg from '@digdir/designsystemet/package.json'; import { useState } from 'react'; @@ -35,7 +36,7 @@ export const useTokenModal = () => { } }); - const theme: ExternalConfigSchemaInput['themes'][string] = { + const theme: NonNullable[string] = { colors: colors.reduce( (acc, color) => { acc[color.name] = color.colors.light['base-default']?.hex || '#'; @@ -43,19 +44,17 @@ export const useTokenModal = () => { }, {} as Record, ), - borderRadius: baseBorderRadius, - typography: { - fontFamily: 'Inter', - }, + ...(baseBorderRadius !== defaultBorderRadius && { + borderRadius: baseBorderRadius, + }), }; const packageWithTag = `@digdir/designsystemet${isProduction ? '@latest' : '@next'}`; - const configBuildSnippet = `npx ${packageWithTag} tokens create --config designsystemet.config.json\nnpx ${packageWithTag} tokens build --config designsystemet.config.json`; + const configBuildSnippet = `npx ${packageWithTag}`; const configSnippet = { $schema: `https://designsystemet.no/schemas/config/${pkg.version}.json`, - outDir: './design-tokens', themes: { [name]: { colors: theme.colors, @@ -72,7 +71,9 @@ export const useTokenModal = () => { }, } : {}), - borderRadius: theme.borderRadius, + ...(theme.borderRadius !== undefined && { + borderRadius: theme.borderRadius, + }), }, }, }; diff --git a/apps/themebuilder/app/_utils/config-to-url.ts b/apps/themebuilder/app/_utils/config-to-url.ts index 708ad75de3..201edf0bdb 100644 --- a/apps/themebuilder/app/_utils/config-to-url.ts +++ b/apps/themebuilder/app/_utils/config-to-url.ts @@ -6,7 +6,7 @@ const QUERY_SEPARATOR = ' '; * Converts a theme config object to a themebuilder URL with query parameters */ export function configThemeToUrl( - theme: ConfigSchema['themes']['default'], + theme: NonNullable[string], lang = 'no', ): string { const params = new URLSearchParams(); diff --git a/apps/www/app/content/fundamentals/en/code/cli-config.mdx b/apps/www/app/content/fundamentals/en/code/cli-config.mdx index 2a859f97fe..5842183b7f 100644 --- a/apps/www/app/content/fundamentals/en/code/cli-config.mdx +++ b/apps/www/app/content/fundamentals/en/code/cli-config.mdx @@ -21,6 +21,7 @@ The config file can be named whatever you want, but if you use `designsystemet.c You can place it anywhere in your project. If you've placed your file in a different location or with a different name, you can use `--config ` in CLI commands. +Both `designsystemet.config.json` and `designsystemet.config.jsonc` are detected automatically, and both may contain comments and trailing commas. ```json { @@ -41,14 +42,104 @@ If you've placed your file in a different location or with a different name, you Above, we have defined a theme called `my-theme` with some colours and border radius. Each colour is defined directly under `colors`, where the key is the colour's name and the value is a hex code. +## Running the CLI + +Run the CLI in the same folder as your config file: + +```bash +npx @digdir/designsystemet@latest +``` + +This creates design tokens and CSS for all themes, based on the [`output`](#output) field in the config file. + +| Option | Description | +| ------ | ----------- | +| `-c, --config ` | Path to the config file. | +| `--dry` | Dry run, no files will be written. | +| `--verbose` | Enable verbose output. | +| `--skip-check` | Skip the automatic migration check of the config file. | +| `-y, --yes` | Skip prompts, for example when migrating the config file. | + + +The `tokens create` and `tokens build` commands are deprecated and will be removed in a future release. Use `designsystemet` with a config file instead. + + ## Structure | Name | Type | Required | Description | | ---- | ---- | ------- | ----------- | | $schema | String | No | Path to JSON schema for validation. Recommended: `node_modules/@digdir/designsystemet/dist/config.schema.json`. | -| themes | Object | Yes | Contains all themes you want to define. Each key is the name of the theme. | -| outDir | String | Yes | The folder where design tokens should be saved. | -| clean | Boolean | No | Delete the output directory before creating tokens. Useful for removing deprecated files. | +| output | Array | No | What the CLI should create, and where. Defaults to `["design-tokens", "css"]`. See [Output](#output). | +| themes | Object | No | Contains all themes you want to define. Each key is the name of the theme. Required by outputs that are created from themes, see [Building without themes](#building-without-themes). | +| outDir | String | No | **Deprecated:** use `output[].dir` instead. | +| clean | Boolean | No | **Deprecated:** use `output[].cleanDir` instead. | + +### Output + +`output` is a list of what the CLI should create. Each item is either the name of an output type, which uses its default settings, or an object with custom settings. +Outputs are always created in the order `design-tokens`, `css`, whatever order they are listed in. When CSS is built from design tokens (with `tokensDir`), the design tokens must be created first. + +All paths are relative to the config file. + +```json +{ + "output": [ + { "type": "design-tokens", "dir": "./design-tokens" }, + { "type": "css", "dir": "./css", "tokensDir": "./design-tokens" } + ] +} +``` + +#### design-tokens + +| Name | Type | Required | Default | Description | +| ---- | ---- | ------- | ------- | ----------- | +| type | `"design-tokens"` | Yes | | The output type. | +| dir | String | No | `design-tokens` | The folder where design tokens should be saved. | +| cleanDir | Boolean | No | `true` | Delete the folder before creating design tokens. Removes files that are no longer in use. | + +#### css + +| Name | Type | Required | Default | Description | +| ---- | ---- | ------- | ------- | ----------- | +| type | `"css"` | Yes | | The output type. | +| dir | String | No | `design-tokens-build` | The folder where CSS should be saved. | +| cleanDir | Boolean | No | `true` | Delete the folder before creating CSS. | +| tokensDir | String | No | `dir` of the `design-tokens` output | The folder containing the design tokens to build CSS from. If neither is set, CSS is created directly from the themes. | + +#### Without design tokens + +If you only need CSS, you can use for example `"output": ["css"]` without `tokensDir`. It is then created directly from the themes, without saving any design tokens. + +#### Building without themes + +`themes` is only needed by outputs that are created from themes. If you already have design tokens, you can leave out `themes` and build from them by setting `tokensDir`: + +```json +{ + "output": [ + { "type": "css", "tokensDir": "./design-tokens" } + ] +} +``` + +If an output needs themes and there are none, the CLI stops with an error. + +### Migrating from outDir and clean + +`outDir` and `clean` are replaced by `output`. When you run the CLI with a config file that uses them, it offers to migrate the file for you. +If you'd rather do it manually, replace `"outDir": ""` with: + +```json +{ + "output": [ + { "type": "design-tokens", "dir": "" }, + { "type": "css", "tokensDir": "" } + ] +} +``` + +`clean` can be removed, since `cleanDir` is `true` by default. If you had `"clean": false`, add `"cleanDir": false` to each output instead, so the folders are not deleted. ### Themes @@ -170,7 +261,10 @@ The `severity` override allows you to customise the colours used for severity, w ```json { "$schema": "node_modules/@digdir/designsystemet/dist/config.schema.json", - "outDir": "./design-tokens", + "output": [ + { "type": "design-tokens", "dir": "./design-tokens" }, + { "type": "css", "dir": "./design-tokens-build", "tokensDir": "./design-tokens" } + ], "themes": { "my-theme": { "colors": { diff --git a/apps/www/app/content/fundamentals/en/start-here/own-theme.mdx b/apps/www/app/content/fundamentals/en/start-here/own-theme.mdx index 9a05ea88d2..a502478975 100644 --- a/apps/www/app/content/fundamentals/en/start-here/own-theme.mdx +++ b/apps/www/app/content/fundamentals/en/start-here/own-theme.mdx @@ -23,8 +23,6 @@ We recommend that both a developer and a designer take part in the process when ```json { - "outDir": "./design-tokens", - "clean": true, "themes": { "theme": { "colors": { @@ -45,9 +43,9 @@ We recommend that both a developer and a designer take part in the process when 5. Run the following command in the terminal: ```bash -npx @digdir/designsystemet@latest tokens create --config designsystemet.config.json +npx @digdir/designsystemet@latest ``` -This will generate Design Tokens based on the configuration file and save them in a folder called `design-tokens`. Push the changes to your repository. +This will generate Design Tokens based on the configuration file and save them in a folder called `design-tokens`, and CSS in a folder called `design-tokens-build`. You can change the folders with the [`output`](/en/fundamentals/code/cli-config#output) field in the configuration file. Push the changes to your repository. 6. Fetch the component library from [Figma Community (figma.com)](https://www.figma.com/community/file/1322138390374166141/designsystemet-core-ui-kit) (Click “Open in Figma”) into your organisation that has at least a Pro licence. Note that this will be a copy of the component library without any connection to the main file. @@ -70,7 +68,7 @@ We recommend *not* pushing changes to Design Tokens from Tokens Studio, as this ```bash -npx @digdir/designsystemet@latest tokens create --config designsystemet.config.json +npx @digdir/designsystemet@latest --config designsystemet.config.json ``` **Remember to run this command every time you make changes to the configuration file.** @@ -116,8 +114,6 @@ If you want additional themes, you can generate a new theme in the Theme Builder ```json { - "outDir": "./design-tokens", - "clean": true, "themes": { "theme-one": { "colors": { @@ -144,13 +140,7 @@ When the Design Tokens are updated in code, you can easily fetch the new values -10. To generate CSS from your theme, run the following command: - -```bash -npx @digdir/designsystemet@latest tokens build --config designsystemet.config.json -``` - -Remember that this must also be done whenever the tokens are updated. +10. The same command also generates CSS from your theme, in the `design-tokens-build` folder. This CSS is what you use in code. ### Icons in Figma To get icons working in Figma, they need to be connected to a library stored in your organisation. See the guide ["Get icons working in Figma"](/en/fundamentals/theme/icons#get-icons-working-in-figma). diff --git a/apps/www/app/content/fundamentals/en/theme/multiple-themes.mdx b/apps/www/app/content/fundamentals/en/theme/multiple-themes.mdx index 542227d0f8..9e34806260 100644 --- a/apps/www/app/content/fundamentals/en/theme/multiple-themes.mdx +++ b/apps/www/app/content/fundamentals/en/theme/multiple-themes.mdx @@ -57,7 +57,10 @@ It is common to have to do it this way if you have a repository that collects al ```json { - "outDir": "./some-org-dt", + "output": [ + { "type": "design-tokens", "dir": "./some-org-dt" }, + { "type": "css", "dir": "./some-org-css", "tokensDir": "./some-org-dt" } + ], "themes": { "some-org": { "colors": { @@ -73,7 +76,10 @@ It is common to have to do it this way if you have a repository that collects al ```json { - "outDir": "./other-org-dt", + "output": [ + { "type": "design-tokens", "dir": "./other-org-dt" }, + { "type": "css", "dir": "./other-org-css", "tokensDir": "./other-org-dt" } + ], "themes": { "other-org": { "colors": { @@ -88,5 +94,5 @@ It is common to have to do it this way if you have a repository that collects al ``` -Note that we have different `outDir`s in these two config files, so that the design tokens for each theme are placed in their own folder. +Note that we have different `output` folders in these two config files, so that the design tokens and CSS for each theme are placed in their own folders. diff --git a/apps/www/app/content/fundamentals/no/code/cli-config.mdx b/apps/www/app/content/fundamentals/no/code/cli-config.mdx index 337fc7e082..bd7de15b84 100644 --- a/apps/www/app/content/fundamentals/no/code/cli-config.mdx +++ b/apps/www/app/content/fundamentals/no/code/cli-config.mdx @@ -22,6 +22,7 @@ Config fila kan heite det du vil, men bruker du `designsystemet.config.json` vil Du kan legge den kor du vil i prosjektet ditt. Har du plassert fila di ein anna plass, eller med eit anna navn, kan du bruke `--config ` i CLI-kommandoar. +Både `designsystemet.config.json` og `designsystemet.config.jsonc` blir funne automatisk, og begge kan innehalde kommentarar og avsluttande komma. ```json { @@ -42,14 +43,105 @@ Har du plassert fila di ein anna plass, eller med eit anna navn, kan du bruke `- Over har me definert eit tema som heiter `my-theme` med nokre fargar og border radius. Kvar farge er definert direkte under `colors`, der nøkkelen er namnet på fargen og verdien er ein hexkode. +## Køyre CLI + +Køyr CLI-et i same mappe som config fila: + +```bash +npx @digdir/designsystemet@latest +``` + +Dette lagar design tokens og CSS for alle tema, basert på [`output`](#output)-feltet i config fila. + +| Val | Forklaring | +| --- | ----------- | +| `-c, --config ` | Sti til config fila. | +| `--dry` | Prøvekøyring, ingen filer blir skrivne. | +| `--verbose` | Vis meir utfyllande logg. | +| `--skip-check` | Hopp over automatisk migreringssjekk av config fila. | +| `-y, --yes` | Hopp over spørsmål, til dømes ved migrering av config fila. | + + +Kommandoane `tokens create` og `tokens build` er utdaterte og blir fjerna i ein framtidig versjon. Bruk `designsystemet` med ei config fil i staden. + + ## Struktur | Namn | Type | Påkrevd | Forklaring | | ---- | ---- | ------- | ----------- | | $schema | Streng | Nei | Sti til JSON schema for validering. Anbefalt: `node_modules/@digdir/designsystemet/dist/config.schema.json`. | -| themes | Objekt | Ja | Inneheld alle tema du vil definere. Ny nøkkel er namnet på temaet. | -| outDir | Streng | Ja | Mappa der design tokens skal lagrast. | -| clean | Boolean | Nei | Slett utdata-mappa før du lagar tokens. Nyttig for å fjerne utdaterte filer. | +| output | Liste | Nei | Kva CLI-et skal lage, og kvar. Standard er `["design-tokens", "css"]`. Sjå [Output](#output). | +| themes | Objekt | Nei | Inneheld alle tema du vil definere. Kvar nøkkel er namnet på temaet. Påkrevd for outputar som blir laga frå tema, sjå [Bygge utan tema](#bygge-utan-tema). | +| outDir | Streng | Nei | **Utdatert:** bruk `output[].dir` i staden. | +| clean | Boolean | Nei | **Utdatert:** bruk `output[].cleanDir` i staden. | + +### Output + +`output` er ei liste over kva CLI-et skal lage. Kvart element er anten namnet på ein output-type, som brukar standardinnstillingane, eller eit objekt med eigne innstillingar. + +Outputane blir alltid laga i rekkjefølgja `design-tokens`, `css`, same kva rekkjefølgje dei står i. Når CSS-en blir bygd frå design tokens (med `tokensDir`), må design tokens vere laga først. + +Alle stiar er relative til config fila. + +```json +{ + "output": [ + { "type": "design-tokens", "dir": "./design-tokens" }, + { "type": "css", "dir": "./css", "tokensDir": "./design-tokens" } + ] +} +``` + +#### design-tokens + +| Namn | Type | Påkrevd | Standard | Forklaring | +| ---- | ---- | ------- | -------- | ----------- | +| type | `"design-tokens"` | Ja | | Output-typen. | +| dir | Streng | Nei | `design-tokens` | Mappa der design tokens skal lagrast. | +| cleanDir | Boolean | Nei | `true` | Slett mappa før design tokens blir laga. Fjernar filer som ikkje lenger er i bruk. | + +#### css + +| Namn | Type | Påkrevd | Standard | Forklaring | +| ---- | ---- | ------- | -------- | ----------- | +| type | `"css"` | Ja | | Output-typen. | +| dir | Streng | Nei | `design-tokens-build` | Mappa der CSS skal lagrast. | +| cleanDir | Boolean | Nei | `true` | Slett mappa før CSS blir laga. | +| tokensDir | Streng | Nei | `dir` i `design-tokens`-outputen | Mappa med design tokens som CSS skal byggast frå. Er ingen av dei sette, blir CSS-en laga direkte frå tema. | + +#### Utan design tokens + +Treng du berre CSS, kan du bruke til dømes `"output": ["css"]` utan `tokensDir`. CSS-en blir då laga direkte frå tema, utan at design tokens blir lagra. + +#### Bygge utan tema + +`themes` trengst berre for outputar som blir laga frå tema. Har du alt design tokens, kan du sløyfe `themes` og byggje frå dei ved å setje `tokensDir`: + +```json +{ + "output": [ + { "type": "css", "tokensDir": "./design-tokens" } + ] +} +``` + +Treng ein output tema og det ikkje finst nokon, stoppar CLI-et med ein feil. + +### Migrere frå outDir og clean + +`outDir` og `clean` er erstatta av `output`. Når du køyrer CLI-et med ei config fil som brukar dei, tilbyr det å migrere fila for deg. +Vil du heller gjere det manuelt, byt ut `"outDir": ""` med: + +```json +{ + "output": [ + { "type": "design-tokens", "dir": "" }, + { "type": "css", "tokensDir": "" } + ] +} +``` + +`clean` kan fjernast, sidan `cleanDir` er `true` som standard. Hadde du `"clean": false`, legg du i staden til `"cleanDir": false` på kvar output, slik at mappene ikkje blir sletta. ### Themes @@ -161,7 +253,10 @@ Overstyringen av `severity` lar deg tilpasse fargane som blir brukt for severity ```json { "$schema": "node_modules/@digdir/designsystemet/dist/config.schema.json", - "outDir": "./design-tokens", + "output": [ + { "type": "design-tokens", "dir": "./design-tokens" }, + { "type": "css", "dir": "./design-tokens-build", "tokensDir": "./design-tokens" } + ], "themes": { "my-theme": { "colors": { diff --git a/apps/www/app/content/fundamentals/no/start-here/own-theme.mdx b/apps/www/app/content/fundamentals/no/start-here/own-theme.mdx index 69bec274a1..9d9a551f79 100644 --- a/apps/www/app/content/fundamentals/no/start-here/own-theme.mdx +++ b/apps/www/app/content/fundamentals/no/start-here/own-theme.mdx @@ -25,8 +25,6 @@ Config filen må være lagret som en `json` fil, vi anbfaler f.eks `designsystem ```json { - "outDir": "./design-tokens", - "clean": true, "themes": { "theme": { "colors": { @@ -48,9 +46,9 @@ Config filen må være lagret som en `json` fil, vi anbfaler f.eks `designsystem 5. Kjør denne kommandoen i terminalen: ```bash -npx @digdir/designsystemet@latest tokens create --config designsystemet.config.json +npx @digdir/designsystemet@latest ``` -Dette vil generere Design Tokens basert på config-fila og lagre dem i en mappe som heter `design-tokens`. Push endringene til ditt repo. +Dette vil generere Design Tokens basert på config-fila og lagre dem i en mappe som heter `design-tokens`, og CSS i en mappe som heter `design-tokens-build`. Du kan endre mappene med [`output`](/no/fundamentals/code/cli-config#output)-feltet i config-fila. Push endringene til ditt repo. 6. Hent komponentbiblioteket fra [Figma Community (figma.com)](https://www.figma.com/community/file/1322138390374166141/designsystemet-core-ui-kit) (Trykk "Open in Figma") til din organisasjon som har mimimum pro-lisens. Merk at dette blir en kopi av komponentbiblioteket uten noen kobling mot hovedfilen. @@ -72,7 +70,7 @@ Vi anbefaler å *ikke* pushe endringer i Design Tokens via Tokens Studio, da det 8. Gå til [Temabyggeren](https://theme.designsystemet.no) og generer fargeskalaer ut fra dine profilfarger. Klikk "Ta i bruk tema". Kopier config-filen og lim inn i `designsystemet.config.json`-fila i ditt repo. Kjør kommandoen under og push endringene. ```bash -npx @digdir/designsystemet@latest tokens create --config designsystemet.config.json +npx @digdir/designsystemet@latest --config designsystemet.config.json ``` **Husk at du må kjøre denne kommandoen hver gang du gjør endringer i config-filen.** @@ -117,8 +115,6 @@ Nå skal du se alle komponentene med dine egne profilfarger i Figma. ```json { - "outDir": "./design-tokens", - "clean": true, "themes": { "theme-one": { "colors": { @@ -144,13 +140,7 @@ Nå skal du se alle komponentene med dine egne profilfarger i Figma. Når Design Tokens oppdateres i koden, kan du enkelt hente de nye verdiene inn i Figma ved å bruke "Pull"-knappen i Tokens Studio igjen. -10. For å generere CSS fra temaet ditt, kjør denne kommandoen: - -```bash -npx @digdir/designsystemet@latest tokens build --config designsystemet.config.json -``` - -Husk at dette må gjøres når tokens blir oppdatert også. +10. Den samme kommandoen genererer også CSS fra temaet ditt, i mappen `design-tokens-build`. Det er denne CSS-en du bruker i koden. ### Ikoner i Figma For å få ikoner til å fungere i Figma, må de kobles til et bibliotek som er lagret på deres organisasjon. Se guiden ["Få ikoner til å fungere i Figma"](/no/fundamentals/theme/icons#få-ikoner-til-å-fungere-i-figma). \ No newline at end of file diff --git a/apps/www/app/content/fundamentals/no/theme/multiple-themes.mdx b/apps/www/app/content/fundamentals/no/theme/multiple-themes.mdx index 639bd5e241..806c0e3038 100644 --- a/apps/www/app/content/fundamentals/no/theme/multiple-themes.mdx +++ b/apps/www/app/content/fundamentals/no/theme/multiple-themes.mdx @@ -58,7 +58,10 @@ Det er vanleg å måtte gjere det på denne måten dersom du har eit repository ```json { - "outDir": "./some-org-dt", + "output": [ + { "type": "design-tokens", "dir": "./some-org-dt" }, + { "type": "css", "dir": "./some-org-css", "tokensDir": "./some-org-dt" } + ], "themes": { "some-org": { "colors": { @@ -74,7 +77,10 @@ Det er vanleg å måtte gjere det på denne måten dersom du har eit repository ```json { - "outDir": "./other-org-dt", + "output": [ + { "type": "design-tokens", "dir": "./other-org-dt" }, + { "type": "css", "dir": "./other-org-css", "tokensDir": "./other-org-dt" } + ], "themes": { "other-org": { "colors": { @@ -89,5 +95,5 @@ Det er vanleg å måtte gjere det på denne måten dersom du har eit repository ``` -Legg merke til at me har ulik `outDir` i desse to config filene, slik at design tokens for kvart tema blir lagt i kvar si mappe. +Legg merke til at me har ulike `output`-mapper i desse to config filene, slik at design tokens og CSS for kvart tema blir lagt i kvar sine mapper. diff --git a/designsystemet.config.json b/designsystemet.config.json index 36950e80d8..af06a9830b 100644 --- a/designsystemet.config.json +++ b/designsystemet.config.json @@ -1,5 +1,5 @@ { - "$schema": "packages/cli/dist/config-full.schema.json", + "$schema": "packages/cli/dist/config.schema.json", "output": [{ "type": "css", "dir": "./packages/css/theme" }, "design-tokens"], "themes": { "designsystemet": { diff --git a/packages/cli/README.md b/packages/cli/README.md index b9eae5f2e6..a5364a7bc4 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -9,55 +9,49 @@ Read the Designsystemet [README](https://github.com/digdir/designsystemet) to ge ## Usage -### Create tokens - -Use `npx @digdir/designsystemet tokens create ` to create design tokens for use with Designsystemet. +Use `npx @digdir/designsystemet` to create design tokens and CSS for use with Designsystemet, based on a [config file](#using-a-config-file). This allows you to define themes including custom colors, font-family, and border-radius. We recommend using the [Designsystemet theme builder](https://theme.designsystemet.no/) for generating a valid config. -#### Update tokens +| Option | Description | +| ------ | ----------- | +| `-c, --config ` | Path to config file (auto-detects `designsystemet.config.json` or `designsystemet.config.jsonc`) | +| `--dry` | Dry run - no files will be written | +| `--verbose` | Enable verbose output | +| `--skip-check` | Skip migration check | +| `-y, --yes` | Skip user prompts | + +> ⚠️ **DEPRECATED** ⚠️ +> The `tokens create` and `tokens build` commands are deprecated and will be removed in a future release. +> Use `designsystemet` with a config file instead. -Whenever a new version of the CLI is released, or you have done changes, we recommend to update design tokens with the `--clean` option to potentially remove any changes deprecated files or unneeded files. +#### Update tokens and CSS -To update design tokens, re-run `npx @digdir/designsystemet tokens create --clean`. -If a [config file](#using-a-config-file) you can also re-run with `"clean": true`. +Whenever a new version of the CLI is released, or you have done changes, re-run `npx @digdir/designsystemet`. +Output directories are cleaned by default (`cleanDir`), which removes any deprecated or unneeded files. > ⚠️ **WARNING** ⚠️ > The design tokens created by this tool are considered an implementation detail, and is subject > to change at any time without being considered a breaking change. We **only** support customisations -> done through the CLI options and config. Direct editing of the design tokens are **not** supported. -> -> Since tokens may be added or removed at any time, it is necessary to routinely re-run this -> command when upgrading the libraries. This will remove any direct edits to the design tokens. - -### Build CSS from tokens - -Use `npx @digdir/designsystemet tokens build ` to build CSS from design tokens generated in the previous step. - -> ⚠️ **WARNING** ⚠️ -> The CSS files from created by this tool are considered build artifacts. They should **not** be +> done through the config. Direct editing of the design tokens are **not** supported. +> +> The CSS files created by this tool are considered build artifacts. They should **not** be > edited directly. While the CSS will not change unexpectedly, new variables may be added at any -> time. Therefore, it is necessary to routinely re-run this command when upgrading the libraries. -> This will remove any direct edits to the CSS. - -#### Update built CSS - -Whenever a new version of the CLI is released, or you have done changes, we recommend to build a new set of CSS from design tokens with the `--clean` option to potentially remove any changes deprecated files or unneeded files. - +> time. +> +> Therefore, it is necessary to routinely re-run this command when upgrading the libraries. +> This will remove any direct edits to the design tokens and CSS. ### Using a config file > ⚠️ **WARNING** ⚠️ > The typography feature is experimental. The config schema may change at any time. - -The `tokens create` command supports a config file. It will auto-detect a `designsystemet.config.json` or `designsystemet.config.jsonc` file in the current directory. You can also use the `--config ` option to supply a different config name and location. +The CLI will auto-detect a `designsystemet.config.json` or `designsystemet.config.jsonc` file in the current directory. You can also use the `--config ` option to supply a different config name and location. Both `.json` and `.jsonc` files may contain comments and trailing commas (JSONC). -The main advantage of using a config file is for automation in scenarios with multiple themes. - To get started, use this template for a `designsystemet.config.json` file: ```jsonc @@ -72,7 +66,6 @@ In editors which support JSON Schema, the `$schema` will then give you editor h ```jsonc { "$schema": "./node_modules/@digdir/designsystemet/dist/config.schema.json", - "outDir": "../path/to/design-tokens", "themes": { "theme": { "colors": { @@ -84,13 +77,45 @@ In editors which support JSON Schema, the `$schema` will then give you editor h } } ``` -To generate new design tokens and CSS files, you would then run. +To generate new design tokens and CSS files, you would then run: ``` -npx @digdir/designsystemet tokens create -npx @digdir/designsystemet tokens build +npx @digdir/designsystemet ``` +This creates design tokens in `design-tokens` and CSS in `design-tokens-build`, relative to the config file. + +#### Output + +Use `output` to choose what is created and where. Each item is either an output type (`"design-tokens"` or `"css"`) using its default settings, or an object: + +```jsonc +{ + "output": [ + // defaults: dir "design-tokens", cleanDir true + { "type": "design-tokens", "dir": "../path/to/design-tokens" }, + // defaults: dir "design-tokens-build", tokensDir from the design-tokens output, cleanDir true + { "type": "css", "dir": "../path/to/css", "tokensDir": "../path/to/design-tokens" }, + ], +} +``` + +Design tokens are always created before CSS. If `tokensDir` is not set, CSS is built from the `dir` of the `design-tokens` output. + +If you only need CSS, use `"output": ["css"]` without `tokensDir`. The CSS is then created directly from the themes, without writing any design tokens. + +`themes` is only needed by outputs that are created from themes. To build CSS from existing design tokens, leave out `themes` and set `tokensDir`: + +```jsonc +{ + "output": [ + { "type": "css", "tokensDir": "./design-tokens" }, + ], +} +``` + +The `outDir` and `clean` fields are deprecated in favour of `output`. The CLI will offer to migrate your config file automatically. + #### Complex config example Have a look at the `*.config.json` files under the `packages/cli` in the Github repo for more complex examples. @@ -100,5 +125,7 @@ Have a look at the `*.config.json` files under the `packages/cli` in the Github You can get a minimal config file, meaning without overrides, generated from existing design tokens using the following command: ```sh -npx @digdir/designsystemet generate-config-from-tokens --dir +npx @digdir/designsystemet generate-config-from-tokens --dir --out ``` + +`--out` defaults to `designsystemet.config.json`. The generated config uses `output`, with the design tokens directory written relative to the config file. diff --git a/packages/cli/bin/config.ts b/packages/cli/bin/config.ts index a697e4b218..ee36d44ea2 100644 --- a/packages/cli/bin/config.ts +++ b/packages/cli/bin/config.ts @@ -4,15 +4,35 @@ import * as R from 'ramda'; import { parseConfig, validateConfig } from '../src/schemas/helpers.ts'; import { type ConfigSchema, + type ConfigSchemaThemes, configSchema, type ExternalConfigSchema, externalConfigSchema, } from '../src/schemas/schema.ts'; +import { warnDeprecatedFields } from '../src/schemas/schema-output.ts'; import { dsfs } from '../src/utils/filesystem.ts'; import { getCliOption, getDefaultCliOption, getSuppliedCliOption, type OptionGetter } from './options.ts'; export { deprecatedCLIOptions } from '../src/schemas/helpers.ts'; +/** Returns the themes of a validated config, or exits with an error if there are none. */ +export function requireThemes(config: Pick): ConfigSchemaThemes { + if (!config.themes) { + console.error( + pc.redBright( + `No themes found in config file. Add ${pc.blue('themes')}, or set ${pc.blue('tokensDir')} on the ${pc.blue('css')} output to build CSS from existing design tokens.`, + ), + ); + process.exit(1); + } + + return config.themes; +} + +// Default config files to auto-detect when no --config is supplied, in order of precedence. +export const DEFAULT_CONFIG_FILEPATHS = ['designsystemet.config.json', 'designsystemet.config.jsonc']; +export const DEFAULT_CONFIG_FILEPATH = DEFAULT_CONFIG_FILEPATHS[0]; + export async function readConfigFile(configFilePath: string, allowFileNotFound = true): Promise { let configFile: string; @@ -52,6 +72,7 @@ export async function parseValidateAndOptsConfig( try { configParsed = parseConfig(configFile); + warnDeprecatedFields(configParsed); } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred while parsing config file'; console.error(pc.redBright(`Failed parsing config file at ${pc.red(configFilePath)}`)); @@ -116,3 +137,24 @@ export async function parseValidateAndOptsConfig( return validatedConfig; } + +export async function getConfigFile(userConfigFilePath: string | undefined) { + if (!R.isNil(userConfigFilePath)) { + // A config path was supplied explicitly. It's allowed to not exist only if it's one of the defaults. + const allowFileNotFound = DEFAULT_CONFIG_FILEPATHS.includes(userConfigFilePath); + const configFile = await readConfigFile(userConfigFilePath, allowFileNotFound); + + return { configFile, configFilePath: userConfigFilePath }; + } + + // No config path supplied: auto-detect the default config files (.json, then .jsonc). + for (const configFilePath of DEFAULT_CONFIG_FILEPATHS) { + const configFile = await readConfigFile(configFilePath, true); + if (configFile) { + return { configFile, configFilePath }; + } + } + + // None found - return empty config using the canonical default path for messaging. + return { configFile: '', configFilePath: DEFAULT_CONFIG_FILEPATH }; +} diff --git a/packages/cli/bin/deprecated.ts b/packages/cli/bin/deprecated.ts new file mode 100644 index 0000000000..a32d1b64ba --- /dev/null +++ b/packages/cli/bin/deprecated.ts @@ -0,0 +1,195 @@ +import { createCommand } from '@commander-js/extra-typings'; +import pc from 'picocolors'; +import { checkAutomigrate } from '../src/automigrate.ts'; +import { convertToHex } from '../src/colors/index.ts'; +import type { CssColor } from '../src/colors/types.ts'; +import { automigrations } from '../src/migrations/index.ts'; +import type { ConfigSchemaThemes } from '../src/schemas/schema.ts'; +import { dsfs } from '../src/utils/filesystem.ts'; +import { + deprecatedCLIOptions as cliOptions, + getConfigFile, + parseValidateAndOptsConfig, + requireThemes, +} from './config.ts'; +import { configOption, dryOption, parseBoolean, verboseOption } from './options.ts'; + +export const DEFAULT_TOKENS_CREATE_DIR = './design-tokens'; +const DEFAULT_TOKENS_BUILD_DIR = './design-tokens-build'; +const DEFAULT_FONT = 'Inter'; +const DEFAULT_THEME_NAME = 'theme'; + +type TokenCommandDeps = { + createDesignTokens: (options: { themes: ConfigSchemaThemes; outDir: string; clean?: boolean }) => Promise; + buildCss: (options: { + tokensDir: string; + outDir: string; + clean?: boolean; + verbose?: boolean; + tailwind?: boolean; + }) => Promise; +}; + +/** + * @deprecated Use `designsystemet` with a config file instead. + */ +export function makeTokenCommands({ createDesignTokens, buildCss }: TokenCommandDeps) { + const tokenCmd = createCommand('tokens'); + + tokenCmd + .description(`[deprecated] use ${pc.blue('designsystemet')} with a config file instead`) + .hook('preAction', () => { + console.warn( + pc.yellow(`\n ⚠️ The ${pc.bold('tokens')} commands are deprecated and will be removed in a future release. + \n ⚠️ Please run ${pc.bold('designsystemet')} with a config file instead.`), + ); + }); + + tokenCmd + .command('build') + .description('[deprecated] Build Designsystemet tokens') + .option('-t, --tokens ', `Path to ${pc.blue('design-tokens')}`, DEFAULT_TOKENS_CREATE_DIR) + .option( + '-o, --out-dir ', + `Output directory for built ${pc.blue('design-tokens')}`, + DEFAULT_TOKENS_BUILD_DIR, + ) + .option(`--${cliOptions.clean} [boolean]`, 'Clean output directory before building tokens', parseBoolean, false) + .addOption(dryOption(`Dry run for built ${pc.blue('design-tokens')}`)) + .addOption(verboseOption()) + .addOption(configOption()) + .option('--experimental-tailwind', 'Generate Tailwind CSS classes for tokens', false) + .action(async (opts) => { + const { verbose, clean, dry, experimentalTailwind, tokens } = opts; + + // TODO - add outdir eqivalent to config option when parsing config, so that it can be set in the config file as well. buildDir? + + dsfs.init({ dry, outdir: opts.outDir, verbose }); + + await buildCss({ + tokensDir: tokens, + outDir: dsfs.outDir, + clean, + verbose, + tailwind: experimentalTailwind, + }); + }); + + tokenCmd + .command('create') + .description('[deprecated] Create Designsystemet tokens') + .addOption(configOption()) + .option(`--${cliOptions.clean} [boolean]`, 'Clean output directory before creating tokens', parseBoolean, false) + .addOption(dryOption(`Dry run for created ${pc.blue('design-tokens')}`)) + .option('--skip-check', 'Skip migration check', false) // TODO -- will be moved to global option in the future, since it applies to all commands, not just create + .option('-y, --yes', 'Skip user prompts', false) // TODO -- will be moved to global option in the future, since it applies to all commands, not just create + /** Deprecated options */ + .option( + `-m, --${cliOptions.theme.colors.main} `, + `Main colors (deprecated, use JSON config file instead)`, + parseColorValues, + ) + .option( + `-s, --${cliOptions.theme.colors.support} `, + `Support colors (deprecated, use JSON config file instead)`, + parseColorValues, + ) + .option( + `-n, --${cliOptions.theme.colors.neutral} `, + `Neutral hex color (deprecated, use JSON config file instead)`, + convertToHex, + ) + .option( + `-o, --${cliOptions.outDir} `, + `Output directory for created ${pc.blue('design-tokens')}`, + DEFAULT_TOKENS_CREATE_DIR, + ) + .option( + `-f, --${cliOptions.theme.typography.fontFamily} `, + `Font family (experimental, deprecated, use JSON config file instead)`, + DEFAULT_FONT, + ) + .option( + `-b, --${cliOptions.theme.borderRadius} `, + `Unitless base border-radius in px (deprecated, use JSON config file instead)`, + (radiusAsString) => Number(radiusAsString), + 4, + ) + .option('--theme ', 'Theme name (deprecated, use JSON config file instead)', DEFAULT_THEME_NAME) + .action(async (opts, cmd) => { + if ( + opts.mainColors || + opts.supportColors || + opts.neutralColor || + (opts.borderRadius && opts.borderRadius !== 4) || + (opts.theme && opts.theme !== DEFAULT_THEME_NAME) || + (opts.fontFamily && opts.fontFamily !== DEFAULT_FONT) + ) { + console.warn( + pc.yellow(`\n ⚠️ Using CLI options for ${pc.bold(`colors, border radius, theme, or font family is deprecated`)} and will be removed in a future release. + \n ⚠️ Please use a JSON config file instead.`), + ); + } + + if (opts.dry) { + console.log(`Performing dry run, no files will be written`); + } + const themeName = opts.theme; + + const { configFile, configFilePath } = await getConfigFile(opts.config); + + if (!opts.skipCheck && usesDeprecatedOutputFields(configFile)) { + console.warn( + pc.yellow( + `\n${pc.bold('outDir')} and ${pc.bold('clean')} are deprecated. Run ${pc.blue('designsystemet')} to migrate your config file to ${pc.bold('output')}.\n`, + ), + ); + } + + const updatedConfigFile = opts.skipCheck + ? configFile + : await checkAutomigrate(configFile, configFilePath, opts.yes, { + // `tokens create` only reads `outDir` and `clean`, so migrating them to `output` would make it ignore them. + exclude: ['newOutputField'], + }); + + const config = await parseValidateAndOptsConfig(updatedConfigFile || configFile, { + theme: themeName, + cmd, + configFilePath, + }); + + dsfs.init({ dry: opts.dry, outdir: config.outDir }); + + await createDesignTokens({ + themes: requireThemes(config), + outDir: dsfs.outDir, + clean: config.clean, + }); + }); + + return tokenCmd; +} + +function parseColorValues(value: string, previous: Record = {}): Record { + const [name, hex] = value.split(':'); + previous[name] = convertToHex(hex); + return previous; +} + +/** + * Whether the config file uses the deprecated `outDir` or `clean` fields. False without a config file, since + * `tokens create` can run on CLI options alone, and for a config file that can't be parsed, which is reported + * when the config is validated. + */ +function usesDeprecatedOutputFields(configFile: string): boolean { + if (!configFile) { + return false; + } + + try { + return automigrations.newOutputField.check(configFile); + } catch { + return false; + } +} diff --git a/packages/cli/bin/designsystemet.ts b/packages/cli/bin/designsystemet.ts index 0dc4134a25..eb4aa277c8 100644 --- a/packages/cli/bin/designsystemet.ts +++ b/packages/cli/bin/designsystemet.ts @@ -1,16 +1,14 @@ #!/usr/bin/env node import path from 'node:path'; -import { Argument, createCommand, Option, program } from '@commander-js/extra-typings'; +import { Argument, program } from '@commander-js/extra-typings'; import pc from 'picocolors'; import * as R from 'ramda'; import pkg from '../package.json' with { type: 'json' }; import { checkAutomigrate } from '../src/automigrate.ts'; -import { convertToHex } from '../src/colors/index.ts'; -import type { CssColor } from '../src/colors/types.ts'; import migrations from '../src/migrations/index.ts'; import { parseConfig, validateConfig } from '../src/schemas/helpers.ts'; import { - type ConfigSchema, + type ConfigSchemaThemes, configSchema, type ExternalConfigSchemaInput, externalConfigSchema, @@ -22,7 +20,10 @@ import { generateConfigFromTokens } from '../src/tokens/generate-config.ts'; import type { OutputFile, Theme } from '../src/tokens/types.ts'; import { toColorNames } from '../src/tokens/utils.ts'; import { dsfs } from '../src/utils/filesystem.ts'; -import { deprecatedCLIOptions as cliOptions, parseValidateAndOptsConfig, readConfigFile } from './config.ts'; +import { isSameOrInside } from '../src/utils/paths.ts'; +import { DEFAULT_CONFIG_FILEPATH, getConfigFile, requireThemes } from './config.ts'; +import { DEFAULT_TOKENS_CREATE_DIR, makeTokenCommands } from './deprecated.ts'; +import { configOption, dryOption, verboseOption } from './options.ts'; const figletAscii = ` _____ _ _ _ @@ -35,217 +36,142 @@ const figletAscii = ` |___/ |___/ `; -program.name('designsystemet').description('CLI for working with Designsystemet').showHelpAfterError(); +program + .name('designsystemet') + .description('CLI for working with Designsystemet') + .showHelpAfterError() + // The root command and its subcommands share option names (e.g. --config, --skip-check), + // so only parse root options before the subcommand, leaving the rest to the subcommand. + .enablePositionalOptions(); program.hook('preAction', () => console.log(figletAscii)); +program.version(pkg.version, '-v, --version', 'Display version number').helpOption('-h, --help', 'Display help'); -const DEFAULT_TOKENS_CREATE_DIR = './design-tokens'; -const DEFAULT_TOKENS_BUILD_DIR = './design-tokens-build'; -const DEFAULT_FONT = 'Inter'; -const DEFAULT_THEME_NAME = 'theme'; -const DEFAULT_CONFIG_FILEPATH = 'designsystemet.config.json'; -// Default config files to auto-detect when no --config is supplied, in order of precedence. -const DEFAULT_CONFIG_FILEPATHS = ['designsystemet.config.json', 'designsystemet.config.jsonc']; - -// Options shared by multiple commands. Factories because commander mutates Option instances, -// so each command needs its own. -const configOption = () => - new Option( - '-c, --config ', - `Path to config file (auto-detects ${DEFAULT_CONFIG_FILEPATHS.map((p) => `"${p}"`).join(' or ')})`, - ); -const dryOption = (description = 'Dry run - no files will be written') => - new Option('--dry [boolean]', description).argParser(parseBoolean).default(false); -const verboseOption = () => new Option('--verbose', 'Enable verbose output').default(false); - -function _makeConfigCommand() { - return createCommand('config') - .usage('designsystemet') - .description('Parses config file and run Designsystemet commands') - .addOption(configOption()) - .addOption(dryOption()) - .addOption(verboseOption()) - .action(async (opts) => { - const { verbose, dry } = opts; - - const { configFile, configFilePath } = await getConfigFile(opts.config); - - dsfs.init({ dry, verbose, outdir: path.dirname(configFilePath) }); - - if (!configFile) { - console.error(pc.redBright(`No config file found. Please create one at ${pc.blue(DEFAULT_CONFIG_FILEPATH)}.`)); - process.exit(1); - } +program + .description('Run Designsystemet') + .addOption(configOption()) + .addOption(dryOption()) + .addOption(verboseOption()) + .option('--skip-check', 'Skip migration check', false) + .option('-y, --yes', 'Skip migration prompts and auto accept', false) + .action(async (opts) => { + const { verbose, dry } = opts; - const parsedConfig = parseConfig(configFile); - // Validate against the public schema first for a user-facing error on unsupported theme fields. - validateConfig(externalConfigSchema, parsedConfig); - const config = validateConfig(configSchema, parsedConfig); + const { configFile, configFilePath } = await getConfigFile(opts.config); - // Sort outputs so that design-tokens are generated before CSS, since CSS may depend on the design tokens being present. - const sortedOutput = R.sortBy((o) => (o.type === 'design-tokens' ? 0 : 1), config.output); + dsfs.init({ dry, verbose, outdir: path.dirname(configFilePath) }); - for (const output of sortedOutput) { - const outDir = path.join(dsfs.outDir, output.dir); + if (!configFile) { + console.error(pc.redBright(`No config file found. Please create one at ${pc.blue(DEFAULT_CONFIG_FILEPATH)}.`)); + process.exit(1); + } - if (output.type === 'design-tokens') { - console.log(`\n🍱 Generating design tokens in ${pc.green(output.dir)}...`); + const updatedConfigFile = opts.skipCheck + ? configFile + : await checkAutomigrate(configFile, configFilePath, opts.yes); - await createDesignTokens({ - themes: config.themes, - outDir: outDir, - clean: output.cleanDir, - }); - } + const parsedConfig = parseConfig(updatedConfigFile); - if (output.type === 'css') { - console.log(`\n🍱 Generating CSS in ${pc.green(output.dir)}...`); - - // Only generate create CSS if no `design-tokens` output is present and no `tokenDir` is explicitly set in the config file. Otherwise, build CSS from existing design tokens. - if (isOnlyCssOutput(config)) { - await createCss({ - themes: config.themes, - outDir: outDir, - clean: output.cleanDir, - verbose, - tailwind: output.experimental_tailwind, - }); - } else { - await buildCss({ - // Resolve the token directory relative to the config file, like output.dir, - // so it matches where a preceding design-tokens output wrote its files. - tokensDir: path.join(dsfs.outDir, output.tokenDir), - outDir, - clean: output.cleanDir, - verbose, - tailwind: output.experimental_tailwind, - }); - } - } - } - }); -} + // This command only reads `output`. If `outDir` or `clean` are still in the config, because the migration was + // declined or skipped, stop before anything is cleaned or written instead of silently ignoring them. + if (parsedConfig.outDir !== undefined || parsedConfig.clean !== undefined) { + console.error( + pc.redBright( + `${pc.blue('outDir')} and ${pc.blue('clean')} are not supported by ${pc.blue('designsystemet')}. Run it again and accept the migration, or replace them with ${pc.blue('output')}. To keep using them, run ${pc.blue('designsystemet tokens create')} instead.`, + ), + ); + process.exit(1); + } + // Validate against the public schema first for a user-facing error on unsupported theme fields. + validateConfig(externalConfigSchema, parsedConfig); + const config = validateConfig(configSchema, parsedConfig); + + // Sort outputs so that design-tokens are generated before CSS, since CSS may depend on the design tokens being present. + const sortedOutput = R.sortBy((o) => (o.type === 'design-tokens' ? 0 : 1), config.output); + const designTokensOutput = config.output.find((o) => o.type === 'design-tokens'); + + // Outputs created from themes can't run without them. Check this before cleaning, so nothing is deleted when + // themes are missing. Same rule as below: design tokens, and CSS with no design tokens to build from. + const needsThemes = config.output.some( + (o) => o.type === 'design-tokens' || (o.type === 'css' && (o.tokensDir ?? designTokensOutput?.dir) === undefined), + ); + if (needsThemes) { + requireThemes(config); + } -function makeTokenCommands() { - const tokenCmd = createCommand('tokens'); - - tokenCmd - .command('build') - .description('Build Designsystemet tokens') - .option('-t, --tokens ', `Path to ${pc.blue('design-tokens')}`, DEFAULT_TOKENS_CREATE_DIR) - .option( - '-o, --out-dir ', - `Output directory for built ${pc.blue('design-tokens')}`, - DEFAULT_TOKENS_BUILD_DIR, - ) - .option(`--${cliOptions.clean} [boolean]`, 'Clean output directory before building tokens', parseBoolean, false) - .addOption(dryOption(`Dry run for built ${pc.blue('design-tokens')}`)) - .addOption(verboseOption()) - .addOption(configOption()) - .option('--experimental-tailwind', 'Generate Tailwind CSS classes for tokens', false) - .action(async (opts) => { - const { verbose, clean, dry, experimentalTailwind, tokens } = opts; - - // TODO - add outdir eqivalent to config option when parsing config, so that it can be set in the config file as well. buildDir? - - dsfs.init({ dry, outdir: opts.outDir, verbose }); - - await buildCss({ - tokensDir: tokens, - outDir: dsfs.outDir, - clean, - verbose, - tailwind: experimentalTailwind, - }); + // Clean every output directory once, before any output is created. Cleaning as part of each output would + // delete what earlier outputs wrote when they share a directory, which makes the outputs depend on their order. + const dirsToClean = R.uniq( + config.output.filter((o) => 'cleanDir' in o && o.cleanDir).map((o) => path.join(dsfs.outDir, o.dir)), + ); + + // Check every directory before cleaning any, so nothing is deleted when one of them is unsafe. + // Paths are resolved, so differently written paths to the same directory (`tokens`, `./tokens/`) match. + const resolveDir = (dir: string) => path.resolve(dsfs.outDir, dir); + const regeneratedDirs = config.output.flatMap((o) => (o.type === 'design-tokens' ? [resolveDir(o.dir)] : [])); + const unsafeCleanError = findUnsafeClean({ + dirsToClean, + configDir: path.dirname(path.resolve(configFilePath)), + // Existing design tokens that `css` outputs build from. Tokens in a `design-tokens` output's directory + // are created again in this run, so cleaning them is safe. + inputDirs: config.output + .flatMap((o) => (o.type === 'css' && o.tokensDir !== undefined ? [resolveDir(o.tokensDir)] : [])) + .filter((dir) => !regeneratedDirs.includes(dir)), + // Directories of outputs that keep their existing files, which another output's cleaning must not delete. + keptDirs: config.output.flatMap((o) => ('cleanDir' in o && o.cleanDir === false ? [resolveDir(o.dir)] : [])), }); + if (unsafeCleanError) { + console.error(pc.redBright(unsafeCleanError)); + process.exit(1); + } - tokenCmd - .command('create') - .description('Create Designsystemet tokens') - .addOption(configOption()) - .option(`--${cliOptions.clean} [boolean]`, 'Clean output directory before creating tokens', parseBoolean, false) - .addOption(dryOption(`Dry run for created ${pc.blue('design-tokens')}`)) - .option('--skip-check', 'Skip migration check', false) // TODO -- will be moved to global option in the future, since it applies to all commands, not just create - .option('-y, --yes', 'Skip user prompts', false) // TODO -- will be moved to global option in the future, since it applies to all commands, not just create - /** Deprecated options */ - .option( - `-m, --${cliOptions.theme.colors.main} `, - `Main colors (deprecated, use JSON config file instead)`, - parseColorValues, - ) - .option( - `-s, --${cliOptions.theme.colors.support} `, - `Support colors (deprecated, use JSON config file instead)`, - parseColorValues, - ) - .option( - `-n, --${cliOptions.theme.colors.neutral} `, - `Neutral hex color (deprecated, use JSON config file instead)`, - convertToHex, - ) - .option( - `-o, --${cliOptions.outDir} `, - `Output directory for created ${pc.blue('design-tokens')}`, - DEFAULT_TOKENS_CREATE_DIR, - ) - .option( - `-f, --${cliOptions.theme.typography.fontFamily} `, - `Font family (experimental, deprecated, use JSON config file instead)`, - DEFAULT_FONT, - ) - .option( - `-b, --${cliOptions.theme.borderRadius} `, - `Unitless base border-radius in px (deprecated, use JSON config file instead)`, - (radiusAsString) => Number(radiusAsString), - 4, - ) - .option('--theme ', 'Theme name (deprecated, use JSON config file instead)', DEFAULT_THEME_NAME) - .action(async (opts, cmd) => { - if ( - opts.mainColors || - opts.supportColors || - opts.neutralColor || - (opts.borderRadius && opts.borderRadius !== 4) || - (opts.theme && opts.theme !== DEFAULT_THEME_NAME) || - (opts.fontFamily && opts.fontFamily !== DEFAULT_FONT) - ) { - console.warn( - pc.yellow(`\n ⚠️ Using CLI options for ${pc.bold(`colors, border radius, theme, or font family is deprecated`)} and will be removed in a future release. - \n ⚠️ Please use a JSON config file instead.`), - ); - } - - if (opts.dry) { - console.log(`Performing dry run, no files will be written`); - } - const themeName = opts.theme; + for (const dir of dirsToClean) { + await dsfs.cleanDir(dir); + } - const { configFile, configFilePath } = await getConfigFile(opts.config); + for (const output of sortedOutput) { + const outDir = path.join(dsfs.outDir, output.dir); - const updatedConfigFile = opts.skipCheck - ? configFile - : await checkAutomigrate(configFile, configFilePath, opts.yes); + if (output.type === 'design-tokens') { + console.log(`\n🍱 Creating design tokens in ${pc.green(output.dir)}...`); - const config = await parseValidateAndOptsConfig(updatedConfigFile || configFile, { - theme: themeName, - cmd, - configFilePath, - }); + await createDesignTokens({ + themes: requireThemes(config), + outDir: outDir, + }); + } - dsfs.init({ dry: opts.dry, outdir: config.outDir }); + if (output.type === 'css') { + console.log(`\n🍱 Creating CSS in ${pc.green(output.dir)}...`); - await createDesignTokens({ - themes: config.themes, - outDir: dsfs.outDir, - clean: config.clean, - }); - }); + // Build CSS from `tokensDir`, or else from the design tokens created by the `design-tokens` output. + // With neither, there are no design tokens to build from, so CSS is created directly from the themes. + const tokensDir = output.tokensDir ?? designTokensOutput?.dir; + // A dry run doesn't write the design tokens this run creates, so they can't be read back from disk. + // Create the CSS from the themes instead, which gives the same result. + const tokensNotWritten = dry && tokensDir !== undefined && regeneratedDirs.includes(resolveDir(tokensDir)); - return tokenCmd; -} + if (tokensDir === undefined || tokensNotWritten) { + await createCss({ + themes: requireThemes(config), + outDir: outDir, + verbose, + tailwind: output.experimental_tailwind, + }); + } else { + await buildCss({ + // Resolve the token directory relative to the config file, like output.dir, + // so it matches where a preceding design-tokens output wrote its files. + tokensDir: path.join(dsfs.outDir, tokensDir), + outDir, + verbose, + tailwind: output.experimental_tailwind, + }); + } + } + } + }); -program.addCommand(makeTokenCommands()); -/** Disabling this for future testing and assessment */ -// program.addCommand(_makeConfigCommand(), { isDefault: true }); +program.addCommand(makeTokenCommands({ createDesignTokens, buildCss })); program .command('generate-config-from-tokens') @@ -314,41 +240,8 @@ program } }); -program.version(pkg.version, '-v, --version', 'Display version number').helpOption('-h, --help', 'Display help'); - await program.parseAsync(process.argv); -function parseColorValues(value: string, previous: Record = {}): Record { - const [name, hex] = value.split(':'); - previous[name] = convertToHex(hex); - return previous; -} - -function parseBoolean(value: string | boolean): boolean { - return value === 'true' || value === true; -} - -async function getConfigFile(userConfigFilePath: string | undefined) { - if (!R.isNil(userConfigFilePath)) { - // A config path was supplied explicitly. It's allowed to not exist only if it's one of the defaults. - const allowFileNotFound = DEFAULT_CONFIG_FILEPATHS.includes(userConfigFilePath); - const configFile = await readConfigFile(userConfigFilePath, allowFileNotFound); - - return { configFile, configFilePath: userConfigFilePath }; - } - - // No config path supplied: auto-detect the default config files (.json, then .jsonc). - for (const configFilePath of DEFAULT_CONFIG_FILEPATHS) { - const configFile = await readConfigFile(configFilePath, true); - if (configFile) { - return { configFile, configFilePath }; - } - } - - // None found - return empty config using the canonical default path for messaging. - return { configFile: '', configFilePath: DEFAULT_CONFIG_FILEPATH }; -} - /** * Creates design token files for the given themes and writes them to `outDir`. * Shared by `tokens create` and the `config` command's `design-tokens` output. @@ -358,7 +251,7 @@ async function createDesignTokens({ outDir, clean, }: { - themes: ConfigSchema['themes']; + themes: ConfigSchemaThemes; outDir: string; clean?: boolean; }) { @@ -390,6 +283,8 @@ async function createDesignTokens({ await dsfs.cleanDir(outDir); } + console.log(`\n💾 Writing design tokens to ${pc.green(outDir)}`); + await dsfs.mkdir(outDir); await dsfs.writeFiles(files, outDir); @@ -434,20 +329,14 @@ async function buildCss({ async function createCss({ themes, outDir, - clean, verbose, tailwind, }: { - themes: ConfigSchema['themes']; + themes: ConfigSchemaThemes; outDir: string; - clean?: boolean; verbose: boolean; tailwind: boolean; }) { - if (clean) { - await dsfs.cleanDir(outDir); - } - const themeNames = Object.keys(themes); if (themeNames.length > 0) { console.log(`Using themes from config file: ${pc.blue(themeNames.join(', '))}`); @@ -460,10 +349,6 @@ async function createCss({ files.push(...themeCSSFiles); } - if (clean) { - await dsfs.cleanDir(outDir); - } - console.log(`\n💾 Writing CSS to ${pc.green(outDir)}`); await dsfs.mkdir(outDir); @@ -472,12 +357,39 @@ async function createCss({ console.log(`\n✅ Finished creating CSS`); } -function isOnlyCssOutput(config: ConfigSchema): boolean { - // Can be defined using either the shorthand or object syntax, so check for both. - const hasDesignTokensOutput = - config.output.find((o) => o.type === 'design-tokens') || - config.output.find((o) => o === ('design-tokens' as unknown as ConfigSchema['output'][number])); - const hasCSSTokensDir = config.output.find((o) => o.type === 'css')?.tokenDir; +/** + * Returns an error message when cleaning `dirsToClean` would delete something it shouldn't: the config file, + * existing design tokens in `inputDirs` that an output builds from, or files in `keptDirs` that an output keeps. + */ +function findUnsafeClean({ + dirsToClean, + configDir, + inputDirs, + keptDirs, +}: { + dirsToClean: string[]; + configDir: string; + inputDirs: string[]; + keptDirs: string[]; +}): string | undefined { + const toConfigRelative = (dir: string) => pc.blue(path.relative(configDir, dir) || '.'); + const fix = `Use another ${pc.blue('dir')}, or set ${pc.blue('cleanDir')} to ${pc.blue('false')} for that output.`; + + for (const dir of dirsToClean) { + if (isSameOrInside(configDir, dir)) { + return `Output directory ${toConfigRelative(dir)} contains the config file, so cleaning it would delete the config file. ${fix}`; + } + + const inputDir = inputDirs.find((input) => isSameOrInside(input, dir)); + if (inputDir) { + return `Output directory ${toConfigRelative(dir)} contains the design tokens in ${toConfigRelative(inputDir)}, so cleaning it would delete them before they are used. ${fix}`; + } + + const keptDir = keptDirs.find((kept) => isSameOrInside(kept, dir)); + if (keptDir) { + return `Output directory ${toConfigRelative(dir)} contains ${toConfigRelative(keptDir)}, which has ${pc.blue('cleanDir')} set to ${pc.blue('false')}, so cleaning it would delete files that should be kept. Use separate directories, or the same ${pc.blue('cleanDir')} for both outputs.`; + } + } - return !hasDesignTokensOutput && !hasCSSTokensDir; + return undefined; } diff --git a/packages/cli/bin/options.ts b/packages/cli/bin/options.ts index 199abb64e5..e5bea11d5a 100644 --- a/packages/cli/bin/options.ts +++ b/packages/cli/bin/options.ts @@ -1,4 +1,5 @@ -import type { Command, OptionValueSource, OptionValues } from '@commander-js/extra-typings'; +import { type Command, Option, type OptionValueSource, type OptionValues } from '@commander-js/extra-typings'; +import { DEFAULT_CONFIG_FILEPATHS } from './config.ts'; const getOptionIfMatchingSource = (...sources: OptionValueSource[]) => @@ -32,3 +33,16 @@ export const getDefaultCliOption = getOptionIfMatchingSource('default'); * for the option as defined in the {@link Command} */ export const getCliOption = getOptionIfMatchingSource('cli', 'default'); + +export const configOption = () => + new Option( + '-c, --config ', + `Path to config file (auto-detects ${DEFAULT_CONFIG_FILEPATHS.map((p) => `"${p}"`).join(' or ')})`, + ); +export const dryOption = (description = 'Dry run - no files will be written') => + new Option('--dry [boolean]', description).argParser(parseBoolean).default(false); +export const verboseOption = () => new Option('--verbose', 'Enable verbose output').default(false); + +export function parseBoolean(value: string | boolean): boolean { + return value === 'true' || value === true; +} diff --git a/packages/cli/docs/designsystemet.config.defaults.json b/packages/cli/docs/designsystemet.config.defaults.json index 13115dfbf5..836245972a 100644 --- a/packages/cli/docs/designsystemet.config.defaults.json +++ b/packages/cli/docs/designsystemet.config.defaults.json @@ -9,8 +9,6 @@ "type": "css", "dir": "design-tokens-build", "cleanDir": true, - "tokenDir": "design-tokens", - "banner": "", "experimental_tailwind": true } ], diff --git a/packages/cli/package.json b/packages/cli/package.json index 857f7510b4..97eb82f24d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -37,8 +37,8 @@ "build:json-schema": "pnpm node ./src/scripts/create-json-schema.ts && pnpm node ./src/scripts/create-full-json-schema.ts && pnpm node ./src/scripts/create-example-config.ts", "types": "tsc --noEmit", "test:unit": "vitest run", - "test:tokens-create-options": "pnpm run designsystemet tokens create -m dominant:\"#007682\" -n \"#003333\" -b 99 -o ./temp/options/design-tokens --theme options --clean", - "test:tokens-create-config": "pnpm run designsystemet tokens create --config ./tests/test-tokens.config.jsonc", + "test:tokens-create-options": "pnpm run designsystemet tokens create -m dominant:\"#007682\" -n \"#003333\" -b 99 -o ./temp/options/design-tokens --theme options --clean --skip-check", + "test:tokens-create-config": "pnpm run designsystemet tokens create --config ./tests/test-tokens.config.jsonc --skip-check", "test:tokens-build-options": "pnpm run designsystemet tokens build -t ./temp/options/design-tokens -o ./temp/options/build --clean --experimental-tailwind", "test:tokens-build-config": "pnpm run designsystemet tokens build -t ./tests/config/design-tokens -o ./tests/config/build --clean --experimental-tailwind", "test:tokens-build-config:inspect": "pnpm run designsystemet:inspect tokens build -t ./tests/config/design-tokens -o ./tests/config/build --clean", diff --git a/packages/cli/src/automigrate.test.ts b/packages/cli/src/automigrate.test.ts new file mode 100644 index 0000000000..48a4864a6b --- /dev/null +++ b/packages/cli/src/automigrate.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { checkAutomigrate } from './automigrate.ts'; +import { parseJsonc } from './schemas/helpers.ts'; +import { dsfs } from './utils/filesystem.ts'; + +const confirm = vi.hoisted(() => vi.fn<() => Promise>()); +vi.mock('@inquirer/confirm', () => ({ default: confirm })); + +type ParsedConfig = { outDir?: string; output?: unknown; themes: { theme: { colors: Record } } }; + +// Eligible for both automigrations: the old color categories and the deprecated `outDir`. +const config = JSON.stringify({ + outDir: 'tokens', + themes: { theme: { colors: { main: { accent: '#0062BA' }, support: {}, neutral: '#1E2B3C' } } }, +}); + +describe('checkAutomigrate', () => { + let written: string[]; + + beforeEach(() => { + vi.spyOn(console, 'log').mockImplementation(() => {}); + written = []; + vi.spyOn(dsfs, 'writeFile').mockImplementation(async (_path, content) => { + written.push(String(content)); + }); + confirm.mockReset(); + }); + + it('does not write a declined migration when a later migration is accepted', async () => { + // Decline flattening the color categories, accept the new output field. + confirm.mockResolvedValueOnce(false).mockResolvedValueOnce(true); + + const runtimeConfig = parseJsonc(await checkAutomigrate(config, 'designsystemet.config.json', false)); + const writtenConfig = parseJsonc(written.at(-1) ?? ''); + + // The file keeps the declined color categories, but gets the accepted `output`. + expect(writtenConfig.themes.theme.colors).toEqual({ main: { accent: '#0062BA' }, support: {}, neutral: '#1E2B3C' }); + expect(writtenConfig.output).toBeDefined(); + expect(writtenConfig.outDir).toBeUndefined(); + + // This run still uses the flattened colors, plus the accepted `output`. + expect(runtimeConfig.themes.theme.colors).toEqual({ accent: '#0062BA', neutral: '#1E2B3C' }); + expect(runtimeConfig.output).toEqual(writtenConfig.output); + }); + + it('writes every accepted migration', async () => { + confirm.mockResolvedValue(true); + + const runtimeConfig = await checkAutomigrate(config, 'designsystemet.config.json', false); + + expect(written.at(-1)).toBe(runtimeConfig); + expect(parseJsonc(runtimeConfig).themes.theme.colors).toEqual({ + accent: '#0062BA', + neutral: '#1E2B3C', + }); + }); + + it('does not offer excluded migrations', async () => { + confirm.mockResolvedValue(true); + + const runtimeConfig = parseJsonc( + await checkAutomigrate(config, 'designsystemet.config.json', false, { exclude: ['newOutputField'] }), + ); + + // Only the color categories migration was offered, so `outDir` is kept and no `output` is added. + expect(confirm).toHaveBeenCalledTimes(1); + expect(runtimeConfig.outDir).toBe('tokens'); + expect(runtimeConfig.output).toBeUndefined(); + expect(runtimeConfig.themes.theme.colors).toEqual({ accent: '#0062BA', neutral: '#1E2B3C' }); + }); + + it('writes nothing when every migration is declined', async () => { + confirm.mockResolvedValue(false); + + await checkAutomigrate(config, 'designsystemet.config.json', false); + + expect(written).toEqual([]); + }); +}); diff --git a/packages/cli/src/automigrate.ts b/packages/cli/src/automigrate.ts index d738a8b2b7..0af3458f01 100644 --- a/packages/cli/src/automigrate.ts +++ b/packages/cli/src/automigrate.ts @@ -3,22 +3,33 @@ import pc from 'picocolors'; import { automigrations } from './migrations/index.ts'; import { dsfs } from './utils/filesystem.ts'; -export const checkAutomigrate = async (configFile: string, configFilePath: string, yes: boolean) => { - if (!configFile) { - return null; - } - let migratedConfigFile = null; - const eligibleMigrations = Object.values(automigrations).filter((migration) => { - try { - return migration.check(configFile); - } catch { - return false; - } - }); +type AutomigrationName = keyof typeof automigrations; + +export const checkAutomigrate = async ( + configFile: string, + configFilePath: string, + yes: boolean, + /** Migrations not to offer, e.g. ones whose result the calling command can't use. */ + { exclude = [] }: { exclude?: AutomigrationName[] } = {}, +) => { + const eligibleMigrations = (Object.keys(automigrations) as AutomigrationName[]) + .filter((name) => !exclude.includes(name)) + .map((name) => automigrations[name]) + .filter((migration) => { + try { + return migration.check(configFile); + } catch { + return false; + } + }); if (eligibleMigrations.length === 0) { - return null; + return configFile; } + // Declined migrations may still transform the config for this run, e.g. to keep it compatible with the schema. + // Those changes must never reach the file, so the text written to disk is tracked separately from the text used now. + let persistedConfig = configFile; + let runtimeConfig = configFile; for (const migration of eligibleMigrations) { console.log(pc.red(`\n ✋ Automigration detected \n`)); console.log( @@ -36,11 +47,14 @@ export const checkAutomigrate = async (configFile: string, configFilePath: strin } if (!answer) { - migratedConfigFile = migration.no(migratedConfigFile ?? configFile); + runtimeConfig = migration.no(runtimeConfig); } else { - migratedConfigFile = migration.yes(migratedConfigFile ?? configFile); - await dsfs.writeFile(configFilePath, migratedConfigFile); + const migrated = migration.yes(persistedConfig, { configFilePath }); + // Only migrate the runtime text separately when a declined migration has made it differ from the file. + runtimeConfig = runtimeConfig === persistedConfig ? migrated : migration.yes(runtimeConfig, { configFilePath }); + persistedConfig = migrated; + await dsfs.writeFile(configFilePath, persistedConfig); } } - return migratedConfigFile; + return runtimeConfig; }; diff --git a/packages/cli/src/figma/scopes.test.ts b/packages/cli/src/figma/scopes.test.ts index 838b4f36e8..857863bba6 100644 --- a/packages/cli/src/figma/scopes.test.ts +++ b/packages/cli/src/figma/scopes.test.ts @@ -61,7 +61,7 @@ describe('figmaVariableScopes covers every generated token', () => { const config = configSchema.parse({ themes: { [themeName]: { colors: { neutral: '#444444', brand: '#0062BA' } } }, }); - const theme = { name: themeName, ...config.themes[themeName] } as Theme; + const theme = { name: themeName, ...config.themes?.[themeName] } as Theme; const dimensions = getTokenSetDimensions(theme); const { tokenSets } = await createTokens(theme, dimensions); const $themes = await generate$Themes(dimensions, [themeName], toColorNames(theme.colors)); diff --git a/packages/cli/src/internal.ts b/packages/cli/src/internal.ts index 4d20821f81..7cc2b5e31b 100644 --- a/packages/cli/src/internal.ts +++ b/packages/cli/src/internal.ts @@ -29,7 +29,7 @@ export { toFigmaCollections, } from './figma/collections.ts'; export { figmaVariableScopes, figmaVariableType } from './figma/scopes.ts'; -export { severityColors } from './schemas/defaults.ts'; +export { defaultBorderRadius, severityColors } from './schemas/defaults.ts'; export { parseConfig, validateConfig } from './schemas/helpers.ts'; export { type ConfigSchema, diff --git a/packages/cli/src/migrations/flatten-color-categories.ts b/packages/cli/src/migrations/flatten-color-categories.ts index 5c7c4a890c..239e3ee533 100644 --- a/packages/cli/src/migrations/flatten-color-categories.ts +++ b/packages/cli/src/migrations/flatten-color-categories.ts @@ -19,7 +19,7 @@ type Automigrate = { name: string; check: (config: any) => boolean; message: string; - yes: (config: string) => string; + yes: (config: string, context?: { configFilePath?: string }) => string; no: (config: string) => string; }; diff --git a/packages/cli/src/migrations/index.ts b/packages/cli/src/migrations/index.ts index 1a1265cb29..78d6314308 100644 --- a/packages/cli/src/migrations/index.ts +++ b/packages/cli/src/migrations/index.ts @@ -1,9 +1,11 @@ import betaToV1 from './beta-to-v1.ts'; import colorRenameNext49 from './color-rename-next49.ts'; import flattenColorCategories from './flatten-color-categories.ts'; +import newOutputField from './new-output-field.ts'; export const automigrations = { colorCategoryFlattening: flattenColorCategories, + newOutputField, }; export default { diff --git a/packages/cli/src/migrations/new-output-field.test.ts b/packages/cli/src/migrations/new-output-field.test.ts new file mode 100644 index 0000000000..ee401c7989 --- /dev/null +++ b/packages/cli/src/migrations/new-output-field.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it, vi } from 'vitest'; +import { parseJsonc } from '../schemas/helpers.ts'; +import migration, { migrateToOutputField } from './new-output-field.ts'; + +describe('new output field migration', () => { + it('only applies to configs with deprecated fields', () => { + expect(migration.check('{ "outDir": "tokens" }')).toBe(true); + expect(migration.check('{ "clean": false }')).toBe(true); + expect(migration.check('{ "output": ["css"] }')).toBe(false); + }); + + it('replaces outDir and clean with output, preserving comments', () => { + const config = `{ + "outDir": "tokens", + "clean": true, + // my themes + "themes": {} +}`; + const migrated = migrateToOutputField(config); + + expect(migrated).toContain('// my themes'); + expect(parseJsonc(migrated)).toEqual({ + themes: {}, + output: [ + { type: 'design-tokens', dir: 'tokens' }, + { type: 'css', tokensDir: 'tokens' }, + ], + }); + }); + + // A comment between a value and its comma used to hide the comma, leaving invalid JSON behind. + it.each([ + ['a block comment before the comma', '{ "outDir": "design-tokens" /* note */, "themes": {} }'], + ['a line comment after the comma', '{\n "clean": true, // note\n "themes": {}\n}'], + ['a line comment before the comma', '{\n "clean": true // note\n ,"themes": {}\n}'], + ['a comment after the last property', '{ "themes": {}, "clean": true /* last */ }'], + ])('removes the deprecated fields and keeps the config valid with %s', (_, config) => { + const migrated = migrateToOutputField(config); + + expect(parseJsonc(migrated)).toEqual({ themes: {} }); + expect(migrated).toMatch(/note|last/); + }); + + it('places output after $schema if present, otherwise at the top', () => { + const withSchema = migrateToOutputField('{ "themes": {}, "$schema": "schema.json", "outDir": "tokens" }'); + expect(Object.keys(parseJsonc(withSchema))).toEqual(['themes', '$schema', 'output']); + + const withoutSchema = migrateToOutputField('{ "themes": {}, "outDir": "tokens" }'); + expect(Object.keys(parseJsonc(withoutSchema))).toEqual(['output', 'themes']); + }); + + it('does not define output when the deprecated fields have default values', () => { + expect(parseJsonc(migrateToOutputField('{ "outDir": "./design-tokens" }'))).toEqual({}); + expect(parseJsonc(migrateToOutputField('{ "outDir": "./design-tokens", "clean": true }'))).toEqual({}); + expect(parseJsonc(migrateToOutputField('{ "clean": true }'))).toEqual({}); + }); + + // `cleanDir` defaults to `true`, so dropping `clean: false` would delete directories the user opted out of cleaning. + it('carries an explicit clean: false over as cleanDir: false on every output', () => { + expect(parseJsonc(migrateToOutputField('{ "outDir": "tokens", "clean": false }'))).toEqual({ + output: [ + { type: 'design-tokens', dir: 'tokens', cleanDir: false }, + { type: 'css', tokensDir: 'tokens', cleanDir: false }, + ], + }); + }); + + it('defines output for clean: false even when outDir has its default value', () => { + const expected = { + output: [ + { type: 'design-tokens', cleanDir: false }, + { type: 'css', cleanDir: false }, + ], + }; + + expect(parseJsonc(migrateToOutputField('{ "clean": false }'))).toEqual(expected); + expect(parseJsonc(migrateToOutputField('{ "outDir": "./design-tokens", "clean": false }'))).toEqual(expected); + }); + + it('only removes deprecated fields when output is already set', () => { + const migrated = migrateToOutputField('{ "outDir": "tokens", "output": ["css"] }'); + + expect(parseJsonc(migrated)).toEqual({ output: ['css'] }); + }); + + it('migrates JSONC configs with comments and trailing commas', () => { + vi.spyOn(console, 'log').mockImplementation(() => {}); + const config = `{ + // my output + "outDir": "tokens", + "themes": {}, +}`; + + expect(parseJsonc(migration.yes(config))).toEqual({ + themes: {}, + output: [ + { type: 'design-tokens', dir: 'tokens' }, + { type: 'css', tokensDir: 'tokens' }, + ], + }); + expect(parseJsonc(migration.yes('{ /* default */ "outDir": "design-tokens", "themes": {} }'))).toEqual({ + themes: {}, + }); + vi.restoreAllMocks(); + }); + + describe('when the config file is not in the directory the CLI was run from', () => { + const context = { configFilePath: 'configs/designsystemet.config.json', cwd: '/project' }; + + it('rewrites outDir relative to the config file', () => { + expect(parseJsonc(migrateToOutputField('{ "outDir": "tokens" }', context))).toEqual({ + output: [ + { type: 'design-tokens', dir: '../tokens' }, + { type: 'css', tokensDir: '../tokens' }, + ], + }); + }); + + it('keeps the default outDir pointing to the same directory', () => { + expect(parseJsonc(migrateToOutputField('{ "clean": true }', context))).toEqual({ + output: [ + { type: 'design-tokens', dir: '../design-tokens' }, + { type: 'css', tokensDir: '../design-tokens' }, + ], + }); + }); + + it('rewrites an absolute outDir relative to the config file', () => { + expect(parseJsonc(migrateToOutputField('{ "outDir": "/project/configs/tokens" }', context))).toEqual({ + output: [ + { type: 'design-tokens', dir: 'tokens' }, + { type: 'css', tokensDir: 'tokens' }, + ], + }); + }); + + it('does not define output when outDir resolves to the default directory next to the config file', () => { + expect(parseJsonc(migrateToOutputField('{ "outDir": "configs/design-tokens" }', context))).toEqual({}); + }); + }); + + it('leaves the config unchanged when declined', () => { + const config = '{ "outDir": "tokens" }'; + + expect(migration.no(config)).toBe(config); + }); +}); diff --git a/packages/cli/src/migrations/new-output-field.ts b/packages/cli/src/migrations/new-output-field.ts new file mode 100644 index 0000000000..dc952d104a --- /dev/null +++ b/packages/cli/src/migrations/new-output-field.ts @@ -0,0 +1,199 @@ +// biome-ignore-all lint/suspicious/noExplicitAny: the deprecated fields are no longer in the schema types, so we need to use any here +import path from 'node:path'; +import { applyEdits, createScanner, findNodeAtLocation, modify, parseTree } from 'jsonc-parser'; +import pc from 'picocolors'; +import { parseJsonc } from '../schemas/helpers.ts'; +import { outputConfigShape } from '../schemas/schema-output.ts'; + +const formattingOptions = { insertSpaces: true, tabSize: 2 } as const; + +const deprecatedFields = ['outDir', 'clean'] as const; + +type MigrationContext = { + /** Path of the config file being migrated. */ + configFilePath?: string; + /** Directory the CLI was run from. Defaults to `process.cwd()`. */ + cwd?: string; +}; + +type Automigrate = { + name: string; + check: (config: string) => boolean; + message: string; + yes: (config: string, context?: MigrationContext) => string; + no: (config: string) => string; +}; + +/** + * The scanner's token kind for a comma. `SyntaxKind` is a `const enum`, which can't be imported with `isolatedModules`, + * so it's read from the scanner instead. + */ +const COMMA_TOKEN = createScanner(',').scan(); + +/** Returns the offset of the next token from `offset` that isn't whitespace or a comment, and that token. */ +const nextToken = (text: string, offset: number) => { + const scanner = createScanner(text, true); + scanner.setPosition(offset); + const token = scanner.scan(); + + return { token, offset: scanner.getTokenOffset() }; +}; + +/** + * Removes a top-level property and its comma, leaving surrounding comments and formatting intact. + * `modify(text, [key], undefined)` removes everything up to the next property, including comments. + * + * Comments between the value and its comma are kept, so the comma is found with the JSONC scanner + * rather than by looking for whitespace only. + */ +const removeProperty = (text: string, key: string): string => { + const tree = parseTree(text); + const property = tree && findNodeAtLocation(tree, [key])?.parent; + if (!property || !tree.children) { + return text; + } + + const propertyStart = property.offset; + const propertyEnd = property.offset + property.length; + + // Ranges to remove: the property itself, and one comma next to it. + const removals: [number, number][] = [[propertyStart, propertyEnd]]; + + const after = nextToken(text, propertyEnd); + const hasTrailingComma = after.token === COMMA_TOKEN; + if (hasTrailingComma) { + removals.push([after.offset, after.offset + 1]); + } else { + // Last property: remove the comma after the previous property instead. + const index = tree.children.indexOf(property); + const previous = tree.children[index - 1]; + if (previous) { + const comma = nextToken(text, previous.offset + previous.length); + if (comma.token === COMMA_TOKEN) { + removals.push([comma.offset, comma.offset + 1]); + } + } + } + + let result = text; + for (const [start, end] of removals.sort(([a], [b]) => b - a)) { + result = result.slice(0, start) + result.slice(end); + } + + // Remove the line the property was on when nothing but whitespace is left on it. + const lineStart = result.lastIndexOf('\n', propertyStart - 1) + 1; + const lineEnd = result.indexOf('\n', propertyStart); + if (lineEnd !== -1 && lineStart > 0 && /^[ \t]*$/.test(result.slice(lineStart, lineEnd))) { + result = result.slice(0, lineStart) + result.slice(lineEnd + 1); + } + + return result; +}; + +const hasDeprecatedFields = (config: string): boolean => { + const currentConfig = parseJsonc(config); + + return deprecatedFields.some((key) => key in currentConfig); +}; + +const defaultOutDir = outputConfigShape.outDir.parse(undefined); + +/** + * Builds an `output` equivalent to the deprecated `outDir` and `clean` fields. + * Returns `undefined` when both have default behaviour, since the default `output` covers it. + * + * `cleanDir` defaults to `true`, so an explicit `clean: false` is carried over as `cleanDir: false` on every output. + * Otherwise the migrated config would delete output directories the user opted out of cleaning. + * A missing `clean` or `clean: true` uses the new default. + */ +export const toOutput = (outDir: string | undefined, { clean }: { clean?: boolean } = {}) => { + const isDefaultDir = outDir === undefined || path.posix.normalize(outDir) === path.posix.normalize(defaultOutDir); + const keepFiles = clean === false; + + if (isDefaultDir && !keepFiles) { + return undefined; + } + + const cleanDir = keepFiles ? { cleanDir: false } : {}; + + return [ + { type: 'design-tokens' as const, ...(!isDefaultDir && { dir: outDir }), ...cleanDir }, + // CSS is built from the design tokens, so it must read them from the same directory. + { type: 'css' as const, ...(!isDefaultDir && { tokensDir: outDir }), ...cleanDir }, + ]; +}; + +/** + * `outDir` was resolved from the directory the CLI was run from, while `output` paths are resolved from the + * config file's directory. Rewrites `outDir` so it points to the same directory when resolved from the config file. + * Paths always use forward slashes, so the config works on any OS. + */ +const toConfigRelative = (outDir: string, { configFilePath, cwd = process.cwd() }: MigrationContext): string => { + if (!configFilePath) { + return outDir; + } + + const configDir = path.dirname(path.resolve(cwd, configFilePath)); + const relative = path.relative(configDir, path.resolve(cwd, outDir)) || '.'; + + return relative.split(path.sep).join('/'); +}; + +export const migrateToOutputField = (config: string, context: MigrationContext = {}): string => { + const currentConfig = parseJsonc(config); + + // Apply targeted edits to the original text instead of re-serializing the whole + // config, so comments, formatting and trailing commas are preserved. + let configText = config; + + // If `output` is already set, the deprecated fields are ignored and can simply be removed. + if (!currentConfig.output) { + // A missing `outDir` meant the default directory, relative to where the CLI was run from. + const output = toOutput(toConfigRelative(currentConfig.outDir ?? defaultOutDir, context), { + clean: currentConfig.clean, + }); + if (output) { + configText = applyEdits( + configText, + modify(configText, ['output'], output, { + formattingOptions, + // Place `output` right after `$schema` if present, otherwise at the top. + getInsertionIndex: (properties) => properties.indexOf('$schema') + 1, + }), + ); + } + } + + for (const key of deprecatedFields) { + configText = removeProperty(configText, key); + } + + return configText; +}; + +const migration: Automigrate = { + name: 'New output field', + check: hasDeprecatedFields, + message: `Your config file uses the deprecated ${pc.yellow('outDir')} and ${pc.yellow('clean')} fields. \nThis migration will replace them with a new ${pc.blue('output')} field if necessary.\n`, + yes: (config: string, context?: MigrationContext): string => { + const migratedConfig = migrateToOutputField(config, context); + console.log(pc.green(`\nConfig file successfully migrated.`)); + if (typeof parseJsonc(migratedConfig).output === 'undefined') { + console.log( + pc.green( + `\nNo new output field was added because the deprecated fields matched outputs default values and does not need to be added explicitly.`, + ), + ); + } + + return migratedConfig; + }, + no: (config: string): string => { + // The file is left as-is. `designsystemet` stops when `outDir` or `clean` are still set, while `tokens create` + // keeps reading them. + console.log(pc.yellow('\nMigration was skipped.\n')); + return config; + }, +}; + +export default migration; diff --git a/packages/cli/src/schemas/__snapshots__/config.schema.json b/packages/cli/src/schemas/__snapshots__/config.schema.json index cc3187b039..895c0ac100 100644 --- a/packages/cli/src/schemas/__snapshots__/config.schema.json +++ b/packages/cli/src/schemas/__snapshots__/config.schema.json @@ -2,14 +2,97 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": { + "output": { + "description": "An array of output types. These are run in the order they are specified.", + "default": [ + "design-tokens", + "css" + ], + "type": "array", + "items": { + "anyOf": [ + { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "design-tokens", + "description": "The type of output file" + }, + "dir": { + "default": "design-tokens", + "description": "The output directory", + "type": "string" + }, + "cleanDir": { + "default": true, + "description": "Whether to clean the output directory before generating files", + "type": "boolean" + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "css", + "description": "The type of output file" + }, + "dir": { + "default": "design-tokens-build", + "description": "The output directory", + "type": "string" + }, + "cleanDir": { + "default": true, + "description": "Whether to clean the output directory before generating files", + "type": "boolean" + }, + "tokensDir": { + "description": "The directory containing the design tokens to build CSS from. Defaults to `dir` of the `design-tokens` output. If neither is set, CSS is created directly from the themes", + "type": "string" + }, + "experimental_tailwind": { + "default": true, + "description": "Whether to enable experimental Tailwind support", + "type": "boolean" + } + }, + "required": [ + "type" + ] + } + ], + "description": "An object representing an output file" + }, + { + "type": "string", + "enum": [ + "design-tokens", + "css" + ], + "description": "An output type using its default settings" + } + ], + "description": "An output file, either as an object or an output type using its default settings." + } + }, "outDir": { "default": "design-tokens", - "description": "Path to the output directory for the created design tokens", + "deprecated": true, + "description": "Deprecated: use `output[].dir` instead. Path to the output directory for the created design tokens", "type": "string" }, "clean": { "default": false, - "description": "Delete the output directory before building or creating tokens", + "deprecated": true, + "description": "Deprecated: use `output[].cleanDir` instead. Delete the output directory before creating tokens", "type": "boolean" }, "themes": { @@ -186,8 +269,5 @@ }, "description": "An object with one or more themes. Each property defines a theme, and the property name is used as the theme name. All themes must define the same color names." } - }, - "required": [ - "themes" - ] + } } diff --git a/packages/cli/src/schemas/schema-output.ts b/packages/cli/src/schemas/schema-output.ts index 51545aa9e5..24e1025524 100644 --- a/packages/cli/src/schemas/schema-output.ts +++ b/packages/cli/src/schemas/schema-output.ts @@ -1,3 +1,4 @@ +import pc from 'picocolors'; import { z } from 'zod'; const designTokensOutputSchema = z.object({ @@ -10,8 +11,12 @@ const cssOutputSchema = z.object({ type: z.literal('css').describe('The type of output file'), dir: z.string().default('design-tokens-build').describe('The output directory'), cleanDir: z.boolean().default(true).describe('Whether to clean the output directory before generating files'), - tokenDir: z.string().default('design-tokens').describe('The directory containing the design tokens'), - banner: z.string().default('').describe('A banner to include at the top of the CSS file'), + tokensDir: z + .string() + .optional() + .describe( + 'The directory containing the design tokens to build CSS from. Defaults to `dir` of the `design-tokens` output. If neither is set, CSS is created directly from the themes', + ), experimental_tailwind: z.boolean().default(true).describe('Whether to enable experimental Tailwind support'), }); @@ -27,45 +32,42 @@ const outputShorthandSchema = z const outputSchema = z .union([outputObjectSchema, outputShorthandSchema]) - .describe('An output file, either as an object or an output type using its default settings'); + .describe('An output file, either as an object or an output type using its default settings.'); + +/** Fields superseded by `output`. Kept so existing config files and `tokens create` keep working. */ +const deprecatedFields = ['outDir', 'clean'] as const; /** The output settings of a config. `outDir` and `clean` are used by `tokens create`, `output` by the `config` command. */ export const outputConfigShape = { - output: z.array(outputSchema).prefault(['design-tokens', 'css']).describe('An array of output files'), - outDir: z - .string() - .default('design-tokens') - .meta({ description: 'Path to the output directory for the created design tokens' }), + output: z + .array(outputSchema) + .prefault(['design-tokens', 'css']) + .describe('An array of output types. These are run in the order they are specified.'), + /** @deprecated Use `output[].dir` instead. */ + outDir: z.string().default('design-tokens').meta({ + deprecated: true, + description: 'Deprecated: use `output[].dir` instead. Path to the output directory for the created design tokens', + }), + /** @deprecated Use `output[].cleanDir` instead. */ clean: z .boolean() .default(false) - .meta({ description: 'Delete the output directory before building or creating tokens' }) + .meta({ + deprecated: true, + description: 'Deprecated: use `output[].cleanDir` instead. Delete the output directory before creating tokens', + }) .optional(), }; -// /** Fields superseded by `output`. Kept so existing config files keep validating. */ -// const deprecatedFields = ['outDir', 'clean'] as const; - -// /** The `output` field and the deprecated fields it superseded. */ -// export const outputConfigShape = { -// output: z.array(outputSchema).prefault(['design-tokens', 'css']).describe('An array of output files'), -// // No `.default()` on the deprecated fields: we need `undefined` when the user did not -// // set them, so we can warn only when they actually did. -// outDir: z.string().optional().meta({ -// deprecated: true, -// description: 'Deprecated: use `output[].dir` instead. Ignored when `output` is set.', -// }), -// clean: z.boolean().optional().meta({ -// deprecated: true, -// description: 'Deprecated: use `output[].cleanDir` instead. Ignored when `output` is set.', -// }), -// }; - -// // Non-fatal: warn about deprecated fields instead of failing validation. -// export const warnDeprecatedFields = (config: Partial>) => { -// for (const key of deprecatedFields) { -// if (config[key] !== undefined) { -// console.warn(pc.yellow(`⚠️ "${key}" is deprecated and ignored; use "output" instead.`)); -// } -// } -// }; +/** Non-fatal: warn about deprecated fields set in a config file instead of failing validation. */ +export const warnDeprecatedFields = (config: Partial>) => { + for (const key of deprecatedFields) { + if (config[key] !== undefined) { + console.warn( + pc.yellow( + `⚠️ Config field "${key}" is deprecated and will be removed in a future release; use "output" instead.`, + ), + ); + } + } +}; diff --git a/packages/cli/src/schemas/schema.ts b/packages/cli/src/schemas/schema.ts index d8c991b049..75b0749e54 100644 --- a/packages/cli/src/schemas/schema.ts +++ b/packages/cli/src/schemas/schema.ts @@ -221,7 +221,8 @@ export const themesSchema = z .meta({ description: 'An object with one or more themes. Each property defines a theme, and the property name is used as the theme name. All themes must define the same color names, size configuration, shadows, border widths, opacities, border-radius step names and typography sets.', - }); + }) + .optional(); export type ConfigSchemaTheme = z.infer; /** The pre-validation shape of a theme, i.e. what users write: defaulted fields are optional. */ @@ -235,6 +236,9 @@ export const configSchema = z.object({ export type ConfigSchema = z.infer; +/** The themes of a config. `themes` is optional, since it's only needed by outputs that are created from themes. */ +export type ConfigSchemaThemes = NonNullable; + export type ConfigSchemaInput = z.input; /** @@ -264,15 +268,19 @@ const externalThemeSchema = themeObjectSchema .meta({ description: 'An object defining a theme. The property name holding the object becomes the theme name.' }); /** - * The public config: {@link configSchema} without `output`, and with themes restricted to the public keys. + * The public config: {@link configSchema}, including `output`, with themes restricted to the public keys. * Use this when exposing the schema externally (the public JSON schema, the theme builder and the Figma plugin); * use {@link configSchema} to validate a config in the CLI. */ -export const externalConfigSchema = configSchema.omit({ output: true }).extend({ - themes: z.record(z.string(), externalThemeSchema).superRefine(checkThemes).meta({ - description: - 'An object with one or more themes. Each property defines a theme, and the property name is used as the theme name. All themes must define the same color names.', - }), +export const externalConfigSchema = configSchema.extend({ + themes: z + .record(z.string(), externalThemeSchema) + .superRefine(checkThemes) + .meta({ + description: + 'An object with one or more themes. Each property defines a theme, and the property name is used as the theme name. All themes must define the same color names.', + }) + .optional(), }); export type ExternalConfigSchema = z.infer; diff --git a/packages/cli/src/scripts/update-preview-tokens.ts b/packages/cli/src/scripts/update-preview-tokens.ts index 1df421ae44..ed71fd6e4b 100644 --- a/packages/cli/src/scripts/update-preview-tokens.ts +++ b/packages/cli/src/scripts/update-preview-tokens.ts @@ -104,6 +104,10 @@ const formatTheme = async (themeConfig: Theme) => { // Parse the config through the schema so defaults (typography, borderRadius, size) are applied. const { themes } = validateConfig(configSchema, config); +if (!themes) { + throw new Error('No themes found in designsystemet.config.json'); +} + formatTheme({ name: 'test', borderRadius: themes.designsystemet.borderRadius, diff --git a/packages/cli/src/tokens/css-variables.test.ts b/packages/cli/src/tokens/css-variables.test.ts index d01ff9cad5..9157e6cf23 100644 --- a/packages/cli/src/tokens/css-variables.test.ts +++ b/packages/cli/src/tokens/css-variables.test.ts @@ -79,7 +79,7 @@ describe('cssVariableName matches the tokens build output', () => { const config = configSchema.parse({ themes: { [themeName]: { colors: { neutral: '#444444', brand: '#0062BA' } } }, }); - const theme = { name: themeName, ...config.themes[themeName] } as Theme; + const theme = { name: themeName, ...config.themes?.[themeName] } as Theme; const files = await formatThemeCSS(theme, { verbose: false, tailwind: false }); const css = files.map((file) => file.output).join('\n'); diff --git a/packages/cli/src/tokens/generate-config.test.ts b/packages/cli/src/tokens/generate-config.test.ts new file mode 100644 index 0000000000..d3c01ca0a7 --- /dev/null +++ b/packages/cli/src/tokens/generate-config.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { toConfigTokensDir } from './generate-config.ts'; + +describe('toConfigTokensDir', () => { + it('returns the tokens directory relative to the config directory', () => { + expect(toConfigTokensDir('/project/design-tokens', '/project')).toBe('design-tokens'); + expect(toConfigTokensDir('/project/tokens', '/project/configs')).toBe('../tokens'); + expect(toConfigTokensDir('/project/src/design-tokens', '/project')).toBe('src/design-tokens'); + }); + + it('allows a sibling directory whose name starts with the tokens directory name', () => { + expect(toConfigTokensDir('/project/tokens', '/project/tokens-config')).toBe('../tokens'); + }); + + // The generated `design-tokens` output would point to `.` or `..`, which `cleanDir` would delete. + it.each([ + ['the tokens directory', '/project/design-tokens'], + ['a subdirectory of the tokens directory', '/project/design-tokens/configs'], + ])('rejects a config file in %s', (_, configDir) => { + expect(() => toConfigTokensDir('/project/design-tokens', configDir)).toThrow(/inside the design tokens directory/); + }); +}); diff --git a/packages/cli/src/tokens/generate-config.ts b/packages/cli/src/tokens/generate-config.ts index 75f2b06ecc..7959eeacdb 100644 --- a/packages/cli/src/tokens/generate-config.ts +++ b/packages/cli/src/tokens/generate-config.ts @@ -1,8 +1,10 @@ import path from 'node:path'; import pc from 'picocolors'; import type { CssColor } from '../colors/types.ts'; +import { toOutput } from '../migrations/new-output-field.ts'; import type { ExternalConfigSchemaInput } from '../schemas/schema.ts'; import { dsfs } from '../utils/filesystem.ts'; +import { isSameOrInside } from '../utils/paths.ts'; type TokenValue = { $type: string; @@ -189,14 +191,38 @@ function extractColors(themeTokens: TokenObject, themeName: string): Record { + const absoluteTokensDir = path.resolve(tokensDir); + + if (isSameOrInside(configDir, absoluteTokensDir)) { + throw new Error( + `The config file can't be placed inside the design tokens directory ${pc.blue(absoluteTokensDir)}, since running the config would delete that directory. Use ${pc.blue('--out')} to place the config file outside it.`, + ); + } + + return path.relative(path.resolve(configDir), absoluteTokensDir).split(path.sep).join('/'); +}; + /** * Generates a config file from existing design tokens */ export async function generateConfigFromTokens(options: GenerateConfigOptions): Promise { - const { tokensDir } = options; + const { tokensDir, outFile } = options; + + // Check the paths before reading any tokens, so an unsafe `--out` fails right away. + const configDir = outFile ? path.dirname(path.resolve(outFile)) : process.cwd(); + const relativeTokensDir = toConfigTokensDir(tokensDir, configDir); console.log(`\nReading tokens from ${pc.blue(tokensDir)}`); @@ -210,9 +236,12 @@ export async function generateConfigFromTokens(options: GenerateConfigOptions): console.log(`\nFound ${pc.green(String(themes.length))} theme(s): ${themes.map((t) => pc.cyan(t)).join(', ')}`); // Generate config for each theme + const configThemes: NonNullable = {}; + const output = toOutput(relativeTokensDir); const config: ExternalConfigSchemaInput = { - outDir: tokensDir, - themes: {}, + // Omitted when the tokens are in the default directory, since the default `output` covers it. + ...(output && { output: [...output] }), + themes: configThemes, }; for (const themeName of themes) { @@ -234,7 +263,7 @@ export async function generateConfigFromTokens(options: GenerateConfigOptions): const borderRadius = extractBorderRadius(themeConfig); const fontFamily = extractFontFamily(themeConfig) ?? extractFontFamilyFromPrimitives(typographyConfig, themeName); - config.themes[themeName] = { + configThemes[themeName] = { colors, borderRadius, typography: fontFamily ? { fontFamily } : undefined, diff --git a/packages/cli/src/utils/paths.test.ts b/packages/cli/src/utils/paths.test.ts new file mode 100644 index 0000000000..a6bb7613c6 --- /dev/null +++ b/packages/cli/src/utils/paths.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { isSameOrInside } from './paths.ts'; + +describe('isSameOrInside', () => { + it('is true for the directory itself and anything inside it', () => { + expect(isSameOrInside('/project', '/project')).toBe(true); + expect(isSameOrInside('/project/configs', '/project')).toBe(true); + expect(isSameOrInside('/project/a/b', '/project')).toBe(true); + }); + + it('is false for parents and siblings', () => { + expect(isSameOrInside('/project', '/project/configs')).toBe(false); + expect(isSameOrInside('/project/other', '/project/configs')).toBe(false); + }); + + it('does not treat a sibling whose name starts with the directory name as inside it', () => { + expect(isSameOrInside('/project/tokens-config', '/project/tokens')).toBe(false); + }); + + it('resolves relative paths', () => { + expect(isSameOrInside('configs/..', '.')).toBe(true); + }); +}); diff --git a/packages/cli/src/utils/paths.ts b/packages/cli/src/utils/paths.ts new file mode 100644 index 0000000000..efad33e50b --- /dev/null +++ b/packages/cli/src/utils/paths.ts @@ -0,0 +1,8 @@ +import path from 'node:path'; + +/** Whether `dir` is `parent` itself or somewhere inside it. */ +export const isSameOrInside = (dir: string, parent: string): boolean => { + const fromParent = path.relative(path.resolve(parent), path.resolve(dir)); + + return fromParent !== '..' && !fromParent.startsWith(`..${path.sep}`) && !path.isAbsolute(fromParent); +}; diff --git a/packages/cli/tests/config/build/colors.d.ts b/packages/cli/tests/config/build/colors.d.ts index 5336820273..b6ba694959 100644 --- a/packages/cli/tests/config/build/colors.d.ts +++ b/packages/cli/tests/config/build/colors.d.ts @@ -1,5 +1,5 @@ /* @deprecated: This file will be removed in a future release. Use types.d.ts instead */ -/* build: v1.22.0 */ +/* build: v1.23.0 */ import type {} from '@digdir/designsystemet-types'; // Augment types based on theme diff --git a/packages/cli/tests/config/build/other-org.css b/packages/cli/tests/config/build/other-org.css index 2d8a7860dd..2a451a7f57 100644 --- a/packages/cli/tests/config/build/other-org.css +++ b/packages/cli/tests/config/build/other-org.css @@ -1,8 +1,8 @@ @charset "UTF-8"; @layer ds.theme.color-scheme, ds.theme.color, ds.theme.forced-colors; /* -build: v1.22.0 -design-tokens: v1.22.0 +build: v1.23.0 +design-tokens: v1.23.0 */ @layer ds.theme.size-mode { diff --git a/packages/cli/tests/config/build/some-org.css b/packages/cli/tests/config/build/some-org.css index 214d67cd7a..6ce4f0546b 100644 --- a/packages/cli/tests/config/build/some-org.css +++ b/packages/cli/tests/config/build/some-org.css @@ -1,8 +1,8 @@ @charset "UTF-8"; @layer ds.theme.color-scheme, ds.theme.color, ds.theme.forced-colors; /* -build: v1.22.0 -design-tokens: v1.22.0 +build: v1.23.0 +design-tokens: v1.23.0 */ @layer ds.theme.size-mode { diff --git a/packages/cli/tests/config/build/types.d.ts b/packages/cli/tests/config/build/types.d.ts index e022e9d461..b433bc9584 100644 --- a/packages/cli/tests/config/build/types.d.ts +++ b/packages/cli/tests/config/build/types.d.ts @@ -1,4 +1,4 @@ -/* build: v1.22.0 */ +/* build: v1.23.0 */ import type {} from '@digdir/designsystemet-types'; // Augment types based on theme diff --git a/packages/cli/tests/config/design-tokens/$designsystemet.jsonc b/packages/cli/tests/config/design-tokens/$designsystemet.jsonc index db04db9041..d7503e3bb0 100644 --- a/packages/cli/tests/config/design-tokens/$designsystemet.jsonc +++ b/packages/cli/tests/config/design-tokens/$designsystemet.jsonc @@ -1,4 +1,4 @@ { "name": "@digdir/designsystemet", - "version": "1.22.0" + "version": "1.23.0" } \ No newline at end of file diff --git a/plugins/designsystemet/src/plugin/code.ts b/plugins/designsystemet/src/plugin/code.ts index 427edb47a0..50ed730897 100644 --- a/plugins/designsystemet/src/plugin/code.ts +++ b/plugins/designsystemet/src/plugin/code.ts @@ -62,17 +62,19 @@ figma.ui.onmessage = async (msg: FigmaMessages) => { externalConfig, ); - themeNames = Object.keys(config.themes ?? {}); + // `themes` is optional in the config, but the plugin creates everything from themes. + const { themes } = config; + if (!themes || Object.keys(themes).length === 0) { + throw new Error('The config must define at least one theme.'); + } + + themeNames = Object.keys(themes); // The dimensions come from the first theme, mirroring the CLI: size modes and // typography sets are expected to be the same across themes. - const tokenSetDimensions = getTokenSetDimensions( - config.themes[themeNames[0]], - ); + const tokenSetDimensions = getTokenSetDimensions(themes[themeNames[0]]); - for (const [themeName, themeConfig] of Object.entries( - config.themes, - ) as [string, ConfigSchema['themes'][string]][]) { + for (const [themeName, themeConfig] of Object.entries(themes)) { const themeTokens = await createTokens( { name: themeName, diff --git a/plugins/designsystemet/src/ui/app.tsx b/plugins/designsystemet/src/ui/app.tsx index 7caccfc704..a878da32d1 100644 --- a/plugins/designsystemet/src/ui/app.tsx +++ b/plugins/designsystemet/src/ui/app.tsx @@ -37,7 +37,7 @@ function reducer(state: UiState, action: Action): UiState { return { ...state, config: action.config, - selectedTheme: Object.keys(action.config.themes)[0] ?? null, + selectedTheme: Object.keys(action.config.themes ?? {})[0] ?? null, selectedScheme: action.scheme, notification: action.notification, }; diff --git a/plugins/designsystemet/src/ui/preview-view.tsx b/plugins/designsystemet/src/ui/preview-view.tsx index e6f041074e..ac176c52df 100644 --- a/plugins/designsystemet/src/ui/preview-view.tsx +++ b/plugins/designsystemet/src/ui/preview-view.tsx @@ -16,7 +16,7 @@ type PreviewViewProps = { onSelectScheme: (scheme: string) => void; }; -type ThemeConfig = ConfigSchema['themes'][string]; +type ThemeConfig = NonNullable[string]; // Pascal case to match the Figma variable modes ('Light'/'Dark'). const COLOR_SCHEME_OPTIONS = ['Light', 'Dark']; @@ -32,9 +32,10 @@ export function PreviewView({ onSelectTheme, onSelectScheme, }: PreviewViewProps): React.JSX.Element { - const themeNames = Object.keys(config.themes); + const themes = config.themes ?? {}; + const themeNames = Object.keys(themes); const themeName = pickOption(themeNames, selectedTheme); - const theme = themeName ? config.themes[themeName] : null; + const theme = themeName ? themes[themeName] : null; const scheme = selectedScheme.toLowerCase() as ColorScheme; return (