@kanso-labs/unplugin-style-dictionary 0.10.17 → 0.10.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +127 -87
- package/dist/compile.js.map +1 -1
- package/dist/types.d.ts +49 -48
- package/dist/up-to-date.js +19 -8
- package/dist/up-to-date.js.map +1 -1
- package/package.json +5 -4
package/README.md
CHANGED
|
@@ -25,9 +25,8 @@ them.
|
|
|
25
25
|
bullet follows it rather than standing beside it.
|
|
26
26
|
- **Automatic watching**: Reads the `source` and `include` patterns from your
|
|
27
27
|
Style Dictionary configurations and watches the files they match, including a
|
|
28
|
-
token package resolved through `node_modules` in a workspace
|
|
29
|
-
|
|
30
|
-
change then triggers depends on the target — see
|
|
28
|
+
token package resolved through `node_modules` in a workspace. What a change
|
|
29
|
+
then triggers depends on the target — see
|
|
31
30
|
[Watching, per target](#watching-per-target).
|
|
32
31
|
- **Config flexibility**: Supports file paths (JSON, JSON5, JSONC, JS, MJS, TS),
|
|
33
32
|
configuration objects, or functions — including registering custom formats at
|
|
@@ -74,10 +73,12 @@ npm error Could not resolve dependency:
|
|
|
74
73
|
npm error peer style-dictionary@"^5.0.0" from @kanso-labs/unplugin-style-dictionary@0.9.0
|
|
75
74
|
```
|
|
76
75
|
|
|
77
|
-
**This package needs Node 22.12 or newer.** The floor is
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
a
|
|
76
|
+
**This package needs Node 22.12 or newer.** The floor is where two ranges meet,
|
|
77
|
+
neither of them this plugin's own: `unplugin`, a runtime dependency, declares
|
|
78
|
+
`engines.node` `^20.19.0 || >=22.12.0`, and every Style Dictionary 5.x release,
|
|
79
|
+
a required peer rather than an optional one, declares `>=22.0.0`. Only 22.12 and
|
|
80
|
+
newer satisfies both, so an older Node cannot install a working set at all. Node
|
|
81
|
+
20 reached end of life on 30 April 2026.
|
|
81
82
|
|
|
82
83
|
## Usage
|
|
83
84
|
|
|
@@ -99,6 +100,13 @@ export default defineConfig({
|
|
|
99
100
|
})
|
|
100
101
|
```
|
|
101
102
|
|
|
103
|
+
Keep the generated files out of Vite's `build.outDir`, which is `dist` unless
|
|
104
|
+
you change it. When that directory sits inside the project root, `vite build`
|
|
105
|
+
empties it once this plugin has compiled, and again on every
|
|
106
|
+
`vite build --watch` rebuild, so tokens written there are gone by the time the
|
|
107
|
+
build finishes. Write them somewhere else, such as `build/`, or set
|
|
108
|
+
`build.emptyOutDir` to `false`.
|
|
109
|
+
|
|
102
110
|
### Rolldown
|
|
103
111
|
|
|
104
112
|
```typescript
|
|
@@ -118,8 +126,8 @@ typically run as a one-shot build rather than a long-lived dev server. There the
|
|
|
118
126
|
plugin compiles tokens once in `buildStart`, which is enough to guarantee
|
|
119
127
|
generated token files exist before the rest of the build consumes them.
|
|
120
128
|
|
|
121
|
-
Under a real `rolldown.watch()`,
|
|
122
|
-
|
|
129
|
+
Under a real `rolldown.watch()`, a token edit rebuilds, as it does under the
|
|
130
|
+
other targets' watch modes — see [Watching, per target](#watching-per-target).
|
|
123
131
|
|
|
124
132
|
### Rollup / Webpack / Rspack
|
|
125
133
|
|
|
@@ -236,7 +244,7 @@ StyleDictionary({
|
|
|
236
244
|
platforms: {
|
|
237
245
|
css: {
|
|
238
246
|
transformGroup: 'css',
|
|
239
|
-
buildPath: '
|
|
247
|
+
buildPath: 'build/css/',
|
|
240
248
|
files: [{ destination: 'variables.css', format: 'css/variables' }],
|
|
241
249
|
},
|
|
242
250
|
},
|
|
@@ -270,7 +278,7 @@ styleDictionaryPlugin({
|
|
|
270
278
|
platforms: {
|
|
271
279
|
css: {
|
|
272
280
|
transformGroup: 'css',
|
|
273
|
-
buildPath: '
|
|
281
|
+
buildPath: 'build/',
|
|
274
282
|
files: [{ destination: 'vars.css', format: 'css/variables' }],
|
|
275
283
|
},
|
|
276
284
|
// Shells out to a native toolchain, so it is worth a minute of a real
|
|
@@ -314,7 +322,7 @@ export default defineConfig({
|
|
|
314
322
|
platforms: {
|
|
315
323
|
custom: {
|
|
316
324
|
transformGroup: 'css',
|
|
317
|
-
buildPath: '
|
|
325
|
+
buildPath: 'build/',
|
|
318
326
|
files: [
|
|
319
327
|
{ destination: 'tokens.txt', format: 'custom/my-format' },
|
|
320
328
|
],
|
|
@@ -350,26 +358,28 @@ Every target compiles tokens before the build that consumes them. What a later
|
|
|
350
358
|
change to a token file triggers is not the same everywhere, because it depends
|
|
351
359
|
on what the host bundler does with the watch list the plugin registers.
|
|
352
360
|
|
|
353
|
-
| Target | Compiles before the build | Rebuilds on a token change
|
|
354
|
-
| ------------------------- | ------------------------- |
|
|
355
|
-
| **Vite** | yes | yes, under the dev server
|
|
356
|
-
| **Vite**, `build --watch` | yes | yes on 6
|
|
357
|
-
| **Rollup** | yes | yes, under `rollup --watch`
|
|
358
|
-
| **Webpack** | yes | yes, under `webpack --watch`
|
|
359
|
-
| **Rspack** | yes | yes, under `rspack --watch`
|
|
360
|
-
| **Rolldown** | yes |
|
|
361
|
+
| Target | Compiles before the build | Rebuilds on a token change | Safe from rebuild loops |
|
|
362
|
+
| ------------------------- | ------------------------- | ----------------------------- | ----------------------- |
|
|
363
|
+
| **Vite** | yes | yes, under the dev server | yes |
|
|
364
|
+
| **Vite**, `build --watch` | yes | yes, on 6, 7 and 8 | yes |
|
|
365
|
+
| **Rollup** | yes | yes, under `rollup --watch` | yes |
|
|
366
|
+
| **Webpack** | yes | yes, under `webpack --watch` | yes |
|
|
367
|
+
| **Rspack** | yes | yes, under `rspack --watch` | yes |
|
|
368
|
+
| **Rolldown** | yes | yes, under `rolldown.watch()` | yes |
|
|
361
369
|
|
|
362
370
|
Patterns and literal paths behave the same way wherever rebuilds happen at all.
|
|
363
371
|
A `source` of `tokens/**/*.json` matches a file sitting directly in `tokens/` as
|
|
364
372
|
well as one in a subdirectory, and a file created after the watcher started is
|
|
365
373
|
picked up too.
|
|
366
374
|
|
|
367
|
-
**Rolldown
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
375
|
+
**Rolldown rebuilds on macOS too, a token reached through a symbolic link
|
|
376
|
+
included.** Its watcher there drops the events for a path registered through a
|
|
377
|
+
link — a workspace package linked into `node_modules`, or a project under `/var`
|
|
378
|
+
or `/tmp`, both of which are links on macOS — so the plugin registers each such
|
|
379
|
+
path by its realpath as well, and maps the event back to the spelling your
|
|
380
|
+
`source` patterns use. This table used to call rolldown platform-dependent, and
|
|
381
|
+
it was the link: the measurement behind that ran in a directory under `/var`.
|
|
382
|
+
The same cases rebuild on Linux and on Windows, where CI runs them.
|
|
373
383
|
|
|
374
384
|
**A token package resolved through `node_modules` is watched too, and that took
|
|
375
385
|
a fix.** In a workspace — `app/node_modules/@acme/tokens` symlinked to
|
|
@@ -384,8 +394,9 @@ is thousands of files no token build reads.
|
|
|
384
394
|
`vite build --watch` had the same problem and gets the same fix on Vite 6 and 7,
|
|
385
395
|
which build on rollup's watcher and its chokidar ignore list: the plugin appends
|
|
386
396
|
the same negations to `build.watch.chokidar.ignored`. Vite 8 builds with
|
|
387
|
-
rolldown, which takes no ignore list from there
|
|
388
|
-
|
|
397
|
+
rolldown, which takes no ignore list from there and needs none: a linked token
|
|
398
|
+
package rebuilds there through the realpath registration the Rolldown note above
|
|
399
|
+
describes.
|
|
389
400
|
|
|
390
401
|
Nothing is needed from you for that. If you had worked around it with a
|
|
391
402
|
`server.watch.ignored` or `build.watch.chokidar.ignored` negation of your own,
|
|
@@ -442,6 +453,14 @@ So treat the build directory as disposable: delete it when a configuration
|
|
|
442
453
|
changes shape, and keep it out of version control and out of any directory
|
|
443
454
|
holding hand-written files.
|
|
444
455
|
|
|
456
|
+
Keep it out of the bundler's own output directory as well, which is the opposite
|
|
457
|
+
problem. Vite empties `build.outDir` when it starts writing its bundle, after
|
|
458
|
+
this plugin has compiled, whenever that directory sits inside the project root —
|
|
459
|
+
`dist`, by default — so a `buildPath` under it leaves nothing behind after
|
|
460
|
+
`vite build`, and nothing after a `vite build --watch` rebuild either. An
|
|
461
|
+
`outDir` outside the root is not emptied unless `build.emptyOutDir` asks for it.
|
|
462
|
+
Put the `buildPath` somewhere else, or set `build.emptyOutDir` to `false`.
|
|
463
|
+
|
|
445
464
|
There is deliberately no `clean` option. Style Dictionary's
|
|
446
465
|
`cleanAllPlatforms()` does not solve this — it removes the destinations the
|
|
447
466
|
_current_ configuration declares, which are exactly the files that are not
|
|
@@ -476,8 +495,19 @@ the nearest `package.json`, so it survives a restart. Where there is no record,
|
|
|
476
495
|
the files a configuration reads are compared against the files it declares.
|
|
477
496
|
|
|
478
497
|
A declared file that changed since the compile that wrote it is compiled over
|
|
479
|
-
rather than trusted, whatever its mtime says.
|
|
480
|
-
|
|
498
|
+
rather than trusted, whatever its mtime says. Each compile records the mtime,
|
|
499
|
+
size and inode of every file it wrote, and a file that no longer has all three
|
|
500
|
+
is built again: a rewrite of another length moves the size, and a file replaced
|
|
501
|
+
by a rename, which is how atomic-save editors and `rsync` write, moves the
|
|
502
|
+
inode, even where the mtime was put back or never moved. Generated output is
|
|
503
|
+
disposable, so an edit made to it by hand does not survive the next build.
|
|
504
|
+
|
|
505
|
+
One rewrite is still not seen: one of the same length, made in place, inside the
|
|
506
|
+
timestamp tick the compile wrote in — about 15ms on Windows, and a whole second
|
|
507
|
+
or more on HFS+, ext3 and FAT. It moves none of the three, and only reading
|
|
508
|
+
every output on every build could catch it, which is the cost the skip exists to
|
|
509
|
+
avoid. Set `cache: false` where something rewrites generated output that
|
|
510
|
+
quickly.
|
|
481
511
|
|
|
482
512
|
Two cases never skip, because neither can be settled from the filesystem:
|
|
483
513
|
|
|
@@ -709,12 +739,12 @@ the one that broke the build.
|
|
|
709
739
|
|
|
710
740
|
Small on purpose. Five bundler entry points, one root entry, and two types.
|
|
711
741
|
|
|
712
|
-
| Import | What it is
|
|
713
|
-
| ----------------------------------------------------------- |
|
|
714
|
-
| `…/vite`, `…/rolldown`, `…/rollup`, `…/rspack`, `…/webpack` | Default export: the plugin for that bundler. Call it with the options below.
|
|
715
|
-
| `…` (the root) | Default export, also named `unplugin`: the unplugin instance
|
|
716
|
-
| `UnpluginStyleDictionaryOptions` | The options type, exported from every entry above.
|
|
717
|
-
| `StyleDictionaryConfigContext` | What the function form of `config` is handed, exported from every entry above.
|
|
742
|
+
| Import | What it is |
|
|
743
|
+
| ----------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
744
|
+
| `…/vite`, `…/rolldown`, `…/rollup`, `…/rspack`, `…/webpack` | Default export: the plugin for that bundler. Call it with the options below. |
|
|
745
|
+
| `…` (the root) | Default export, also named `unplugin`: the unplugin instance. Its `.vite`, `.rolldown`, `.rollup`, `.rspack` and `.webpack` are the five supported targets, the same plugins the subpaths export. It also carries unplugin's own `.esbuild`, `.farm`, `.unloader`, `.rsbuild`, `.bun` and `.raw`, which are not supported here: `.esbuild` throws on its first build, and nothing tests the others. |
|
|
746
|
+
| `UnpluginStyleDictionaryOptions` | The options type, exported from every entry above. |
|
|
747
|
+
| `StyleDictionaryConfigContext` | What the function form of `config` is handed, exported from every entry above. |
|
|
718
748
|
|
|
719
749
|
Anything not in that table is internal, whatever a build output happens to
|
|
720
750
|
contain. In particular the watch filter and the raw unplugin factory are not
|
|
@@ -723,62 +753,27 @@ takes a second `meta` argument — the bundler-identifying `UnpluginContextMeta`
|
|
|
723
753
|
that a consumer would have to construct by hand, so calling it the obvious way
|
|
724
754
|
is a type error rather than a plugin.
|
|
725
755
|
|
|
726
|
-
Reach for the root entry when
|
|
727
|
-
|
|
756
|
+
Reach for the root entry when one configuration object feeds more than one
|
|
757
|
+
bundler:
|
|
728
758
|
|
|
729
759
|
```typescript
|
|
730
760
|
import styleDictionary from '@kanso-labs/unplugin-style-dictionary'
|
|
731
761
|
|
|
732
|
-
const
|
|
762
|
+
const options = { config: 'sd.config.json' }
|
|
763
|
+
|
|
764
|
+
const forRollup = styleDictionary.rollup(options)
|
|
765
|
+
const forWebpack = styleDictionary.webpack(options)
|
|
733
766
|
```
|
|
734
767
|
|
|
768
|
+
It is not a way to reach a bundler that has no subpath. Every target without one
|
|
769
|
+
is unplugin's own and unsupported here. esbuild in particular fails outright:
|
|
770
|
+
the plugin registers its watch list before it compiles, and unplugin's esbuild
|
|
771
|
+
context throws on that registration, so `.esbuild` writes nothing even for a
|
|
772
|
+
one-shot build.
|
|
773
|
+
|
|
735
774
|
## Options Reference
|
|
736
775
|
|
|
737
776
|
````typescript
|
|
738
|
-
/**
|
|
739
|
-
* Options for the Style Dictionary unplugin factory, shared across all bundler
|
|
740
|
-
* targets (Vite, Rolldown, Rollup, Webpack).
|
|
741
|
-
*
|
|
742
|
-
* Every target compiles tokens before the build that consumes them. Live
|
|
743
|
-
* rebuild-on-change is driven by the host bundler's watch mode, because token
|
|
744
|
-
* source files sit outside the module graph: Vite's dev server, `rollup
|
|
745
|
-
* --watch` and `webpack --watch` all rebuild on a token change, and a one-shot
|
|
746
|
-
* build (e.g. `tsdown`/`rolldown build` without `--watch`) only builds once, in
|
|
747
|
-
* `buildStart`.
|
|
748
|
-
*
|
|
749
|
-
* What the plugin says goes through the host wherever the host has a channel
|
|
750
|
-
* for it: Vite's `config.logger`, the plugin context under rollup and
|
|
751
|
-
* rolldown, and `compilation.warnings` under webpack and rspack, which is what
|
|
752
|
-
* puts a failed compile in `stats.toJson()`. The progress lines and the size
|
|
753
|
-
* table take the host's `info` channel, and webpack and rspack have none, so
|
|
754
|
-
* there both go to the console. A failure is reported on the warning channel
|
|
755
|
-
* and never the error one — rollup's `this.error` aborts the bundle, and that
|
|
756
|
-
* decision is `failOnError`'s alone. Where no host offers a channel the console
|
|
757
|
-
* is used, with colour gated on `NO_COLOR`, `FORCE_COLOR` and whether the
|
|
758
|
-
* stream is a terminal.
|
|
759
|
-
*
|
|
760
|
-
* Style Dictionary's own lines are not the plugin's. The platform name and the
|
|
761
|
-
* `✔︎` for each file it wrote are its own `console` calls, so they reach the
|
|
762
|
-
* console under every host, whatever the host's log level. `logLevel: 'silent'`
|
|
763
|
-
* quiets them, as does `log.verbosity: 'silent'` in the configuration.
|
|
764
|
-
*
|
|
765
|
-
* The three `onBuild*` hooks are called synchronously and their return value
|
|
766
|
-
* is not awaited, so a build never waits for one. A hook may still be written
|
|
767
|
-
* `async`: a promise it returns is left to run on its own, and a rejection is
|
|
768
|
-
* caught and reported rather than reaching the host as an unhandled one. A
|
|
769
|
-
* hook that throws is reported and does not fail the build that called it.
|
|
770
|
-
*
|
|
771
|
-
* They return `Promise<void> | void` rather than `void` for that reason. Both
|
|
772
|
-
* accept an `async` hook as far as the compiler is concerned, but `void` alone
|
|
773
|
-
* makes one a `no-misused-promises` error under the type-aware lint rules a
|
|
774
|
-
* consumer is likely to be running — for a hook this documents as supported.
|
|
775
|
-
*
|
|
776
|
-
* Rolldown's watch mode is the exception, and it is not about glob patterns.
|
|
777
|
-
* `addWatchFile` is accepted either way, but what happens next differs by
|
|
778
|
-
* platform — on macOS a file registered through it is watched by nothing, while
|
|
779
|
-
* on a Linux runner the same edit reaches a rebuild. Do not rely on a token
|
|
780
|
-
* edit triggering a rebuild there.
|
|
781
|
-
*/
|
|
782
777
|
/**
|
|
783
778
|
* What the host is doing, handed to the function form of `config` so it can
|
|
784
779
|
* decide what to build.
|
|
@@ -819,6 +814,51 @@ export interface StyleDictionaryConfigContext {
|
|
|
819
814
|
watch: boolean
|
|
820
815
|
}
|
|
821
816
|
|
|
817
|
+
/**
|
|
818
|
+
* Options for the Style Dictionary unplugin factory, shared across all bundler
|
|
819
|
+
* targets (Vite, Rolldown, Rollup, Webpack, Rspack).
|
|
820
|
+
*
|
|
821
|
+
* Every target compiles tokens before the build that consumes them. Live
|
|
822
|
+
* rebuild-on-change is driven by the host bundler's watch mode, because token
|
|
823
|
+
* source files sit outside the module graph: Vite's dev server, `rollup
|
|
824
|
+
* --watch`, `rolldown.watch()`, `webpack --watch` and `rspack --watch` all
|
|
825
|
+
* rebuild on a token change, and a one-shot build (e.g. `tsdown`/`rolldown
|
|
826
|
+
* build` without `--watch`) only builds once, in `buildStart`.
|
|
827
|
+
*
|
|
828
|
+
* What the plugin says goes through the host wherever the host has a channel
|
|
829
|
+
* for it: Vite's `config.logger`, the plugin context under rollup and
|
|
830
|
+
* rolldown, and `compilation.warnings` under webpack and rspack, which is what
|
|
831
|
+
* puts a failed compile in `stats.toJson()`. The progress lines and the size
|
|
832
|
+
* table take the host's `info` channel, and webpack and rspack have none, so
|
|
833
|
+
* there both go to the console. A failure is reported on the warning channel
|
|
834
|
+
* and never the error one — rollup's `this.error` aborts the bundle, and that
|
|
835
|
+
* decision is `failOnError`'s alone. Where no host offers a channel the console
|
|
836
|
+
* is used, with colour gated on `NO_COLOR`, `FORCE_COLOR` and whether the
|
|
837
|
+
* stream is a terminal.
|
|
838
|
+
*
|
|
839
|
+
* Style Dictionary's own lines are not the plugin's. The platform name and the
|
|
840
|
+
* `✔︎` for each file it wrote are its own `console` calls, so they reach the
|
|
841
|
+
* console under every host, whatever the host's log level. `logLevel: 'silent'`
|
|
842
|
+
* quiets them, as does `log.verbosity: 'silent'` in the configuration.
|
|
843
|
+
*
|
|
844
|
+
* The three `onBuild*` hooks are called synchronously and their return value
|
|
845
|
+
* is not awaited, so a build never waits for one. A hook may still be written
|
|
846
|
+
* `async`: a promise it returns is left to run on its own, and a rejection is
|
|
847
|
+
* caught and reported rather than reaching the host as an unhandled one. A
|
|
848
|
+
* hook that throws is reported and does not fail the build that called it.
|
|
849
|
+
*
|
|
850
|
+
* They return `Promise<void> | void` rather than `void` for that reason. Both
|
|
851
|
+
* accept an `async` hook as far as the compiler is concerned, but `void` alone
|
|
852
|
+
* makes one a `no-misused-promises` error under the type-aware lint rules a
|
|
853
|
+
* consumer is likely to be running — for a hook this documents as supported.
|
|
854
|
+
*
|
|
855
|
+
* Rolldown's watch mode rebuilds on macOS as well, a token reached through a
|
|
856
|
+
* symbolic link included. Its watcher there drops the events for a path
|
|
857
|
+
* registered through a link — a workspace package linked into `node_modules`,
|
|
858
|
+
* or a project under `/var` or `/tmp`, both links on macOS — so each such path
|
|
859
|
+
* is registered by its realpath beside it, and the event is mapped back to the
|
|
860
|
+
* spelling the `source` patterns use.
|
|
861
|
+
*/
|
|
822
862
|
export interface UnpluginStyleDictionaryOptions {
|
|
823
863
|
/**
|
|
824
864
|
* Whether a configuration whose output is already up to date may skip its
|
|
@@ -926,7 +966,7 @@ export interface UnpluginStyleDictionaryOptions {
|
|
|
926
966
|
* frame naming this plugin, and the overlay is dismissed on the next
|
|
927
967
|
* rebuild that succeeds.
|
|
928
968
|
*
|
|
929
|
-
* This is Vite's overlay, so it does nothing on the other
|
|
969
|
+
* This is Vite's overlay, so it does nothing on the other four targets,
|
|
930
970
|
* and nothing under `vite build` — there is no page to draw on.
|
|
931
971
|
*
|
|
932
972
|
* It is not `failOnError`'s job, and the two are independent. `failOnError`
|
|
@@ -934,9 +974,9 @@ export interface UnpluginStyleDictionaryOptions {
|
|
|
934
974
|
* A dev server deliberately keeps serving through a failed rebuild, which is
|
|
935
975
|
* precisely the case where the overlay is the only thing that can say so.
|
|
936
976
|
*
|
|
937
|
-
* A
|
|
938
|
-
*
|
|
939
|
-
*
|
|
977
|
+
* A token file that fails to parse is reported like any other failed
|
|
978
|
+
* compile: it reaches the overlay, and `onBuildError`, with the parser's
|
|
979
|
+
* message.
|
|
940
980
|
*
|
|
941
981
|
* @default true
|
|
942
982
|
*/
|
package/dist/compile.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"compile.js","names":[],"sources":["../src/compile.ts"],"sourcesContent":["// One compile of every resolved configuration: building each one through the\n// atomic volume, recording what it wrote, and saying how it went.\n//\n// Nothing here holds state. What a plugin instance owns is handed in as a\n// `PluginInstance`, which the factory builds once, and the two parts of it\n// that are assigned after the factory has run — `root` and the overlay\n// callback — are reached through it when a compile runs rather than copied\n// when it was built.\n\nimport fs from 'node:fs'\nimport path from 'node:path'\nimport StyleDictionary from 'style-dictionary'\n\nimport type { ResolvedConfig } from './config.js'\nimport type { UnpluginStyleDictionaryOptions } from './types.js'\nimport type { DestinationRecord } from './up-to-date.js'\n\nimport { configForBuild, describeConfig, readConfigObject } from './config.js'\nimport { asError, errorMessage } from './errors.js'\nimport { patternsMatchingNothing, sourcePatternsOf } from './patterns.js'\nimport { sizeTable } from './size-report.js'\nimport {\n configFingerprint,\n declaredDestinations,\n isUpToDate,\n newestSourceOf,\n readPersistedRecords,\n recordOf,\n recordsFileFor,\n writePersistedRecords,\n writtenDestination,\n} from './up-to-date.js'\n\n// What `runBuilds` reads from the plugin instance it compiles for.\n//\n// `root` and `notifyBuildOutcome` are functions for the same reason: each is\n// assigned after the factory has run — `root` by `adoptCompiler` or\n// `configResolved`, the overlay callback by `configureServer` — so a value\n// copied when this was built would be the working directory and `undefined`\n// for good. `log` and `logTable` read the message host when they are called,\n// for the same reason. `generatedDestinations` is the instance's own record of what it\n// wrote, and `destinationRecords` is the process's record of which\n// configuration last wrote each file and what it read, with\n// `persistedRecordFiles` naming the file each root's copy persists to;\n// none of them lives here.\nexport interface PluginInstance {\n cache: boolean\n destinationRecords: Map<string, DestinationRecord>\n failOnError: NonNullable<UnpluginStyleDictionaryOptions['failOnError']>\n generatedDestinations: Set<string>\n log: (message: string, type: 'error' | 'info' | 'success') => void\n logTable: (render: (colour: boolean) => string[]) => void\n notifyBuildOutcome: (error: Error | null) => void\n onBuildEnd: UnpluginStyleDictionaryOptions['onBuildEnd']\n onBuildError: UnpluginStyleDictionaryOptions['onBuildError']\n onBuildStart: UnpluginStyleDictionaryOptions['onBuildStart']\n persistedRecordFiles: Map<string, string>\n platformsOption: UnpluginStyleDictionaryOptions['platforms']\n quiet: boolean\n report: boolean\n root: () => string\n verbosity: 'default' | 'silent' | 'verbose' | undefined\n volume: typeof fs\n watch: UnpluginStyleDictionaryOptions['watch']\n}\n\n// Compile design tokens\nexport async function runBuilds(\n instance: PluginInstance,\n resolvedConfigs: ResolvedConfig[],\n context?: string,\n): Promise<void> {\n const {\n cache,\n destinationRecords,\n failOnError,\n generatedDestinations,\n log,\n logTable,\n notifyBuildOutcome,\n onBuildEnd,\n onBuildError,\n onBuildStart,\n persistedRecordFiles,\n platformsOption,\n quiet,\n report,\n verbosity,\n volume,\n watch,\n } = instance\n\n // Read when this compile starts rather than when the instance was built,\n // because the host assigns it after the factory has run.\n const root = instance.root()\n\n const startTime = Date.now()\n\n // Ahead of the `try` rather than inside it, because the reporting below\n // reads it and that reporting is deliberately outside.\n const generatedFiles = new Set<string>()\n\n // How many configurations were already up to date. Read by the reporting\n // below, which is why it sits out here with `generatedFiles`.\n let skipped = 0\n\n // The records an earlier process persisted for this root, loaded the first\n // time this process compiles for it and never over a record this process\n // wrote itself. Only with `cache` on, the one case that reads or keeps\n // them: every other build compiles regardless.\n let recordsFile = cache ? persistedRecordFiles.get(root) : undefined\n if (cache && recordsFile === undefined) {\n recordsFile = recordsFileFor(root)\n persistedRecordFiles.set(root, recordsFile)\n for (const [destination, record] of readPersistedRecords(recordsFile)) {\n if (!destinationRecords.has(destination)) {\n destinationRecords.set(destination, record)\n }\n }\n }\n\n // What this compile recorded, and so what it adds to the persisted copy.\n const recorded = new Map<string, DestinationRecord>()\n\n try {\n if (!context) {\n log('Compiling design tokens...', 'info')\n }\n\n // Before anything is resolved or built, and once per build — a watch\n // rebuild is a build, so this fires again for each one.\n if (onBuildStart) callHook(log, 'onBuildStart', onBuildStart)\n\n // Configurations are built one after another rather than with\n // `Promise.all`, and that is load-bearing. Two configurations may name\n // the same destination file, and each instance gets the atomic volume\n // swapped onto it below — overlapping builds would interleave those\n // writes and hand a reader a file assembled from both.\n for (const [index, item] of resolvedConfigs.entries()) {\n // Read ahead of the instance, because avoiding the instance is the\n // point: construction plus `extend` is the 15-30% of a build that\n // parses the token sources, and `buildAllPlatforms` is the rest.\n //\n // Without a `report`, so a configuration that will not parse says\n // nothing here — the build below hands Style Dictionary the path and\n // lets its own message through, which is more specific than anything\n // this could say.\n const declared = cache ? await readConfigObject(item) : null\n const selectedPlatforms = platformsFor(platformsOption, context)\n\n // Ahead of the up-to-date check, which judges a selection by the\n // destinations the configuration declares for it and so cannot see a\n // name it does not define: `['css', 'andriod']` was judged on `css`\n // alone, and with `css` current the whole build was skipped and the typo\n // went unreported until a source changed. The build path checks again\n // below, for the builds that reach it without `declared`.\n if (declared) {\n checkSelection(item, index, selectedPlatforms, declared.platforms)\n }\n\n // What this configuration reads, as it stands before the compile below\n // reads any of it. The up-to-date check needs it now, and the record\n // needs it afterwards: it is what the next check measures \"changed\n // since\" against. Only asked with `cache` on, the one case that reads it.\n const newestSource = declared\n ? await newestSourceOf({ log, root, watch }, item, declared)\n : null\n\n // Who this configuration is: its contents, the hooks it names as the\n // registry holds them now, and the Style Dictionary version. Taken here,\n // after the `config` function's `register*` calls have landed, and once:\n // the check compares it with each destination's record, and the record\n // keeps it. Only with `cache` on, because nothing compares it otherwise\n // and reading a configuration module to compute it would run it again.\n const fingerprint = declared\n ? configFingerprint(root, item, declared)\n : null\n\n if (\n declared &&\n isUpToDate(\n { destinationRecords, fingerprint, newestSource },\n item,\n declared,\n selectedPlatforms,\n )\n ) {\n // The destinations still have to be collected. They are what stops\n // the plugin's own output being treated as a watched source, so a\n // skipped configuration that contributed none would have its files\n // rebuild the moment a watcher noticed them.\n for (const destination of declaredDestinations(declared)) {\n generatedFiles.add(destination)\n }\n\n skipped++\n continue\n }\n\n // `{ init: false }` is the escape hatch Style Dictionary documents on\n // this constructor, and it is what makes a bad configuration\n // catchable. Left to itself the constructor ends in a call to\n // `init()` whose promise it neither stores nor returns, so a config\n // that fails to load rejects a promise nobody holds: the `catch`\n // below never runs, and the host dies with a raw stack or — where an\n // `unhandledRejection` handler suppresses it — hangs on a\n // `buildStart` that never settles. `await sd.hasInitialized` cannot\n // observe it either, since that promise is only ever resolved, at the\n // tail of a successful extend.\n //\n // It is handed the configuration as an object rather than as a path\n // for the same reason: Style Dictionary imports a path with no\n // cache-busting query of its own, so under a long-lived dev server\n // every rebuild after the first built the config the process started\n // with while the watch list followed the edit.\n const sd = new StyleDictionary(await configForBuild(item), {\n init: false,\n })\n\n // One initialisation rather than two. `init()` is `extend()` with\n // `mutateOriginal`, so the old pair loaded the configuration and\n // combined every source twice — running a custom parser or\n // preprocessor twice with it — and the first of the two ran at\n // default verbosity, which is how Style Dictionary's own warnings\n // escaped this plugin's `silent`. `config` defaults to the one the\n // constructor was handed.\n //\n // `verbosity` is `undefined` unless a consumer asked for a level, and\n // Style Dictionary falls through an unset one to the configuration's\n // own `log.verbosity`. Overwriting it here is what silenced the one\n // line explaining why a build wrote nothing. `log.warnings` is not\n // touched either way: a consumer's `warnings: 'error'` turning a\n // missing output file into a thrown build is their decision.\n await namingConfig(item, index, async () =>\n sd.extend(undefined, { mutateOriginal: true, verbosity }),\n )\n\n // **Before the build, and that is the whole of it.** A token set that\n // resolved to nothing is not an error anywhere in this stack: Style\n // Dictionary writes the file with no custom properties in it, prints\n // its usual `✔︎` line at any verbosity, and returns. So a token file\n // deleted mid-session took the generated output down with it and\n // reported `Rebuilt design tokens` while doing it, and a `source`\n // matching nothing shipped an empty stylesheet from a build that\n // exited 0.\n //\n // Checked here because `buildAllPlatforms` truncates and rewrites the\n // destination: one line later the previous good output is already gone\n // and an error would be accurate and useless.\n if (sd.allTokens.length === 0) {\n // The configuration as an object, so its own patterns can be named.\n // Without a `report`, because a configuration that will not parse\n // never reaches here — the `extend` above would have thrown first.\n const asObject = await readConfigObject(item)\n const barren = asObject\n ? await patternsMatchingNothing(sourcePatternsOf(asObject))\n : []\n\n // Thrown rather than reported, so it takes the path `failOnError`\n // already owns — the same decision, made in one place, rather than a\n // second way for a build to fail.\n throw new Error(\n [\n `${describeConfig(item, index)} resolved no tokens, so its output would be emptied.`,\n barren.length > 0\n ? `These patterns matched no files: ${barren.join(', ')}`\n : `It declares no source or include patterns that matched anything.`,\n `Nothing was written. Set failOnError to false to build anyway.`,\n ].join(' '),\n )\n }\n\n // Swap in the atomic volume only now that the instance has finished\n // reading its configs and token sources, so every write below lands\n // through `rename` while the read path stays exactly as it was. Seen\n // through a layer that notes what this build writes, which the record\n // below needs: a declared file is not necessarily a written one.\n const wrote = new Set<string>()\n sd.volume = notingWrites(volume, wrote)\n\n // The files this compile is about to write, named exactly as the\n // up-to-date check names them, and only the selected platforms' — a\n // scoped build leaves the others' files, and whatever wrote them, alone.\n const writing = declaredDestinations(\n { platforms: sd.platforms },\n selectedPlatforms,\n )\n\n // Forgotten before the first write rather than overwritten after the\n // last, because a build that throws partway has already replaced some\n // of these files. A record left standing would vouch for output that\n // no longer matches the configuration it names.\n for (const destination of writing) {\n destinationRecords.delete(destination)\n }\n\n if (selectedPlatforms === undefined) {\n await namingConfig(item, index, async () => sd.buildAllPlatforms())\n } else {\n // `cache: false`, and a configuration that would not parse ahead of\n // `extend`, arrive here without the check above having run.\n checkSelection(item, index, selectedPlatforms, sd.platforms)\n\n // One after another, matching the loop this sits inside: two\n // platforms may name the same destination, and `buildAllPlatforms`\n // fanning its own out with `Promise.all` is Style Dictionary's\n // choice over configurations it owns, not this plugin's over a\n // selection a consumer wrote.\n for (const name of selectedPlatforms) {\n await namingConfig(item, index, async () => sd.buildPlatform(name))\n }\n }\n\n // Every declared platform, not only the ones this compile built. A\n // file an unselected platform wrote on an earlier build is still the\n // plugin's own output, and dropping it from this set would let a\n // watcher treat it as a token source and rebuild once for every change\n // to it. Not a loop: a scoped rebuild never rewrites that file.\n //\n // Collected on every build rather than only on the ones whose size\n // report prints it below. The set is also what keeps a rebuild from\n // being triggered by the write it just made, and a rebuild passes a\n // `context` — so gating the collection on `!context` left it empty on\n // exactly the builds a watcher is live for.\n //\n // Named by `writtenDestination`, the one place that knows where Style\n // Dictionary writes a file. Every copy of that rule this code has had\n // named files that were never written — against `root` until #362, and\n // an absolute destination until #367 — and handed them to `onBuildEnd`,\n // the size report and this very set.\n for (const platform of Object.values(sd.platforms)) {\n for (const file of platform.files ?? []) {\n if (file.destination) {\n generatedFiles.add(\n writtenDestination(platform.buildPath, file.destination),\n )\n }\n }\n }\n\n // Recorded only now, so a build that threw vouches for none of what it\n // wrote before throwing. Every configuration is recorded, a file-backed\n // one included: an object configuration naming the same file has to\n // see that something else wrote it last.\n //\n // `newestSource` goes in beside the fingerprint. It was taken before\n // this compile read anything, so a source saved while it ran is newer\n // than it, and the rebuild queued for that save is not skipped. So does\n // each file's mtime as this compile left it, byte-identical skip and\n // all: a later check trusts the record only while the file still has\n // it. A file Style Dictionary declined to write is recorded as such, so\n // a later check does not go looking for it; one it wrote and that is\n // already gone gets no record, so the next build compiles.\n //\n // Only a compile with `cache` on has a fingerprint to record. One with it\n // off has still forgotten what it rewrote, above, which is what keeps\n // every other instance from vouching for its output.\n if (fingerprint !== null) {\n for (const destination of writing) {\n const record = recordOf(\n destination,\n fingerprint,\n newestSource,\n wrote.has(destination),\n )\n if (record) {\n destinationRecords.set(destination, record)\n recorded.set(destination, record)\n }\n }\n }\n }\n\n // Replaced wholesale rather than added to, so a destination dropped from\n // a configuration stops being treated as ours and becomes watchable\n // again. A build that throws never reaches this and leaves the previous\n // set standing, which is the safe direction: the files it wrote before\n // failing are still ours.\n // Once per compile rather than per configuration, and only after every\n // configuration succeeded. A compile that throws persists nothing, which\n // is safe: whatever it rewrote no longer has the mtime its old record\n // names, so that record vouches for nothing.\n if (recordsFile !== undefined && recorded.size > 0) {\n writePersistedRecords(recordsFile, recorded)\n }\n\n generatedDestinations.clear()\n for (const destination of generatedFiles) {\n generatedDestinations.add(destination.replace(/\\\\/g, '/'))\n }\n } catch (err) {\n const duration = Date.now() - startTime\n\n // The change a rebuild followed, worded as a change rather than as the\n // culprit: after a debounced burst `context` is the last file that\n // changed, which is not necessarily the one that broke the build.\n log(\n context\n ? `Compilation failed after a change in ${context} (${duration}ms): ${errorMessage(err)}`\n : `Compilation failed after ${duration}ms: ${errorMessage(err)}`,\n 'error',\n )\n\n // Ahead of the throw decision on purpose, so the overlay sees a failure\n // whatever `failOnError` does with it. Under the dev server's default\n // the line below does not throw, and reading the outcome from a caller's\n // `catch` would see a rebuild that looked like it succeeded.\n notifyBuildOutcome(asError(err))\n\n // Ahead of the throw decision for the same reason as the line above: a\n // rebuild under the dev server's default does not throw, and a hook that\n // only fired when something else was about to fail would be silent on\n // exactly the builds a consumer is watching.\n if (onBuildError) callHook(log, 'onBuildError', onBuildError, err)\n\n // Reported, and then rethrown so the host stops. Swallowing it left\n // every target exiting 0 with the previous run's tokens still on disk\n // and in the bundle — a green build shipping stale values.\n if (failsTheBuild(failOnError, context)) throw err\n\n // Explicit, now that the reporting below sits outside the `try`. This\n // `catch` used to end the function by falling off the end of it; a\n // failure that is not rethrown would otherwise carry on to announce a\n // compile that did not happen.\n return\n }\n\n // The compile is what the overlay reflects, so this is said here rather\n // than at the end: everything below is reporting, it returns early in\n // three places, and a size table that throws must not leave a successful\n // build looking unfinished.\n notifyBuildOutcome(null)\n\n // One measurement, read by the hook below and by the reporting under it.\n const duration = Date.now() - startTime\n\n // Beside the overlay notification, and for the same reason it sits here\n // rather than at the end of the function: the reporting below returns\n // early in three places, and a build that finished has finished whether or\n // not a size table gets printed for it.\n //\n // Sorted, so two runs of one configuration hand back the same order —\n // `generatedFiles` is a set in platform-then-file order, which is stable\n // in practice and guaranteed by nothing. The paths stay platform-native:\n // this is a list a consumer is going to open files with, not one the\n // watcher compares against.\n //\n // Only the declared files that are on disk. One whose filter matched no\n // tokens is declared and never written, and a hook opening it got an ENOENT.\n if (onBuildEnd) {\n // `toSorted` is what the linter asks for and what this cannot use:\n // `lib` is ES2022 here and `toSorted` is ES2023, so it types as an error\n // even though every Node this package supports has it. The rule guards\n // against mutating an array someone else holds, and this one was built\n // from the set on the line it appears on.\n const files = Array.from(generatedFiles)\n .filter((file) => fs.existsSync(file))\n // oxlint-disable-next-line unicorn/no-array-sort\n .sort((left, right) => left.localeCompare(right))\n callHook(log, 'onBuildEnd', onBuildEnd, files, duration)\n }\n\n // The `try` ends above, and everything from here down is reporting. Style\n // Dictionary has finished writing by now and `generatedDestinations` is\n // already replaced, so nothing below can put a file on disk in doubt —\n // which is why a throw from it must not be caught as a compile failure.\n // It used to be: a fault in the padding arithmetic printed `Compilation\n // failed after 19ms` over a build whose every token file was correct, and\n // with `failOnError` defaulting to `'build'` that stopped the bundler.\n\n // Every configuration was already current, so nothing was written. Said\n // rather than left implied: a build that prints its opening line and then\n // finishes in two milliseconds reads as one that silently did nothing.\n const everythingSkipped = skipped === resolvedConfigs.length\n\n if (context) {\n log(\n everythingSkipped\n ? `Design tokens already up to date after change in ${context} (${duration}ms)`\n : `Rebuilt design tokens due to change in ${context} (${duration}ms)`,\n 'success',\n )\n return\n }\n\n // The table is skipped when nothing was written, on top of `report` and\n // `quiet`. It reads every generated file in full and gzips it, and\n // reprinting the sizes of files this build did not touch is the one case\n // where that cost buys nothing at all.\n if (report && !quiet && !everythingSkipped && generatedFiles.size > 0) {\n try {\n logTable((colour) => sizeTable({ colour, root }, generatedFiles))\n } catch (err) {\n // At `'error'`, so it is said at every level including `silent`,\n // exactly as a compile failure is — and worded so it cannot be read\n // as one. Not rethrown: the build succeeded.\n log(\n `Failed to report generated file sizes: ${errorMessage(err)}`,\n 'error',\n )\n }\n }\n\n if (everythingSkipped) {\n log(`Design tokens are already up to date (${duration}ms)`, 'success')\n return\n }\n\n log(\n skipped > 0\n ? `Compiled successfully! (${duration}ms, ${skipped} already up to date)`\n : `Compiled successfully! (${duration}ms)`,\n 'success',\n )\n}\n\n// Runs one of the consumer's `onBuild*` hooks without letting it decide the\n// fate of the build that called it.\n//\n// Two ways a hook can go wrong, and neither may propagate. A throw is caught\n// here, because a post-processing step that fails must not undo a compile the\n// plugin itself completed — the files are written and correct. A rejected\n// promise is the quieter one: the return value is deliberately not awaited,\n// so a rejection has nothing holding it and reaches the host as an unhandled\n// rejection, which under Node's default takes the process down — a dev server\n// killed from inside a hook that was only meant to reformat a file.\n//\n// Both are reported at `'error'`, so they are said at every level including\n// `silent`, and worded so neither can be read as the compile having failed.\nfunction callHook<A extends unknown[]>(\n log: PluginInstance['log'],\n name: string,\n hook: (...args: A) => Promise<void> | void,\n ...args: A\n): void {\n let result: unknown\n\n try {\n // Captured rather than dropped, because the promise an `async` hook\n // returns is the thing the check below needs. Wrapping this call in a\n // block-bodied arrow — which is what the linter asks for when the return\n // type is plain `void` — discarded it, and the rejection escaped exactly\n // as it had before any of this existed.\n result = hook(...args)\n } catch (err) {\n log(`The ${name} hook threw: ${errorMessage(err)}`, 'error')\n return\n }\n\n if (!isThenable(result)) return\n\n void Promise.resolve(result).catch((err: unknown) => {\n log(`The ${name} hook rejected: ${errorMessage(err)}`, 'error')\n })\n}\n\n// Throws when `selected` names a platform that `platforms` does not define.\n// Named, so a typo is an error rather than a platform silently not built —\n// which is what Style Dictionary's own CLI means by \"Must be defined in the\n// config\" — and one function, so the check ahead of the up-to-date skip and\n// the one on the build path say exactly the same thing.\nfunction checkSelection(\n item: ResolvedConfig,\n index: number,\n selected: string[] | undefined,\n platforms: Record<string, unknown> | undefined,\n): void {\n if (selected === undefined) return\n\n const defined = Object.keys(platforms ?? {})\n const unknown = selected.filter((name) => !defined.includes(name))\n if (unknown.length > 0) {\n throw new Error(\n `${describeConfig(item, index)} does not define the platform(s) ${unknown.join(', ')}. It defines ${defined.join(', ')}.`,\n )\n }\n}\n\n// Whether a failure in this compile should be thrown rather than only\n// reported. The two compiles are told apart by `runBuilds`'s `context`,\n// which only the rebuild paths pass.\nfunction failsTheBuild(\n failOnError: NonNullable<UnpluginStyleDictionaryOptions['failOnError']>,\n context: string | undefined,\n): boolean {\n return (\n failOnError === true ||\n (context === undefined ? failOnError === 'build' : failOnError === 'serve')\n )\n}\n\n// A hook is the consumer's code, and what it hands back is not this plugin's to\n// assume. A predicate rather than `instanceof Promise`, which answers `false`\n// for a thenable from another realm or from a promise library — exactly the\n// case where letting a rejection escape does the damage.\nfunction isThenable(value: unknown): value is PromiseLike<unknown> {\n return (\n typeof value === 'object' &&\n value !== null &&\n 'then' in value &&\n typeof value.then === 'function'\n )\n}\n\n// Runs one of Style Dictionary's own steps for a configuration, and names the\n// configuration in anything it throws. Its messages name neither the\n// configuration nor the file behind them, so with several configurations a\n// failure could have come from any of them — in the log line, the overlay and\n// `onBuildError` alike. `cause` keeps its own error reachable.\n//\n// Only Style Dictionary's steps, never the whole loop: the plugin's own errors\n// already open with `describeConfig`, and wrapping those would name the\n// configuration twice.\nasync function namingConfig<T>(\n item: ResolvedConfig,\n index: number,\n step: () => Promise<T>,\n): Promise<T> {\n try {\n return await step()\n } catch (err) {\n throw new Error(\n `${describeConfig(item, index)} failed: ${errorMessage(err)}`,\n { cause: err },\n )\n }\n}\n\n// `volume`, with every path Style Dictionary writes through it noted in\n// `into`, resolved as `writtenDestination` resolves a declared one. Each file\n// a build writes reaches `promises.writeFile` (`buildPlatform` in\n// `lib/StyleDictionary.js`) — a byte-identical one included, since the atomic\n// writer decides to skip the rename only once it has been handed the file — so\n// `into` ends up holding exactly what the build wrote, and a file it declined\n// to write never appears in it. Built per compile, over the atomic volume\n// rather than in place of it.\n//\n// The sync writer is inherited untouched. Only an action reaches it, a\n// configuration with actions is never skipped, and `tests/index.test.ts`\n// checks that an action is handed the atomic writer itself.\nfunction notingWrites(volume: typeof fs, into: Set<string>): typeof fs {\n const note = (file: unknown) => {\n if (typeof file === 'string') into.add(path.resolve(file))\n }\n\n /* oxlint-disable typescript/no-unsafe-type-assertion */\n return Object.create(volume, {\n promises: {\n value: Object.create(volume.promises, {\n writeFile: {\n value: async (...args: Parameters<typeof fs.promises.writeFile>) => {\n note(args[0])\n await volume.promises.writeFile(...args)\n },\n },\n }) as typeof fs.promises,\n },\n }) as typeof fs\n /* oxlint-enable typescript/no-unsafe-type-assertion */\n}\n\n// Which platforms this compile covers, or `undefined` for all of them.\n//\n// The array form applies to every build; the object form splits the first\n// compile from the watch rebuilds, and `context` is what tells them apart —\n// only the rebuild paths pass one. An absent key means every platform, so\n// `{ watch: ['css'] }` builds everything once and then only css.\nfunction platformsFor(\n platformsOption: UnpluginStyleDictionaryOptions['platforms'],\n context: string | undefined,\n): string[] | undefined {\n if (platformsOption === undefined) return undefined\n if (Array.isArray(platformsOption)) return platformsOption\n\n return context === undefined ? platformsOption.build : platformsOption.watch\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"compile.js","names":[],"sources":["../src/compile.ts"],"sourcesContent":["// One compile of every resolved configuration: building each one through the\n// atomic volume, recording what it wrote, and saying how it went.\n//\n// Nothing here holds state. What a plugin instance owns is handed in as a\n// `PluginInstance`, which the factory builds once, and the two parts of it\n// that are assigned after the factory has run — `root` and the overlay\n// callback — are reached through it when a compile runs rather than copied\n// when it was built.\n\nimport fs from 'node:fs'\nimport path from 'node:path'\nimport StyleDictionary from 'style-dictionary'\n\nimport type { ResolvedConfig } from './config.js'\nimport type { UnpluginStyleDictionaryOptions } from './types.js'\nimport type { DestinationRecord } from './up-to-date.js'\n\nimport { configForBuild, describeConfig, readConfigObject } from './config.js'\nimport { asError, errorMessage } from './errors.js'\nimport { patternsMatchingNothing, sourcePatternsOf } from './patterns.js'\nimport { sizeTable } from './size-report.js'\nimport {\n configFingerprint,\n declaredDestinations,\n isUpToDate,\n newestSourceOf,\n readPersistedRecords,\n recordOf,\n recordsFileFor,\n writePersistedRecords,\n writtenDestination,\n} from './up-to-date.js'\n\n// What `runBuilds` reads from the plugin instance it compiles for.\n//\n// `root` and `notifyBuildOutcome` are functions for the same reason: each is\n// assigned after the factory has run — `root` by `adoptCompiler` or\n// `configResolved`, the overlay callback by `configureServer` — so a value\n// copied when this was built would be the working directory and `undefined`\n// for good. `log` and `logTable` read the message host when they are called,\n// for the same reason. `generatedDestinations` is the instance's own record of what it\n// wrote, and `destinationRecords` is the process's record of which\n// configuration last wrote each file and what it read, with\n// `persistedRecordFiles` naming the file each root's copy persists to;\n// none of them lives here.\nexport interface PluginInstance {\n cache: boolean\n destinationRecords: Map<string, DestinationRecord>\n failOnError: NonNullable<UnpluginStyleDictionaryOptions['failOnError']>\n generatedDestinations: Set<string>\n log: (message: string, type: 'error' | 'info' | 'success') => void\n logTable: (render: (colour: boolean) => string[]) => void\n notifyBuildOutcome: (error: Error | null) => void\n onBuildEnd: UnpluginStyleDictionaryOptions['onBuildEnd']\n onBuildError: UnpluginStyleDictionaryOptions['onBuildError']\n onBuildStart: UnpluginStyleDictionaryOptions['onBuildStart']\n persistedRecordFiles: Map<string, string>\n platformsOption: UnpluginStyleDictionaryOptions['platforms']\n quiet: boolean\n report: boolean\n root: () => string\n verbosity: 'default' | 'silent' | 'verbose' | undefined\n volume: typeof fs\n watch: UnpluginStyleDictionaryOptions['watch']\n}\n\n// Compile design tokens\nexport async function runBuilds(\n instance: PluginInstance,\n resolvedConfigs: ResolvedConfig[],\n context?: string,\n): Promise<void> {\n const {\n cache,\n destinationRecords,\n failOnError,\n generatedDestinations,\n log,\n logTable,\n notifyBuildOutcome,\n onBuildEnd,\n onBuildError,\n onBuildStart,\n persistedRecordFiles,\n platformsOption,\n quiet,\n report,\n verbosity,\n volume,\n watch,\n } = instance\n\n // Read when this compile starts rather than when the instance was built,\n // because the host assigns it after the factory has run.\n const root = instance.root()\n\n const startTime = Date.now()\n\n // Ahead of the `try` rather than inside it, because the reporting below\n // reads it and that reporting is deliberately outside.\n const generatedFiles = new Set<string>()\n\n // How many configurations were already up to date. Read by the reporting\n // below, which is why it sits out here with `generatedFiles`.\n let skipped = 0\n\n // The records an earlier process persisted for this root, loaded the first\n // time this process compiles for it and never over a record this process\n // wrote itself. Only with `cache` on, the one case that reads or keeps\n // them: every other build compiles regardless.\n let recordsFile = cache ? persistedRecordFiles.get(root) : undefined\n if (cache && recordsFile === undefined) {\n recordsFile = recordsFileFor(root)\n persistedRecordFiles.set(root, recordsFile)\n for (const [destination, record] of readPersistedRecords(recordsFile)) {\n if (!destinationRecords.has(destination)) {\n destinationRecords.set(destination, record)\n }\n }\n }\n\n // What this compile recorded, and so what it adds to the persisted copy.\n const recorded = new Map<string, DestinationRecord>()\n\n try {\n if (!context) {\n log('Compiling design tokens...', 'info')\n }\n\n // Before anything is resolved or built, and once per build — a watch\n // rebuild is a build, so this fires again for each one.\n if (onBuildStart) callHook(log, 'onBuildStart', onBuildStart)\n\n // Configurations are built one after another rather than with\n // `Promise.all`, and that is load-bearing. Two configurations may name\n // the same destination file, and each instance gets the atomic volume\n // swapped onto it below — overlapping builds would interleave those\n // writes and hand a reader a file assembled from both.\n for (const [index, item] of resolvedConfigs.entries()) {\n // Read ahead of the instance, because avoiding the instance is the\n // point: construction plus `extend` is the 15-30% of a build that\n // parses the token sources, and `buildAllPlatforms` is the rest.\n //\n // Without a `report`, so a configuration that will not parse says\n // nothing here — the build below hands Style Dictionary the path and\n // lets its own message through, which is more specific than anything\n // this could say.\n const declared = cache ? await readConfigObject(item) : null\n const selectedPlatforms = platformsFor(platformsOption, context)\n\n // Ahead of the up-to-date check, which judges a selection by the\n // destinations the configuration declares for it and so cannot see a\n // name it does not define: `['css', 'andriod']` was judged on `css`\n // alone, and with `css` current the whole build was skipped and the typo\n // went unreported until a source changed. The build path checks again\n // below, for the builds that reach it without `declared`.\n if (declared) {\n checkSelection(item, index, selectedPlatforms, declared.platforms)\n }\n\n // What this configuration reads, as it stands before the compile below\n // reads any of it. The up-to-date check needs it now, and the record\n // needs it afterwards: it is what the next check measures \"changed\n // since\" against. Only asked with `cache` on, the one case that reads it.\n const newestSource = declared\n ? await newestSourceOf({ log, root, watch }, item, declared)\n : null\n\n // Who this configuration is: its contents, the hooks it names as the\n // registry holds them now, and the Style Dictionary version. Taken here,\n // after the `config` function's `register*` calls have landed, and once:\n // the check compares it with each destination's record, and the record\n // keeps it. Only with `cache` on, because nothing compares it otherwise\n // and reading a configuration module to compute it would run it again.\n const fingerprint = declared\n ? configFingerprint(root, item, declared)\n : null\n\n if (\n declared &&\n isUpToDate(\n { destinationRecords, fingerprint, newestSource },\n item,\n declared,\n selectedPlatforms,\n )\n ) {\n // The destinations still have to be collected. They are what stops\n // the plugin's own output being treated as a watched source, so a\n // skipped configuration that contributed none would have its files\n // rebuild the moment a watcher noticed them.\n for (const destination of declaredDestinations(declared)) {\n generatedFiles.add(destination)\n }\n\n skipped++\n continue\n }\n\n // `{ init: false }` is the escape hatch Style Dictionary documents on\n // this constructor, and it is what makes a bad configuration\n // catchable. Left to itself the constructor ends in a call to\n // `init()` whose promise it neither stores nor returns, so a config\n // that fails to load rejects a promise nobody holds: the `catch`\n // below never runs, and the host dies with a raw stack or — where an\n // `unhandledRejection` handler suppresses it — hangs on a\n // `buildStart` that never settles. `await sd.hasInitialized` cannot\n // observe it either, since that promise is only ever resolved, at the\n // tail of a successful extend.\n //\n // It is handed the configuration as an object rather than as a path\n // for the same reason: Style Dictionary imports a path with no\n // cache-busting query of its own, so under a long-lived dev server\n // every rebuild after the first built the config the process started\n // with while the watch list followed the edit.\n const sd = new StyleDictionary(await configForBuild(item), {\n init: false,\n })\n\n // One initialisation rather than two. `init()` is `extend()` with\n // `mutateOriginal`, so the old pair loaded the configuration and\n // combined every source twice — running a custom parser or\n // preprocessor twice with it — and the first of the two ran at\n // default verbosity, which is how Style Dictionary's own warnings\n // escaped this plugin's `silent`. `config` defaults to the one the\n // constructor was handed.\n //\n // `verbosity` is `undefined` unless a consumer asked for a level, and\n // Style Dictionary falls through an unset one to the configuration's\n // own `log.verbosity`. Overwriting it here is what silenced the one\n // line explaining why a build wrote nothing. `log.warnings` is not\n // touched either way: a consumer's `warnings: 'error'` turning a\n // missing output file into a thrown build is their decision.\n await namingConfig(item, index, async () =>\n sd.extend(undefined, { mutateOriginal: true, verbosity }),\n )\n\n // **Before the build, and that is the whole of it.** A token set that\n // resolved to nothing is not an error anywhere in this stack: Style\n // Dictionary writes the file with no custom properties in it, prints\n // its usual `✔︎` line at any verbosity, and returns. So a token file\n // deleted mid-session took the generated output down with it and\n // reported `Rebuilt design tokens` while doing it, and a `source`\n // matching nothing shipped an empty stylesheet from a build that\n // exited 0.\n //\n // Checked here because `buildAllPlatforms` truncates and rewrites the\n // destination: one line later the previous good output is already gone\n // and an error would be accurate and useless.\n if (sd.allTokens.length === 0) {\n // The configuration as an object, so its own patterns can be named.\n // Without a `report`, because a configuration that will not parse\n // never reaches here — the `extend` above would have thrown first.\n const asObject = await readConfigObject(item)\n const barren = asObject\n ? await patternsMatchingNothing(sourcePatternsOf(asObject))\n : []\n\n // Thrown rather than reported, so it takes the path `failOnError`\n // already owns — the same decision, made in one place, rather than a\n // second way for a build to fail.\n throw new Error(\n [\n `${describeConfig(item, index)} resolved no tokens, so its output would be emptied.`,\n barren.length > 0\n ? `These patterns matched no files: ${barren.join(', ')}`\n : `It declares no source or include patterns that matched anything.`,\n `Nothing was written. Set failOnError to false to build anyway.`,\n ].join(' '),\n )\n }\n\n // Swap in the atomic volume only now that the instance has finished\n // reading its configs and token sources, so every write below lands\n // through `rename` while the read path stays exactly as it was. Seen\n // through a layer that notes what this build writes, which the record\n // below needs: a declared file is not necessarily a written one.\n const wrote = new Set<string>()\n sd.volume = notingWrites(volume, wrote)\n\n // The files this compile is about to write, named exactly as the\n // up-to-date check names them, and only the selected platforms' — a\n // scoped build leaves the others' files, and whatever wrote them, alone.\n const writing = declaredDestinations(\n { platforms: sd.platforms },\n selectedPlatforms,\n )\n\n // Forgotten before the first write rather than overwritten after the\n // last, because a build that throws partway has already replaced some\n // of these files. A record left standing would vouch for output that\n // no longer matches the configuration it names.\n for (const destination of writing) {\n destinationRecords.delete(destination)\n }\n\n if (selectedPlatforms === undefined) {\n await namingConfig(item, index, async () => sd.buildAllPlatforms())\n } else {\n // `cache: false`, and a configuration that would not parse ahead of\n // `extend`, arrive here without the check above having run.\n checkSelection(item, index, selectedPlatforms, sd.platforms)\n\n // One after another, matching the loop this sits inside: two\n // platforms may name the same destination, and `buildAllPlatforms`\n // fanning its own out with `Promise.all` is Style Dictionary's\n // choice over configurations it owns, not this plugin's over a\n // selection a consumer wrote.\n for (const name of selectedPlatforms) {\n await namingConfig(item, index, async () => sd.buildPlatform(name))\n }\n }\n\n // Every declared platform, not only the ones this compile built. A\n // file an unselected platform wrote on an earlier build is still the\n // plugin's own output, and dropping it from this set would let a\n // watcher treat it as a token source and rebuild once for every change\n // to it. Not a loop: a scoped rebuild never rewrites that file.\n //\n // Collected on every build rather than only on the ones whose size\n // report prints it below. The set is also what keeps a rebuild from\n // being triggered by the write it just made, and a rebuild passes a\n // `context` — so gating the collection on `!context` left it empty on\n // exactly the builds a watcher is live for.\n //\n // Named by `writtenDestination`, the one place that knows where Style\n // Dictionary writes a file. Every copy of that rule this code has had\n // named files that were never written — against `root` until #362, and\n // an absolute destination until #367 — and handed them to `onBuildEnd`,\n // the size report and this very set.\n for (const platform of Object.values(sd.platforms)) {\n for (const file of platform.files ?? []) {\n if (file.destination) {\n generatedFiles.add(\n writtenDestination(platform.buildPath, file.destination),\n )\n }\n }\n }\n\n // Recorded only now, so a build that threw vouches for none of what it\n // wrote before throwing. Every configuration is recorded, a file-backed\n // one included: an object configuration naming the same file has to\n // see that something else wrote it last.\n //\n // `newestSource` goes in beside the fingerprint. It was taken before\n // this compile read anything, so a source saved while it ran is newer\n // than it, and the rebuild queued for that save is not skipped. So do\n // each file's mtime, size and inode as this compile left it,\n // byte-identical skip and all: a later check trusts the record only\n // while the file still has all three. A file Style Dictionary declined\n // to write is recorded as such, so a later check does not go looking\n // for it; one it wrote and that is already gone gets no record, so the\n // next build compiles.\n //\n // Only a compile with `cache` on has a fingerprint to record. One with it\n // off has still forgotten what it rewrote, above, which is what keeps\n // every other instance from vouching for its output.\n if (fingerprint !== null) {\n for (const destination of writing) {\n const record = recordOf(\n destination,\n fingerprint,\n newestSource,\n wrote.has(destination),\n )\n if (record) {\n destinationRecords.set(destination, record)\n recorded.set(destination, record)\n }\n }\n }\n }\n\n // Replaced wholesale rather than added to, so a destination dropped from\n // a configuration stops being treated as ours and becomes watchable\n // again. A build that throws never reaches this and leaves the previous\n // set standing, which is the safe direction: the files it wrote before\n // failing are still ours.\n // Once per compile rather than per configuration, and only after every\n // configuration succeeded. A compile that throws persists nothing, which\n // is safe: whatever it rewrote was replaced rather than written in place,\n // so it no longer has the inode its old record names, and that record\n // vouches for nothing.\n if (recordsFile !== undefined && recorded.size > 0) {\n writePersistedRecords(recordsFile, recorded)\n }\n\n generatedDestinations.clear()\n for (const destination of generatedFiles) {\n generatedDestinations.add(destination.replace(/\\\\/g, '/'))\n }\n } catch (err) {\n const duration = Date.now() - startTime\n\n // The change a rebuild followed, worded as a change rather than as the\n // culprit: after a debounced burst `context` is the last file that\n // changed, which is not necessarily the one that broke the build.\n log(\n context\n ? `Compilation failed after a change in ${context} (${duration}ms): ${errorMessage(err)}`\n : `Compilation failed after ${duration}ms: ${errorMessage(err)}`,\n 'error',\n )\n\n // Ahead of the throw decision on purpose, so the overlay sees a failure\n // whatever `failOnError` does with it. Under the dev server's default\n // the line below does not throw, and reading the outcome from a caller's\n // `catch` would see a rebuild that looked like it succeeded.\n notifyBuildOutcome(asError(err))\n\n // Ahead of the throw decision for the same reason as the line above: a\n // rebuild under the dev server's default does not throw, and a hook that\n // only fired when something else was about to fail would be silent on\n // exactly the builds a consumer is watching.\n if (onBuildError) callHook(log, 'onBuildError', onBuildError, err)\n\n // Reported, and then rethrown so the host stops. Swallowing it left\n // every target exiting 0 with the previous run's tokens still on disk\n // and in the bundle — a green build shipping stale values.\n if (failsTheBuild(failOnError, context)) throw err\n\n // Explicit, now that the reporting below sits outside the `try`. This\n // `catch` used to end the function by falling off the end of it; a\n // failure that is not rethrown would otherwise carry on to announce a\n // compile that did not happen.\n return\n }\n\n // The compile is what the overlay reflects, so this is said here rather\n // than at the end: everything below is reporting, it returns early in\n // three places, and a size table that throws must not leave a successful\n // build looking unfinished.\n notifyBuildOutcome(null)\n\n // One measurement, read by the hook below and by the reporting under it.\n const duration = Date.now() - startTime\n\n // Beside the overlay notification, and for the same reason it sits here\n // rather than at the end of the function: the reporting below returns\n // early in three places, and a build that finished has finished whether or\n // not a size table gets printed for it.\n //\n // Sorted, so two runs of one configuration hand back the same order —\n // `generatedFiles` is a set in platform-then-file order, which is stable\n // in practice and guaranteed by nothing. The paths stay platform-native:\n // this is a list a consumer is going to open files with, not one the\n // watcher compares against.\n //\n // Only the declared files that are on disk. One whose filter matched no\n // tokens is declared and never written, and a hook opening it got an ENOENT.\n if (onBuildEnd) {\n // `toSorted` is what the linter asks for and what this cannot use:\n // `lib` is ES2022 here and `toSorted` is ES2023, so it types as an error\n // even though every Node this package supports has it. The rule guards\n // against mutating an array someone else holds, and this one was built\n // from the set on the line it appears on.\n const files = Array.from(generatedFiles)\n .filter((file) => fs.existsSync(file))\n // oxlint-disable-next-line unicorn/no-array-sort\n .sort((left, right) => left.localeCompare(right))\n callHook(log, 'onBuildEnd', onBuildEnd, files, duration)\n }\n\n // The `try` ends above, and everything from here down is reporting. Style\n // Dictionary has finished writing by now and `generatedDestinations` is\n // already replaced, so nothing below can put a file on disk in doubt —\n // which is why a throw from it must not be caught as a compile failure.\n // It used to be: a fault in the padding arithmetic printed `Compilation\n // failed after 19ms` over a build whose every token file was correct, and\n // with `failOnError` defaulting to `'build'` that stopped the bundler.\n\n // Every configuration was already current, so nothing was written. Said\n // rather than left implied: a build that prints its opening line and then\n // finishes in two milliseconds reads as one that silently did nothing.\n const everythingSkipped = skipped === resolvedConfigs.length\n\n if (context) {\n log(\n everythingSkipped\n ? `Design tokens already up to date after change in ${context} (${duration}ms)`\n : `Rebuilt design tokens due to change in ${context} (${duration}ms)`,\n 'success',\n )\n return\n }\n\n // The table is skipped when nothing was written, on top of `report` and\n // `quiet`. It reads every generated file in full and gzips it, and\n // reprinting the sizes of files this build did not touch is the one case\n // where that cost buys nothing at all.\n if (report && !quiet && !everythingSkipped && generatedFiles.size > 0) {\n try {\n logTable((colour) => sizeTable({ colour, root }, generatedFiles))\n } catch (err) {\n // At `'error'`, so it is said at every level including `silent`,\n // exactly as a compile failure is — and worded so it cannot be read\n // as one. Not rethrown: the build succeeded.\n log(\n `Failed to report generated file sizes: ${errorMessage(err)}`,\n 'error',\n )\n }\n }\n\n if (everythingSkipped) {\n log(`Design tokens are already up to date (${duration}ms)`, 'success')\n return\n }\n\n log(\n skipped > 0\n ? `Compiled successfully! (${duration}ms, ${skipped} already up to date)`\n : `Compiled successfully! (${duration}ms)`,\n 'success',\n )\n}\n\n// Runs one of the consumer's `onBuild*` hooks without letting it decide the\n// fate of the build that called it.\n//\n// Two ways a hook can go wrong, and neither may propagate. A throw is caught\n// here, because a post-processing step that fails must not undo a compile the\n// plugin itself completed — the files are written and correct. A rejected\n// promise is the quieter one: the return value is deliberately not awaited,\n// so a rejection has nothing holding it and reaches the host as an unhandled\n// rejection, which under Node's default takes the process down — a dev server\n// killed from inside a hook that was only meant to reformat a file.\n//\n// Both are reported at `'error'`, so they are said at every level including\n// `silent`, and worded so neither can be read as the compile having failed.\nfunction callHook<A extends unknown[]>(\n log: PluginInstance['log'],\n name: string,\n hook: (...args: A) => Promise<void> | void,\n ...args: A\n): void {\n let result: unknown\n\n try {\n // Captured rather than dropped, because the promise an `async` hook\n // returns is the thing the check below needs. Wrapping this call in a\n // block-bodied arrow — which is what the linter asks for when the return\n // type is plain `void` — discarded it, and the rejection escaped exactly\n // as it had before any of this existed.\n result = hook(...args)\n } catch (err) {\n log(`The ${name} hook threw: ${errorMessage(err)}`, 'error')\n return\n }\n\n if (!isThenable(result)) return\n\n void Promise.resolve(result).catch((err: unknown) => {\n log(`The ${name} hook rejected: ${errorMessage(err)}`, 'error')\n })\n}\n\n// Throws when `selected` names a platform that `platforms` does not define.\n// Named, so a typo is an error rather than a platform silently not built —\n// which is what Style Dictionary's own CLI means by \"Must be defined in the\n// config\" — and one function, so the check ahead of the up-to-date skip and\n// the one on the build path say exactly the same thing.\nfunction checkSelection(\n item: ResolvedConfig,\n index: number,\n selected: string[] | undefined,\n platforms: Record<string, unknown> | undefined,\n): void {\n if (selected === undefined) return\n\n const defined = Object.keys(platforms ?? {})\n const unknown = selected.filter((name) => !defined.includes(name))\n if (unknown.length > 0) {\n throw new Error(\n `${describeConfig(item, index)} does not define the platform(s) ${unknown.join(', ')}. It defines ${defined.join(', ')}.`,\n )\n }\n}\n\n// Whether a failure in this compile should be thrown rather than only\n// reported. The two compiles are told apart by `runBuilds`'s `context`,\n// which only the rebuild paths pass.\nfunction failsTheBuild(\n failOnError: NonNullable<UnpluginStyleDictionaryOptions['failOnError']>,\n context: string | undefined,\n): boolean {\n return (\n failOnError === true ||\n (context === undefined ? failOnError === 'build' : failOnError === 'serve')\n )\n}\n\n// A hook is the consumer's code, and what it hands back is not this plugin's to\n// assume. A predicate rather than `instanceof Promise`, which answers `false`\n// for a thenable from another realm or from a promise library — exactly the\n// case where letting a rejection escape does the damage.\nfunction isThenable(value: unknown): value is PromiseLike<unknown> {\n return (\n typeof value === 'object' &&\n value !== null &&\n 'then' in value &&\n typeof value.then === 'function'\n )\n}\n\n// Runs one of Style Dictionary's own steps for a configuration, and names the\n// configuration in anything it throws. Its messages name neither the\n// configuration nor the file behind them, so with several configurations a\n// failure could have come from any of them — in the log line, the overlay and\n// `onBuildError` alike. `cause` keeps its own error reachable.\n//\n// Only Style Dictionary's steps, never the whole loop: the plugin's own errors\n// already open with `describeConfig`, and wrapping those would name the\n// configuration twice.\nasync function namingConfig<T>(\n item: ResolvedConfig,\n index: number,\n step: () => Promise<T>,\n): Promise<T> {\n try {\n return await step()\n } catch (err) {\n throw new Error(\n `${describeConfig(item, index)} failed: ${errorMessage(err)}`,\n { cause: err },\n )\n }\n}\n\n// `volume`, with every path Style Dictionary writes through it noted in\n// `into`, resolved as `writtenDestination` resolves a declared one. Each file\n// a build writes reaches `promises.writeFile` (`buildPlatform` in\n// `lib/StyleDictionary.js`) — a byte-identical one included, since the atomic\n// writer decides to skip the rename only once it has been handed the file — so\n// `into` ends up holding exactly what the build wrote, and a file it declined\n// to write never appears in it. Built per compile, over the atomic volume\n// rather than in place of it.\n//\n// The sync writer is inherited untouched. Only an action reaches it, a\n// configuration with actions is never skipped, and `tests/index.test.ts`\n// checks that an action is handed the atomic writer itself.\nfunction notingWrites(volume: typeof fs, into: Set<string>): typeof fs {\n const note = (file: unknown) => {\n if (typeof file === 'string') into.add(path.resolve(file))\n }\n\n /* oxlint-disable typescript/no-unsafe-type-assertion */\n return Object.create(volume, {\n promises: {\n value: Object.create(volume.promises, {\n writeFile: {\n value: async (...args: Parameters<typeof fs.promises.writeFile>) => {\n note(args[0])\n await volume.promises.writeFile(...args)\n },\n },\n }) as typeof fs.promises,\n },\n }) as typeof fs\n /* oxlint-enable typescript/no-unsafe-type-assertion */\n}\n\n// Which platforms this compile covers, or `undefined` for all of them.\n//\n// The array form applies to every build; the object form splits the first\n// compile from the watch rebuilds, and `context` is what tells them apart —\n// only the rebuild paths pass one. An absent key means every platform, so\n// `{ watch: ['css'] }` builds everything once and then only css.\nfunction platformsFor(\n platformsOption: UnpluginStyleDictionaryOptions['platforms'],\n context: string | undefined,\n): string[] | undefined {\n if (platformsOption === undefined) return undefined\n if (Array.isArray(platformsOption)) return platformsOption\n\n return context === undefined ? platformsOption.build : platformsOption.watch\n}\n"],"mappings":"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"}
|
package/dist/types.d.ts
CHANGED
|
@@ -1,49 +1,5 @@
|
|
|
1
1
|
import { Config } from "style-dictionary";
|
|
2
2
|
//#region src/types.d.ts
|
|
3
|
-
/**
|
|
4
|
-
* Options for the Style Dictionary unplugin factory, shared across all bundler
|
|
5
|
-
* targets (Vite, Rolldown, Rollup, Webpack).
|
|
6
|
-
*
|
|
7
|
-
* Every target compiles tokens before the build that consumes them. Live
|
|
8
|
-
* rebuild-on-change is driven by the host bundler's watch mode, because token
|
|
9
|
-
* source files sit outside the module graph: Vite's dev server, `rollup
|
|
10
|
-
* --watch` and `webpack --watch` all rebuild on a token change, and a one-shot
|
|
11
|
-
* build (e.g. `tsdown`/`rolldown build` without `--watch`) only builds once, in
|
|
12
|
-
* `buildStart`.
|
|
13
|
-
*
|
|
14
|
-
* What the plugin says goes through the host wherever the host has a channel
|
|
15
|
-
* for it: Vite's `config.logger`, the plugin context under rollup and
|
|
16
|
-
* rolldown, and `compilation.warnings` under webpack and rspack, which is what
|
|
17
|
-
* puts a failed compile in `stats.toJson()`. The progress lines and the size
|
|
18
|
-
* table take the host's `info` channel, and webpack and rspack have none, so
|
|
19
|
-
* there both go to the console. A failure is reported on the warning channel
|
|
20
|
-
* and never the error one — rollup's `this.error` aborts the bundle, and that
|
|
21
|
-
* decision is `failOnError`'s alone. Where no host offers a channel the console
|
|
22
|
-
* is used, with colour gated on `NO_COLOR`, `FORCE_COLOR` and whether the
|
|
23
|
-
* stream is a terminal.
|
|
24
|
-
*
|
|
25
|
-
* Style Dictionary's own lines are not the plugin's. The platform name and the
|
|
26
|
-
* `✔︎` for each file it wrote are its own `console` calls, so they reach the
|
|
27
|
-
* console under every host, whatever the host's log level. `logLevel: 'silent'`
|
|
28
|
-
* quiets them, as does `log.verbosity: 'silent'` in the configuration.
|
|
29
|
-
*
|
|
30
|
-
* The three `onBuild*` hooks are called synchronously and their return value
|
|
31
|
-
* is not awaited, so a build never waits for one. A hook may still be written
|
|
32
|
-
* `async`: a promise it returns is left to run on its own, and a rejection is
|
|
33
|
-
* caught and reported rather than reaching the host as an unhandled one. A
|
|
34
|
-
* hook that throws is reported and does not fail the build that called it.
|
|
35
|
-
*
|
|
36
|
-
* They return `Promise<void> | void` rather than `void` for that reason. Both
|
|
37
|
-
* accept an `async` hook as far as the compiler is concerned, but `void` alone
|
|
38
|
-
* makes one a `no-misused-promises` error under the type-aware lint rules a
|
|
39
|
-
* consumer is likely to be running — for a hook this documents as supported.
|
|
40
|
-
*
|
|
41
|
-
* Rolldown's watch mode is the exception, and it is not about glob patterns.
|
|
42
|
-
* `addWatchFile` is accepted either way, but what happens next differs by
|
|
43
|
-
* platform — on macOS a file registered through it is watched by nothing, while
|
|
44
|
-
* on a Linux runner the same edit reaches a rebuild. Do not rely on a token
|
|
45
|
-
* edit triggering a rebuild there.
|
|
46
|
-
*/
|
|
47
3
|
/**
|
|
48
4
|
* What the host is doing, handed to the function form of `config` so it can
|
|
49
5
|
* decide what to build.
|
|
@@ -81,6 +37,51 @@ export interface StyleDictionaryConfigContext {
|
|
|
81
37
|
*/
|
|
82
38
|
watch: boolean;
|
|
83
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* Options for the Style Dictionary unplugin factory, shared across all bundler
|
|
42
|
+
* targets (Vite, Rolldown, Rollup, Webpack, Rspack).
|
|
43
|
+
*
|
|
44
|
+
* Every target compiles tokens before the build that consumes them. Live
|
|
45
|
+
* rebuild-on-change is driven by the host bundler's watch mode, because token
|
|
46
|
+
* source files sit outside the module graph: Vite's dev server, `rollup
|
|
47
|
+
* --watch`, `rolldown.watch()`, `webpack --watch` and `rspack --watch` all
|
|
48
|
+
* rebuild on a token change, and a one-shot build (e.g. `tsdown`/`rolldown
|
|
49
|
+
* build` without `--watch`) only builds once, in `buildStart`.
|
|
50
|
+
*
|
|
51
|
+
* What the plugin says goes through the host wherever the host has a channel
|
|
52
|
+
* for it: Vite's `config.logger`, the plugin context under rollup and
|
|
53
|
+
* rolldown, and `compilation.warnings` under webpack and rspack, which is what
|
|
54
|
+
* puts a failed compile in `stats.toJson()`. The progress lines and the size
|
|
55
|
+
* table take the host's `info` channel, and webpack and rspack have none, so
|
|
56
|
+
* there both go to the console. A failure is reported on the warning channel
|
|
57
|
+
* and never the error one — rollup's `this.error` aborts the bundle, and that
|
|
58
|
+
* decision is `failOnError`'s alone. Where no host offers a channel the console
|
|
59
|
+
* is used, with colour gated on `NO_COLOR`, `FORCE_COLOR` and whether the
|
|
60
|
+
* stream is a terminal.
|
|
61
|
+
*
|
|
62
|
+
* Style Dictionary's own lines are not the plugin's. The platform name and the
|
|
63
|
+
* `✔︎` for each file it wrote are its own `console` calls, so they reach the
|
|
64
|
+
* console under every host, whatever the host's log level. `logLevel: 'silent'`
|
|
65
|
+
* quiets them, as does `log.verbosity: 'silent'` in the configuration.
|
|
66
|
+
*
|
|
67
|
+
* The three `onBuild*` hooks are called synchronously and their return value
|
|
68
|
+
* is not awaited, so a build never waits for one. A hook may still be written
|
|
69
|
+
* `async`: a promise it returns is left to run on its own, and a rejection is
|
|
70
|
+
* caught and reported rather than reaching the host as an unhandled one. A
|
|
71
|
+
* hook that throws is reported and does not fail the build that called it.
|
|
72
|
+
*
|
|
73
|
+
* They return `Promise<void> | void` rather than `void` for that reason. Both
|
|
74
|
+
* accept an `async` hook as far as the compiler is concerned, but `void` alone
|
|
75
|
+
* makes one a `no-misused-promises` error under the type-aware lint rules a
|
|
76
|
+
* consumer is likely to be running — for a hook this documents as supported.
|
|
77
|
+
*
|
|
78
|
+
* Rolldown's watch mode rebuilds on macOS as well, a token reached through a
|
|
79
|
+
* symbolic link included. Its watcher there drops the events for a path
|
|
80
|
+
* registered through a link — a workspace package linked into `node_modules`,
|
|
81
|
+
* or a project under `/var` or `/tmp`, both links on macOS — so each such path
|
|
82
|
+
* is registered by its realpath beside it, and the event is mapped back to the
|
|
83
|
+
* spelling the `source` patterns use.
|
|
84
|
+
*/
|
|
84
85
|
export interface UnpluginStyleDictionaryOptions {
|
|
85
86
|
/**
|
|
86
87
|
* Whether a configuration whose output is already up to date may skip its
|
|
@@ -178,7 +179,7 @@ export interface UnpluginStyleDictionaryOptions {
|
|
|
178
179
|
* frame naming this plugin, and the overlay is dismissed on the next
|
|
179
180
|
* rebuild that succeeds.
|
|
180
181
|
*
|
|
181
|
-
* This is Vite's overlay, so it does nothing on the other
|
|
182
|
+
* This is Vite's overlay, so it does nothing on the other four targets,
|
|
182
183
|
* and nothing under `vite build` — there is no page to draw on.
|
|
183
184
|
*
|
|
184
185
|
* It is not `failOnError`'s job, and the two are independent. `failOnError`
|
|
@@ -186,9 +187,9 @@ export interface UnpluginStyleDictionaryOptions {
|
|
|
186
187
|
* A dev server deliberately keeps serving through a failed rebuild, which is
|
|
187
188
|
* precisely the case where the overlay is the only thing that can say so.
|
|
188
189
|
*
|
|
189
|
-
* A
|
|
190
|
-
*
|
|
191
|
-
*
|
|
190
|
+
* A token file that fails to parse is reported like any other failed
|
|
191
|
+
* compile: it reaches the overlay, and `onBuildError`, with the parser's
|
|
192
|
+
* message.
|
|
192
193
|
*
|
|
193
194
|
* @default true
|
|
194
195
|
*/
|
package/dist/up-to-date.js
CHANGED
|
@@ -4,7 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
import StyleDictionary from "style-dictionary";
|
|
5
5
|
import { createHash } from "node:crypto";
|
|
6
6
|
//#region src/up-to-date.ts
|
|
7
|
-
const RECORDS_VERSION =
|
|
7
|
+
const RECORDS_VERSION = 4;
|
|
8
8
|
function configFingerprint(root, item, configObj) {
|
|
9
9
|
let serialised;
|
|
10
10
|
try {
|
|
@@ -43,7 +43,7 @@ function isUpToDate({ destinationRecords, fingerprint, newestSource }, item, con
|
|
|
43
43
|
}
|
|
44
44
|
const stats = statOrNull(destination);
|
|
45
45
|
if (!stats) return false;
|
|
46
|
-
if (writtenByThis && record.written !== stats.mtimeMs) return false;
|
|
46
|
+
if (writtenByThis && (record.written !== stats.mtimeMs || record.size !== stats.size || record.ino !== stats.ino)) return false;
|
|
47
47
|
const lastRead = writtenByThis ? record.newestSource : null;
|
|
48
48
|
if (lastRead === null ? stats.mtimeMs <= newestSource : newestSource > lastRead) return false;
|
|
49
49
|
}
|
|
@@ -81,10 +81,12 @@ function readPersistedRecords(file) {
|
|
|
81
81
|
const stored = parsed.records;
|
|
82
82
|
for (const destination of Object.keys(stored)) {
|
|
83
83
|
const value = Reflect.get(stored, destination);
|
|
84
|
-
if (typeof value === "object" && value !== null && "fingerprint" in value && typeof value.fingerprint === "string" && "newestSource" in value && (value.newestSource === null || typeof value.newestSource === "number") && "written" in value && (value.written === null || typeof value.written === "number")) records.set(destination, {
|
|
84
|
+
if (typeof value === "object" && value !== null && "fingerprint" in value && typeof value.fingerprint === "string" && "ino" in value && (value.ino === null || typeof value.ino === "number") && "newestSource" in value && (value.newestSource === null || typeof value.newestSource === "number") && "size" in value && (value.size === null || typeof value.size === "number") && "written" in value && (value.written === null || typeof value.written === "number")) records.set(destination, {
|
|
85
85
|
fingerprint: value.fingerprint,
|
|
86
|
+
ino: value.ino,
|
|
86
87
|
newestSource: value.newestSource,
|
|
87
88
|
persisted: true,
|
|
89
|
+
size: value.size,
|
|
88
90
|
written: value.written
|
|
89
91
|
});
|
|
90
92
|
}
|
|
@@ -93,13 +95,17 @@ function readPersistedRecords(file) {
|
|
|
93
95
|
function recordOf(destination, fingerprint, newestSource, wrote) {
|
|
94
96
|
if (!wrote) return {
|
|
95
97
|
fingerprint,
|
|
98
|
+
ino: null,
|
|
96
99
|
newestSource,
|
|
100
|
+
size: null,
|
|
97
101
|
written: null
|
|
98
102
|
};
|
|
99
103
|
const stats = statOrNull(destination);
|
|
100
104
|
return stats ? {
|
|
101
105
|
fingerprint,
|
|
106
|
+
ino: stats.ino,
|
|
102
107
|
newestSource,
|
|
108
|
+
size: stats.size,
|
|
103
109
|
written: stats.mtimeMs
|
|
104
110
|
} : null;
|
|
105
111
|
}
|
|
@@ -119,11 +125,16 @@ function writePersistedRecords(file, recorded) {
|
|
|
119
125
|
const merged = readPersistedRecords(file);
|
|
120
126
|
for (const [destination, record] of recorded) merged.set(destination, record);
|
|
121
127
|
const records = {};
|
|
122
|
-
for (const [destination,
|
|
123
|
-
fingerprint,
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
128
|
+
for (const [destination, record] of merged) {
|
|
129
|
+
const { fingerprint, ino, newestSource, size, written } = record;
|
|
130
|
+
if (written === null ? statOrNull(path.dirname(destination)) : statOrNull(destination)) records[destination] = {
|
|
131
|
+
fingerprint,
|
|
132
|
+
ino,
|
|
133
|
+
newestSource,
|
|
134
|
+
size,
|
|
135
|
+
written
|
|
136
|
+
};
|
|
137
|
+
}
|
|
127
138
|
const temporary = `${file}.${String(process.pid)}.tmp`;
|
|
128
139
|
try {
|
|
129
140
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
package/dist/up-to-date.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"up-to-date.js","names":[],"sources":["../src/up-to-date.ts"],"sourcesContent":["// Whether a configuration's compile can be skipped, and the destinations that\n// question is asked about.\n//\n// Nothing here holds state — not even the record of which configuration last\n// wrote each destination, which is process-wide and lives in `index.ts` beside\n// `compilesInFlight`. What a plugin instance owns is handed in on each call.\n// The functions that read and write the persisted copy of that record work on\n// a file they are handed and keep nothing.\n\nimport type { Config, Hooks } from 'style-dictionary/types'\n\nimport { createHash } from 'node:crypto'\nimport fs from 'node:fs'\nimport path from 'node:path'\nimport StyleDictionary from 'style-dictionary'\n\nimport type { Log, ResolvedConfig } from './config.js'\nimport type { UnpluginStyleDictionaryOptions } from './types.js'\n\nimport {\n expandPatterns,\n sourcePatternsOf,\n watchOptionPatterns,\n} from './patterns.js'\n\n// What is known about the compile that last built one destination: which\n// configuration it was, the newest source it saw before it read any of them,\n// and the file's mtime once it was done. `newestSource` is `null` where that\n// compile did not look, which is when `cache` was off or the sources could not\n// be established. `written` is `null` where the compile declared the file and\n// Style Dictionary declined to write it — a filter matching no tokens — so\n// there is nothing on disk for it to vouch for. `persisted` marks a record an\n// earlier process left.\nexport interface DestinationRecord {\n fingerprint: string\n newestSource: null | number\n persisted?: true\n written: null | number\n}\n\n// Bumped whenever what a record or a fingerprint means changes, so a file an\n// older version of the plugin wrote is ignored rather than misread. 2 added\n// the hooks a configuration names and the Style Dictionary version to the\n// fingerprint, and started comparing it for a configuration given as a path.\n// 3 records a declared file the compile did not write, with `written: null`.\nconst RECORDS_VERSION = 3\n\n// A stable identity for one resolved configuration, or `null` where it\n// cannot have one. Functions are serialised by source rather than dropped,\n// because an inline `format` or `transform` is exactly the edit a\n// fingerprint has to notice, and `JSON.stringify` omits a function outright.\n//\n// It carries more than the configuration: the hooks it names, as the registry\n// holds them now, and the Style Dictionary version. A hook named by a string is\n// only that string in the configuration, and its body lives in the registry,\n// which a `register*` call changes without anything on disk moving — the\n// README's own Custom Formats pattern registers inside the `config` function.\n// An upgrade changes the built-in hooks the same way. Neither is a file the\n// up-to-date check reads, so neither could be seen before this.\n//\n// A digest of that serialisation rather than the serialisation itself,\n// because it is held against every destination a build writes, and a\n// configuration carrying its tokens inline serialises to all of them.\nexport function configFingerprint(\n root: string,\n item: ResolvedConfig,\n configObj: Config,\n): null | string {\n let serialised: string\n try {\n serialised = JSON.stringify(\n [\n root,\n item.file ?? null,\n configObj,\n namedHooksOf(configObj),\n StyleDictionary.VERSION,\n ],\n (_key, value: unknown) =>\n typeof value === 'function' ? `[fn]${String(value)}` : value,\n )\n } catch {\n // Circular, or holding a BigInt. It takes no identity rather than a\n // wrong one, so it compiles every time exactly as it did before.\n return null\n }\n\n return createHash('sha256').update(serialised).digest('hex')\n}\n\n// Every absolute destination a configuration declares, read off the\n// configuration itself rather than off an extended Style Dictionary\n// instance. Reading it here is the whole point: constructing the instance\n// is what the skip exists to avoid.\n//\n// Each one is named by `writtenDestination`, which `runBuilds` asks as well,\n// so the up-to-date check and the record of what was built name each file\n// where Style Dictionary writes it. Whether it did is the record's to say: a\n// file whose filter matches no tokens is declared and never written.\n//\n// `only` narrows this to named platforms, and exactly one caller wants that:\n// the up-to-date check, which asks whether the work *this* compile would do\n// is already done. Everywhere else the answer has to cover every declared\n// platform, because a file an unselected platform wrote earlier is still the\n// plugin's own output and has to stay out of the watch list.\nexport function declaredDestinations(\n configObj: Config,\n only?: string[],\n): string[] {\n const destinations: string[] = []\n\n const entries = Object.entries(configObj.platforms ?? {})\n const selected = only\n ? entries.filter(([name]) => only.includes(name))\n : entries\n\n for (const [, platform] of selected) {\n for (const file of platform.files ?? []) {\n if (file.destination) {\n destinations.push(\n writtenDestination(platform.buildPath, file.destination),\n )\n }\n }\n }\n\n return destinations\n}\n\n// Whether a configuration's compile can be skipped: every file its last compile\n// wrote still exists as it was left, and nothing it reads has changed since\n// that compile read it. A declared file Style Dictionary declined to write is\n// not asked about, because it was never going to be on disk — asking made a\n// configuration with an empty filter compile on every build.\n//\n// \"Since\" is measured against what that compile read wherever it left a\n// record, in this process or persisted by an earlier one. The record holds the\n// newest source it saw before reading, and a source newer than that is one it\n// never read. Comparing against the output instead lost a save that landed\n// while a rebuild ran, because the rebuild wrote its output after the save.\n// It also never skipped again once an edit left one destination unchanged:\n// the atomic write keeps a byte-identical file's mtime, so the edited source\n// stayed newer than it for good. Only with no record at all is the output\n// compared.\n//\n// Conservative in every direction it can be: anything it cannot establish —\n// a destination that is missing, a source it cannot stat, a configuration\n// declaring no destinations at all — is a reason to build rather than to\n// skip.\nexport function isUpToDate(\n {\n destinationRecords,\n fingerprint,\n newestSource,\n }: {\n destinationRecords: ReadonlyMap<string, DestinationRecord>\n fingerprint: null | string\n newestSource: null | number\n },\n item: ResolvedConfig,\n configObj: Config,\n only?: string[],\n): boolean {\n // An action writes what no `destination` names, so there is nothing for\n // the comparison below to check and skipping would leave its work undone.\n const hasActions = Object.values(configObj.platforms ?? {}).some(\n (platform) => (platform.actions?.length ?? 0) > 0,\n )\n if (hasActions) return false\n\n const destinations = declaredDestinations(configObj, only)\n if (destinations.length === 0) return false\n\n if (newestSource === null) return false\n\n for (const destination of destinations) {\n const record = destinationRecords.get(destination)\n const writtenByThis =\n fingerprint !== null && record?.fingerprint === fingerprint\n\n // Recorded against something else: another configuration, or this one\n // before a hook it names changed or Style Dictionary was upgraded. The\n // file holds that compile's output, and nothing the comparison below\n // reads can say so — which is why a configuration given as a path is\n // held to this as much as one given as an object.\n if (record !== undefined && !writtenByThis) return false\n\n // A configuration given as a path has its own file among the sources, so\n // an edit to it is accounted for and the skip holds across processes. One\n // given as an object or a function has not, so it skips only over files\n // this process wrote for it. Having built it at some point is not enough:\n // an edit that was then undone left the edit's output standing, and so\n // did a build of it that threw partway. An earlier process's record is not\n // enough either, which is why the first compile of a process builds it:\n // its functions are fingerprinted by their source, and what they close\n // over can differ from one process to the next.\n if (!item.file && (!writtenByThis || record.persisted === true)) {\n return false\n }\n\n // Declared and left unwritten by this configuration's last compile. There\n // is no file to stat, so all that can have changed is what it reads.\n if (writtenByThis && record.written === null) {\n if (record.newestSource === null || newestSource > record.newestSource) {\n return false\n }\n continue\n }\n\n const stats = statOrNull(destination)\n if (!stats) return false\n\n // The file has moved since this configuration wrote it, so something\n // wrote it afterwards and nothing here can say what it holds. Its mtime\n // is no evidence either way: an edit to generated output is newer than\n // every source, and would have been kept.\n if (writtenByThis && record.written !== stats.mtimeMs) return false\n\n const lastRead = writtenByThis ? record.newestSource : null\n\n if (\n lastRead === null\n ? stats.mtimeMs <= newestSource\n : newestSource > lastRead\n ) {\n return false\n }\n }\n\n return true\n}\n\n// The newest mtime among the files a configuration reads, or `null` where that\n// cannot be established: a source that cannot be stat'ed, or patterns that\n// matched no file at all.\n//\n// Asked before a compile reads anything, and recorded against what it writes,\n// so that the next `isUpToDate` compares against what the compile actually\n// read. A save that lands afterwards is newer than this, however much newer\n// the compile's own output turns out to be. Both sides of that comparison are\n// mtimes of the files themselves, so no clock is involved and nothing depends\n// on how finely the filesystem stamps a write.\nexport async function newestSourceOf(\n {\n log,\n root,\n watch,\n }: {\n log: Log\n root: string\n watch: UnpluginStyleDictionaryOptions['watch']\n },\n item: ResolvedConfig,\n configObj: Config,\n): Promise<null | number> {\n // `options.watch` belongs in here as much as `source` does. A consumer\n // names an extra file because something in the build reads it — a custom\n // format's own data file, most obviously — and leaving it out let a change\n // to it be skipped over while the watcher dutifully reported it.\n const read = sourcePatternsOf(configObj)\n const extra = watchOptionPatterns(root, watch)\n\n const sources = await expandPatterns(\n {\n globs: [...read.globs, ...extra.globs],\n literals: [...read.literals, ...extra.literals],\n },\n log,\n )\n if (item.file) sources.push(item.file.replace(/\\\\/g, '/'))\n\n if (sources.length === 0) return null\n\n let newestSource = -Infinity\n let sawFile = false\n\n for (const source of sources) {\n const stats = statOrNull(source)\n if (!stats) return null\n\n // Directories are in this list on purpose — `expandPatterns` registers\n // each pattern's static parent so a token file created later is\n // noticed — but their mtime cannot be read as an input signal here. A\n // directory's mtime moves whenever an entry is added or renamed inside\n // it, and the atomic write renames every generated file into place, so\n // a `buildPath` inside a watched directory made the build itself the\n // newest thing the comparison could see. Nothing was ever up to date.\n if (stats.isDirectory()) continue\n\n sawFile = true\n newestSource = Math.max(newestSource, stats.mtimeMs)\n }\n\n // Every pattern expanded to directories alone, so nothing was actually\n // read. Style Dictionary would build an empty dictionary from that, and a\n // skip would present the empty result as current.\n return sawFile ? newestSource : null\n}\n\n// The records an earlier process persisted, each marked as such. Anything\n// that does not read as this version's records — a missing file, one another\n// version wrote, one cut short — is no records at all, which costs a compile\n// and never a wrong skip.\nexport function readPersistedRecords(\n file: string,\n): Map<string, DestinationRecord> {\n const records = new Map<string, DestinationRecord>()\n\n let parsed: unknown\n try {\n parsed = JSON.parse(fs.readFileSync(file, 'utf8'))\n } catch {\n return records\n }\n\n if (\n typeof parsed !== 'object' ||\n parsed === null ||\n !('version' in parsed) ||\n parsed.version !== RECORDS_VERSION ||\n !('records' in parsed) ||\n typeof parsed.records !== 'object' ||\n parsed.records === null\n ) {\n return records\n }\n\n const stored = parsed.records\n for (const destination of Object.keys(stored)) {\n const value: unknown = Reflect.get(stored, destination)\n if (\n typeof value === 'object' &&\n value !== null &&\n 'fingerprint' in value &&\n typeof value.fingerprint === 'string' &&\n 'newestSource' in value &&\n (value.newestSource === null || typeof value.newestSource === 'number') &&\n 'written' in value &&\n (value.written === null || typeof value.written === 'number')\n ) {\n records.set(destination, {\n fingerprint: value.fingerprint,\n newestSource: value.newestSource,\n persisted: true,\n written: value.written,\n })\n }\n }\n\n return records\n}\n\n// The record a compile leaves for one destination it declared. One it wrote\n// is recorded with the file's mtime, and one Style Dictionary declined to write\n// with `written: null`. `null` comes back where a file it wrote is not there\n// to vouch for, so nothing is recorded for it and the next build compiles.\nexport function recordOf(\n destination: string,\n fingerprint: string,\n newestSource: null | number,\n wrote: boolean,\n): DestinationRecord | null {\n if (!wrote) return { fingerprint, newestSource, written: null }\n\n const stats = statOrNull(destination)\n return stats ? { fingerprint, newestSource, written: stats.mtimeMs } : null\n}\n\n// Where one project's records persist: under the nearest `package.json`'s\n// `node_modules/.cache`, the directory build tools share for this, and where\n// Vite keeps its own cache. The nearest package rather than `root`, because a\n// host's root can be a subdirectory — Vite's `root: 'src'` — and a cache\n// there would put a `node_modules` inside the consumer's sources. No `.json`\n// extension, so no `source` glob a consumer writes can read it as tokens.\nexport function recordsFileFor(root: string): string {\n let directory = root\n while (!fs.existsSync(path.join(directory, 'package.json'))) {\n const parent = path.dirname(directory)\n if (parent === directory) {\n directory = root\n break\n }\n directory = parent\n }\n\n return path.join(\n directory,\n 'node_modules',\n '.cache',\n 'unplugin-style-dictionary',\n 'records',\n )\n}\n\n// Merges what one compile recorded into the persisted records.\n//\n// Read again right before the write rather than trusted from the load, so a\n// record another process added since is kept. Two processes writing at once\n// can still lose one's entries, and that only costs a compile on the next\n// start. An entry whose destination is gone is dropped, which bounds the file\n// by the files that exist — and for a file the compile left unwritten, which is\n// never there, by the directory it would have been written into. Written\n// through a temporary file and a rename, so a reader never sees half of it.\nexport function writePersistedRecords(\n file: string,\n recorded: ReadonlyMap<string, DestinationRecord>,\n): void {\n const merged = readPersistedRecords(file)\n for (const [destination, record] of recorded) merged.set(destination, record)\n\n const records: Record<string, Omit<DestinationRecord, 'persisted'>> = {}\n for (const [destination, { fingerprint, newestSource, written }] of merged) {\n const stillThere =\n written === null\n ? statOrNull(path.dirname(destination))\n : statOrNull(destination)\n if (stillThere) {\n records[destination] = { fingerprint, newestSource, written }\n }\n }\n\n const temporary = `${file}.${String(process.pid)}.tmp`\n try {\n fs.mkdirSync(path.dirname(file), { recursive: true })\n fs.writeFileSync(\n temporary,\n JSON.stringify({ records, version: RECORDS_VERSION }),\n )\n fs.renameSync(temporary, file)\n } catch {\n // Only a cache: failing to write it costs a compile on the next start,\n // and never this build.\n try {\n fs.rmSync(temporary, { force: true })\n } catch {\n // Nothing more to do about a temporary file that cannot be removed.\n }\n }\n}\n\n// Where Style Dictionary writes one file, as an absolute path.\n//\n// It joins the destination onto the platform's `buildPath` when there is one\n// and writes the result relative to the working directory. **Joins, not\n// resolves**: an absolute destination lands under the build path rather than\n// replacing it. Measured with `buildPath: 'gen/'` and an absolute destination,\n// Style Dictionary wrote `gen/<that path>`, while the plugin had named the\n// absolute path on its own — a file that was never written.\n//\n// `root` has no say. It is where a relative `config` path is looked up and\n// nothing more, and reading `buildPath` against it named files that did not\n// exist whenever the two differed (#362). Style Dictionary joins with\n// `path-unified/posix`; the platform's own `join` names the same file, and\n// resolving the result is what normalises the separators either way.\nexport function writtenDestination(\n buildPath: string | undefined,\n destination: string,\n): string {\n return path.resolve(\n process.cwd(),\n buildPath ? path.join(buildPath, destination) : destination,\n )\n}\n\n// The hooks a configuration names by string, each as the registry holds it\n// now: the parsers and preprocessors it lists, each platform's transforms and\n// the transforms its `transformGroup` expands to, and each file's `format`,\n// `filter` and `fileHeader`. A hook given inline is part of the configuration\n// already. The configuration's own `hooks` win over the registry, as they do\n// when Style Dictionary builds.\n//\n// **Only the hooks it names, never the whole registry.** The registry is\n// shared by the whole process, so fingerprinting all of it would let one\n// configuration's `register*` call defeat every other configuration's skip.\nfunction namedHooksOf(\n configObj: Config,\n): Record<string, Record<string, unknown>> {\n const own: Hooks = configObj.hooks ?? {}\n const registry = StyleDictionary.hooks\n const named: Record<string, Record<string, unknown>> = {}\n\n const add = (kind: keyof Hooks, name: unknown) => {\n if (typeof name !== 'string') return\n const hook: unknown = own[kind]?.[name] ?? registry[kind][name]\n named[kind] ??= {}\n named[kind][name] = hook ?? null\n }\n\n for (const name of configObj.parsers ?? []) add('parsers', name)\n for (const name of configObj.preprocessors ?? []) add('preprocessors', name)\n\n for (const platform of Object.values(configObj.platforms ?? {})) {\n for (const name of platform.preprocessors ?? []) add('preprocessors', name)\n for (const transform of platform.transforms ?? []) {\n add('transforms', transform)\n }\n\n const group = platform.transformGroup\n if (group !== undefined) {\n add('transformGroups', group)\n\n // `unknown`, because a group nobody registered is `undefined` here\n // whatever the registry's type says.\n const members: unknown =\n own.transformGroups?.[group] ?? registry.transformGroups[group]\n if (Array.isArray(members)) {\n for (const name of members) add('transforms', name)\n }\n }\n\n add('fileHeaders', platform.options?.fileHeader)\n for (const file of platform.files ?? []) {\n add('formats', file.format)\n add('filters', file.filter)\n add('fileHeaders', file.options?.fileHeader)\n }\n }\n\n return named\n}\n\n// `fs.statSync` without the throw. A file that is missing, or that cannot be\n// read, is the same answer to every caller here: nothing to compare against.\nfunction statOrNull(file: string): fs.Stats | null {\n try {\n return fs.statSync(file)\n } catch {\n return null\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"up-to-date.js","names":[],"sources":["../src/up-to-date.ts"],"sourcesContent":["// Whether a configuration's compile can be skipped, and the destinations that\n// question is asked about.\n//\n// Nothing here holds state — not even the record of which configuration last\n// wrote each destination, which is process-wide and lives in `index.ts` beside\n// `compilesInFlight`. What a plugin instance owns is handed in on each call.\n// The functions that read and write the persisted copy of that record work on\n// a file they are handed and keep nothing.\n\nimport type { Config, Hooks } from 'style-dictionary/types'\n\nimport { createHash } from 'node:crypto'\nimport fs from 'node:fs'\nimport path from 'node:path'\nimport StyleDictionary from 'style-dictionary'\n\nimport type { Log, ResolvedConfig } from './config.js'\nimport type { UnpluginStyleDictionaryOptions } from './types.js'\n\nimport {\n expandPatterns,\n sourcePatternsOf,\n watchOptionPatterns,\n} from './patterns.js'\n\n// What is known about the compile that last built one destination: which\n// configuration it was, the newest source it saw before it read any of them,\n// and the file's mtime, size and inode once it was done. `newestSource` is\n// `null` where that compile did not look, which is when `cache` was off or the\n// sources could not be established. `written` is `null` where the compile\n// declared the file and Style Dictionary declined to write it — a filter\n// matching no tokens — so there is nothing on disk for it to vouch for, and\n// `size` and `ino` are `null` with it. `persisted` marks a record an earlier\n// process left.\n//\n// The mtime alone cannot vouch for the file. It moves only by a whole\n// timestamp tick — about 15ms on Windows, a second or more on HFS+, ext3 and\n// FAT — so a rewrite inside the tick the compile wrote in keeps it. `size`\n// sees a rewrite of another length, and `ino` a file replaced by a rename,\n// which is how atomic-save editors, `rsync` and this plugin's own writer\n// write. Both come from the stat the check already makes, so neither costs a\n// read. **Not `ctimeMs`**: it is stamped from the same clock as the mtime, so\n// it misses the same rewrites, and it moves on a `chmod` or an extended\n// attribute that changes nothing a build would.\nexport interface DestinationRecord {\n fingerprint: string\n ino: null | number\n newestSource: null | number\n persisted?: true\n size: null | number\n written: null | number\n}\n\n// Bumped whenever what a record or a fingerprint means changes, so a file an\n// older version of the plugin wrote is ignored rather than misread. 2 added\n// the hooks a configuration names and the Style Dictionary version to the\n// fingerprint, and started comparing it for a configuration given as a path.\n// 3 records a declared file the compile did not write, with `written: null`.\n// 4 records each written file's `size` and `ino` beside its mtime, which a\n// version-3 record carries neither of and must not be read as matching.\nconst RECORDS_VERSION = 4\n\n// A stable identity for one resolved configuration, or `null` where it\n// cannot have one. Functions are serialised by source rather than dropped,\n// because an inline `format` or `transform` is exactly the edit a\n// fingerprint has to notice, and `JSON.stringify` omits a function outright.\n//\n// It carries more than the configuration: the hooks it names, as the registry\n// holds them now, and the Style Dictionary version. A hook named by a string is\n// only that string in the configuration, and its body lives in the registry,\n// which a `register*` call changes without anything on disk moving — the\n// README's own Custom Formats pattern registers inside the `config` function.\n// An upgrade changes the built-in hooks the same way. Neither is a file the\n// up-to-date check reads, so neither could be seen before this.\n//\n// A digest of that serialisation rather than the serialisation itself,\n// because it is held against every destination a build writes, and a\n// configuration carrying its tokens inline serialises to all of them.\nexport function configFingerprint(\n root: string,\n item: ResolvedConfig,\n configObj: Config,\n): null | string {\n let serialised: string\n try {\n serialised = JSON.stringify(\n [\n root,\n item.file ?? null,\n configObj,\n namedHooksOf(configObj),\n StyleDictionary.VERSION,\n ],\n (_key, value: unknown) =>\n typeof value === 'function' ? `[fn]${String(value)}` : value,\n )\n } catch {\n // Circular, or holding a BigInt. It takes no identity rather than a\n // wrong one, so it compiles every time exactly as it did before.\n return null\n }\n\n return createHash('sha256').update(serialised).digest('hex')\n}\n\n// Every absolute destination a configuration declares, read off the\n// configuration itself rather than off an extended Style Dictionary\n// instance. Reading it here is the whole point: constructing the instance\n// is what the skip exists to avoid.\n//\n// Each one is named by `writtenDestination`, which `runBuilds` asks as well,\n// so the up-to-date check and the record of what was built name each file\n// where Style Dictionary writes it. Whether it did is the record's to say: a\n// file whose filter matches no tokens is declared and never written.\n//\n// `only` narrows this to named platforms, and exactly one caller wants that:\n// the up-to-date check, which asks whether the work *this* compile would do\n// is already done. Everywhere else the answer has to cover every declared\n// platform, because a file an unselected platform wrote earlier is still the\n// plugin's own output and has to stay out of the watch list.\nexport function declaredDestinations(\n configObj: Config,\n only?: string[],\n): string[] {\n const destinations: string[] = []\n\n const entries = Object.entries(configObj.platforms ?? {})\n const selected = only\n ? entries.filter(([name]) => only.includes(name))\n : entries\n\n for (const [, platform] of selected) {\n for (const file of platform.files ?? []) {\n if (file.destination) {\n destinations.push(\n writtenDestination(platform.buildPath, file.destination),\n )\n }\n }\n }\n\n return destinations\n}\n\n// Whether a configuration's compile can be skipped: every file its last compile\n// wrote still exists as it was left, and nothing it reads has changed since\n// that compile read it. A declared file Style Dictionary declined to write is\n// not asked about, because it was never going to be on disk — asking made a\n// configuration with an empty filter compile on every build.\n//\n// \"Since\" is measured against what that compile read wherever it left a\n// record, in this process or persisted by an earlier one. The record holds the\n// newest source it saw before reading, and a source newer than that is one it\n// never read. Comparing against the output instead lost a save that landed\n// while a rebuild ran, because the rebuild wrote its output after the save.\n// It also never skipped again once an edit left one destination unchanged:\n// the atomic write keeps a byte-identical file's mtime, so the edited source\n// stayed newer than it for good. Only with no record at all is the output\n// compared.\n//\n// Conservative in every direction it can be: anything it cannot establish —\n// a destination that is missing, a source it cannot stat, a configuration\n// declaring no destinations at all — is a reason to build rather than to\n// skip.\nexport function isUpToDate(\n {\n destinationRecords,\n fingerprint,\n newestSource,\n }: {\n destinationRecords: ReadonlyMap<string, DestinationRecord>\n fingerprint: null | string\n newestSource: null | number\n },\n item: ResolvedConfig,\n configObj: Config,\n only?: string[],\n): boolean {\n // An action writes what no `destination` names, so there is nothing for\n // the comparison below to check and skipping would leave its work undone.\n const hasActions = Object.values(configObj.platforms ?? {}).some(\n (platform) => (platform.actions?.length ?? 0) > 0,\n )\n if (hasActions) return false\n\n const destinations = declaredDestinations(configObj, only)\n if (destinations.length === 0) return false\n\n if (newestSource === null) return false\n\n for (const destination of destinations) {\n const record = destinationRecords.get(destination)\n const writtenByThis =\n fingerprint !== null && record?.fingerprint === fingerprint\n\n // Recorded against something else: another configuration, or this one\n // before a hook it names changed or Style Dictionary was upgraded. The\n // file holds that compile's output, and nothing the comparison below\n // reads can say so — which is why a configuration given as a path is\n // held to this as much as one given as an object.\n if (record !== undefined && !writtenByThis) return false\n\n // A configuration given as a path has its own file among the sources, so\n // an edit to it is accounted for and the skip holds across processes. One\n // given as an object or a function has not, so it skips only over files\n // this process wrote for it. Having built it at some point is not enough:\n // an edit that was then undone left the edit's output standing, and so\n // did a build of it that threw partway. An earlier process's record is not\n // enough either, which is why the first compile of a process builds it:\n // its functions are fingerprinted by their source, and what they close\n // over can differ from one process to the next.\n if (!item.file && (!writtenByThis || record.persisted === true)) {\n return false\n }\n\n // Declared and left unwritten by this configuration's last compile. There\n // is no file to stat, so all that can have changed is what it reads.\n if (writtenByThis && record.written === null) {\n if (record.newestSource === null || newestSource > record.newestSource) {\n return false\n }\n continue\n }\n\n const stats = statOrNull(destination)\n if (!stats) return false\n\n // The file has moved since this configuration wrote it, so something\n // wrote it afterwards and nothing here can say what it holds. Its mtime\n // is no evidence either way: an edit to generated output is newer than\n // every source, and would have been kept. The size and the inode are\n // asked beside the mtime, because a rewrite inside the timestamp tick the\n // compile wrote in keeps the mtime: one of another length moves the size,\n // and one renamed over the file moves the inode.\n if (\n writtenByThis &&\n (record.written !== stats.mtimeMs ||\n record.size !== stats.size ||\n record.ino !== stats.ino)\n ) {\n return false\n }\n\n const lastRead = writtenByThis ? record.newestSource : null\n\n if (\n lastRead === null\n ? stats.mtimeMs <= newestSource\n : newestSource > lastRead\n ) {\n return false\n }\n }\n\n return true\n}\n\n// The newest mtime among the files a configuration reads, or `null` where that\n// cannot be established: a source that cannot be stat'ed, or patterns that\n// matched no file at all.\n//\n// Asked before a compile reads anything, and recorded against what it writes,\n// so that the next `isUpToDate` compares against what the compile actually\n// read. A save that lands afterwards is newer than this, however much newer\n// the compile's own output turns out to be. Both sides of that comparison are\n// mtimes of the files themselves, so no clock is involved and nothing depends\n// on how finely the filesystem stamps a write.\nexport async function newestSourceOf(\n {\n log,\n root,\n watch,\n }: {\n log: Log\n root: string\n watch: UnpluginStyleDictionaryOptions['watch']\n },\n item: ResolvedConfig,\n configObj: Config,\n): Promise<null | number> {\n // `options.watch` belongs in here as much as `source` does. A consumer\n // names an extra file because something in the build reads it — a custom\n // format's own data file, most obviously — and leaving it out let a change\n // to it be skipped over while the watcher dutifully reported it.\n const read = sourcePatternsOf(configObj)\n const extra = watchOptionPatterns(root, watch)\n\n const sources = await expandPatterns(\n {\n globs: [...read.globs, ...extra.globs],\n literals: [...read.literals, ...extra.literals],\n },\n log,\n )\n if (item.file) sources.push(item.file.replace(/\\\\/g, '/'))\n\n if (sources.length === 0) return null\n\n let newestSource = -Infinity\n let sawFile = false\n\n for (const source of sources) {\n const stats = statOrNull(source)\n if (!stats) return null\n\n // Directories are in this list on purpose — `expandPatterns` registers\n // each pattern's static parent so a token file created later is\n // noticed — but their mtime cannot be read as an input signal here. A\n // directory's mtime moves whenever an entry is added or renamed inside\n // it, and the atomic write renames every generated file into place, so\n // a `buildPath` inside a watched directory made the build itself the\n // newest thing the comparison could see. Nothing was ever up to date.\n if (stats.isDirectory()) continue\n\n sawFile = true\n newestSource = Math.max(newestSource, stats.mtimeMs)\n }\n\n // Every pattern expanded to directories alone, so nothing was actually\n // read. Style Dictionary would build an empty dictionary from that, and a\n // skip would present the empty result as current.\n return sawFile ? newestSource : null\n}\n\n// The records an earlier process persisted, each marked as such. Anything\n// that does not read as this version's records — a missing file, one another\n// version wrote, one cut short — is no records at all, which costs a compile\n// and never a wrong skip.\nexport function readPersistedRecords(\n file: string,\n): Map<string, DestinationRecord> {\n const records = new Map<string, DestinationRecord>()\n\n let parsed: unknown\n try {\n parsed = JSON.parse(fs.readFileSync(file, 'utf8'))\n } catch {\n return records\n }\n\n if (\n typeof parsed !== 'object' ||\n parsed === null ||\n !('version' in parsed) ||\n parsed.version !== RECORDS_VERSION ||\n !('records' in parsed) ||\n typeof parsed.records !== 'object' ||\n parsed.records === null\n ) {\n return records\n }\n\n const stored = parsed.records\n for (const destination of Object.keys(stored)) {\n const value: unknown = Reflect.get(stored, destination)\n if (\n typeof value === 'object' &&\n value !== null &&\n 'fingerprint' in value &&\n typeof value.fingerprint === 'string' &&\n 'ino' in value &&\n (value.ino === null || typeof value.ino === 'number') &&\n 'newestSource' in value &&\n (value.newestSource === null || typeof value.newestSource === 'number') &&\n 'size' in value &&\n (value.size === null || typeof value.size === 'number') &&\n 'written' in value &&\n (value.written === null || typeof value.written === 'number')\n ) {\n records.set(destination, {\n fingerprint: value.fingerprint,\n ino: value.ino,\n newestSource: value.newestSource,\n persisted: true,\n size: value.size,\n written: value.written,\n })\n }\n }\n\n return records\n}\n\n// The record a compile leaves for one destination it declared. One it wrote\n// is recorded with the file's mtime, size and inode as it stood once written,\n// and one Style Dictionary declined to write with all three `null`. `null`\n// comes back where a file it wrote is not there to vouch for, so nothing is\n// recorded for it and the next build compiles.\nexport function recordOf(\n destination: string,\n fingerprint: string,\n newestSource: null | number,\n wrote: boolean,\n): DestinationRecord | null {\n if (!wrote) {\n return { fingerprint, ino: null, newestSource, size: null, written: null }\n }\n\n const stats = statOrNull(destination)\n return stats\n ? {\n fingerprint,\n ino: stats.ino,\n newestSource,\n size: stats.size,\n written: stats.mtimeMs,\n }\n : null\n}\n\n// Where one project's records persist: under the nearest `package.json`'s\n// `node_modules/.cache`, the directory build tools share for this, and where\n// Vite keeps its own cache. The nearest package rather than `root`, because a\n// host's root can be a subdirectory — Vite's `root: 'src'` — and a cache\n// there would put a `node_modules` inside the consumer's sources. No `.json`\n// extension, so no `source` glob a consumer writes can read it as tokens.\nexport function recordsFileFor(root: string): string {\n let directory = root\n while (!fs.existsSync(path.join(directory, 'package.json'))) {\n const parent = path.dirname(directory)\n if (parent === directory) {\n directory = root\n break\n }\n directory = parent\n }\n\n return path.join(\n directory,\n 'node_modules',\n '.cache',\n 'unplugin-style-dictionary',\n 'records',\n )\n}\n\n// Merges what one compile recorded into the persisted records.\n//\n// Read again right before the write rather than trusted from the load, so a\n// record another process added since is kept. Two processes writing at once\n// can still lose one's entries, and that only costs a compile on the next\n// start. An entry whose destination is gone is dropped, which bounds the file\n// by the files that exist — and for a file the compile left unwritten, which is\n// never there, by the directory it would have been written into. Written\n// through a temporary file and a rename, so a reader never sees half of it.\nexport function writePersistedRecords(\n file: string,\n recorded: ReadonlyMap<string, DestinationRecord>,\n): void {\n const merged = readPersistedRecords(file)\n for (const [destination, record] of recorded) merged.set(destination, record)\n\n const records: Record<string, Omit<DestinationRecord, 'persisted'>> = {}\n for (const [destination, record] of merged) {\n const { fingerprint, ino, newestSource, size, written } = record\n const stillThere =\n written === null\n ? statOrNull(path.dirname(destination))\n : statOrNull(destination)\n if (stillThere) {\n records[destination] = { fingerprint, ino, newestSource, size, written }\n }\n }\n\n const temporary = `${file}.${String(process.pid)}.tmp`\n try {\n fs.mkdirSync(path.dirname(file), { recursive: true })\n fs.writeFileSync(\n temporary,\n JSON.stringify({ records, version: RECORDS_VERSION }),\n )\n fs.renameSync(temporary, file)\n } catch {\n // Only a cache: failing to write it costs a compile on the next start,\n // and never this build.\n try {\n fs.rmSync(temporary, { force: true })\n } catch {\n // Nothing more to do about a temporary file that cannot be removed.\n }\n }\n}\n\n// Where Style Dictionary writes one file, as an absolute path.\n//\n// It joins the destination onto the platform's `buildPath` when there is one\n// and writes the result relative to the working directory. **Joins, not\n// resolves**: an absolute destination lands under the build path rather than\n// replacing it. Measured with `buildPath: 'gen/'` and an absolute destination,\n// Style Dictionary wrote `gen/<that path>`, while the plugin had named the\n// absolute path on its own — a file that was never written.\n//\n// `root` has no say. It is where a relative `config` path is looked up and\n// nothing more, and reading `buildPath` against it named files that did not\n// exist whenever the two differed (#362). Style Dictionary joins with\n// `path-unified/posix`; the platform's own `join` names the same file, and\n// resolving the result is what normalises the separators either way.\nexport function writtenDestination(\n buildPath: string | undefined,\n destination: string,\n): string {\n return path.resolve(\n process.cwd(),\n buildPath ? path.join(buildPath, destination) : destination,\n )\n}\n\n// The hooks a configuration names by string, each as the registry holds it\n// now: the parsers and preprocessors it lists, each platform's transforms and\n// the transforms its `transformGroup` expands to, and each file's `format`,\n// `filter` and `fileHeader`. A hook given inline is part of the configuration\n// already. The configuration's own `hooks` win over the registry, as they do\n// when Style Dictionary builds.\n//\n// **Only the hooks it names, never the whole registry.** The registry is\n// shared by the whole process, so fingerprinting all of it would let one\n// configuration's `register*` call defeat every other configuration's skip.\nfunction namedHooksOf(\n configObj: Config,\n): Record<string, Record<string, unknown>> {\n const own: Hooks = configObj.hooks ?? {}\n const registry = StyleDictionary.hooks\n const named: Record<string, Record<string, unknown>> = {}\n\n const add = (kind: keyof Hooks, name: unknown) => {\n if (typeof name !== 'string') return\n const hook: unknown = own[kind]?.[name] ?? registry[kind][name]\n named[kind] ??= {}\n named[kind][name] = hook ?? null\n }\n\n for (const name of configObj.parsers ?? []) add('parsers', name)\n for (const name of configObj.preprocessors ?? []) add('preprocessors', name)\n\n for (const platform of Object.values(configObj.platforms ?? {})) {\n for (const name of platform.preprocessors ?? []) add('preprocessors', name)\n for (const transform of platform.transforms ?? []) {\n add('transforms', transform)\n }\n\n const group = platform.transformGroup\n if (group !== undefined) {\n add('transformGroups', group)\n\n // `unknown`, because a group nobody registered is `undefined` here\n // whatever the registry's type says.\n const members: unknown =\n own.transformGroups?.[group] ?? registry.transformGroups[group]\n if (Array.isArray(members)) {\n for (const name of members) add('transforms', name)\n }\n }\n\n add('fileHeaders', platform.options?.fileHeader)\n for (const file of platform.files ?? []) {\n add('formats', file.format)\n add('filters', file.filter)\n add('fileHeaders', file.options?.fileHeader)\n }\n }\n\n return named\n}\n\n// `fs.statSync` without the throw. A file that is missing, or that cannot be\n// read, is the same answer to every caller here: nothing to compare against.\nfunction statOrNull(file: string): fs.Stats | null {\n try {\n return fs.statSync(file)\n } catch {\n return null\n }\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kanso-labs/unplugin-style-dictionary",
|
|
3
|
-
"version": "0.10.
|
|
4
|
-
"description": "Compile Style Dictionary design tokens ahead of your bundler (Vite, Rolldown, Rollup, or
|
|
3
|
+
"version": "0.10.19",
|
|
4
|
+
"description": "Compile Style Dictionary design tokens ahead of your bundler (Vite, Rolldown, Rollup, Webpack or Rspack) from a single unplugin-based plugin, rebuilding on a token change under Vite's dev server and each bundler's watch mode",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"unplugin",
|
|
7
7
|
"vite",
|
|
8
8
|
"rolldown",
|
|
9
9
|
"rollup",
|
|
10
10
|
"webpack",
|
|
11
|
+
"rspack",
|
|
11
12
|
"style-dictionary",
|
|
12
13
|
"design-tokens",
|
|
13
14
|
"tokens"
|
|
@@ -88,7 +89,7 @@
|
|
|
88
89
|
"@types/picomatch": "4.0.3",
|
|
89
90
|
"@types/semver": "7.8.0",
|
|
90
91
|
"@vitest/coverage-v8": "5.0.3",
|
|
91
|
-
"eslint": "10.
|
|
92
|
+
"eslint": "10.12.0",
|
|
92
93
|
"eslint-plugin-oxlint": "1.86.0",
|
|
93
94
|
"eslint-plugin-perfectionist": "5.12.1",
|
|
94
95
|
"globals": "17.13.0",
|
|
@@ -102,7 +103,7 @@
|
|
|
102
103
|
"rolldown": "1.2.12",
|
|
103
104
|
"rollup": "4.64.0",
|
|
104
105
|
"semver": "7.8.5",
|
|
105
|
-
"style-dictionary": "5.
|
|
106
|
+
"style-dictionary": "5.6.0",
|
|
106
107
|
"tsdown": "0.23.0",
|
|
107
108
|
"typescript": "6.0.3",
|
|
108
109
|
"typescript-eslint": "8.71.0",
|