@kanso-labs/unplugin-style-dictionary 0.10.12 → 0.10.14

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.
@@ -1 +1 @@
1
- {"version":3,"file":"patterns.js","names":[],"sources":["../src/patterns.ts"],"sourcesContent":["// The `source` and `include` patterns a configuration reads, the watch list\n// derived from them, and the paths a watcher is actually handed for it.\n//\n// Nothing here holds state. What a plugin instance owns — its root, its\n// logger, its `watch` option — is handed in on each call.\n\nimport type { Config } from 'style-dictionary'\n\nimport fs from 'node:fs'\nimport path from 'node:path'\nimport { glob } from 'tinyglobby'\n\nimport type { Log, ResolvedConfig } from './config.js'\nimport type { UnpluginStyleDictionaryOptions } from './types.js'\n\nimport { readConfigObject } from './config.js'\nimport { errorMessage } from './errors.js'\n\n// A pattern is a glob when any of these appear in it. Deliberately the set\n// picomatch and tinyglobby act on, since those two are what match and expand\n// here — a path containing one of these characters literally is not\n// distinguishable from a pattern, and would not be matchable either.\nconst GLOB_CHARACTERS = /[!*?[\\]{}]/\n\n// What a watcher is handed, and what a changed path is tested against, are\n// not the same list, and conflating them is why a glob source was watched by\n// nothing at all. Every watcher in play takes filenames rather than\n// patterns: Vite's chokidar and rollup's `FileWatcher` are both constructed\n// with `disableGlobbing: true`, Vite's `addWatchFile` drops anything that\n// fails `fs.existsSync`, and webpack never globs `fileDependencies`. So the\n// patterns stay for matching and the paths are expanded for registering.\nexport async function expandPatterns(\n patterns: string[],\n log: Log,\n): Promise<string[]> {\n const paths = new Set<string>()\n const globs: string[] = []\n\n for (const pattern of patterns) {\n if (GLOB_CHARACTERS.test(pattern)) {\n globs.push(pattern)\n\n // Watching the directory as well as its current contents. chokidar\n // reports a creation inside a watched directory, which is the only\n // way a token file added later is ever noticed.\n const parent = staticParentOf(pattern)\n if (parent && fs.existsSync(parent)) paths.add(parent)\n } else {\n paths.add(pattern)\n }\n }\n\n if (globs.length > 0) {\n try {\n // tinyglobby matches with picomatch, which is what\n // `matchesWatchedFile` tests with, so what is registered here and what\n // is accepted there cannot disagree.\n for (const match of await glob(globs, { absolute: true })) {\n paths.add(match.replace(/\\\\/g, '/'))\n }\n } catch (err) {\n log(`Failed to expand watch patterns: ${errorMessage(err)}`, 'error')\n }\n }\n\n return Array.from(paths)\n}\n\n// Which of a configuration's own `source`/`include` patterns match no file on\n// disk. Only for diagnosis: it is the emptiness of the resolved token set that\n// decides whether a build fails, because only that catches every route to an\n// empty set. This names the pattern at fault, which the token count cannot, and\n// it reports a mistyped pattern in a configuration whose others still match —\n// where nothing fails at all and one platform quietly loses its tokens.\nexport async function patternsMatchingNothing(\n patterns: string[],\n): Promise<string[]> {\n const barren: string[] = []\n\n for (const pattern of patterns) {\n // A literal path is a `stat`, not a glob: `tinyglobby` treats a path with\n // no magic characters as a literal anyway, and this keeps the common case\n // off the filesystem walk.\n if (!GLOB_CHARACTERS.test(pattern)) {\n if (!fs.existsSync(pattern)) barren.push(pattern)\n continue\n }\n\n try {\n const matched = await glob([pattern], { absolute: true })\n if (matched.length === 0) barren.push(pattern)\n } catch {\n // A pattern that cannot even be globbed is the build's problem to\n // report; saying it twice, in a diagnostic, helps nobody.\n }\n }\n\n return barren\n}\n\n// The patterns one configuration reads, resolved the way the build resolves\n// them. The watch list, the up-to-date check and the empty-token-set message\n// all read a configuration's patterns through here, so they cannot disagree\n// about which files it reads.\n//\n// Against the working directory, because that is where Style Dictionary\n// resolves them: `combineJSON` globs each pattern with no `cwd` of its own.\n// Resolving against the configuration file's directory instead is how the\n// watch list came to name paths the build never reads — a configuration in a\n// subdirectory built correctly and watched nothing at all.\n//\n// An empty pattern is skipped, because Style Dictionary reads nothing from one.\n// Resolved, it would be the working directory itself, and the watch list used\n// to register exactly that for an empty entry in a `source` array.\nexport function sourcePatternsOf(configObj: Config): string[] {\n const patterns: string[] = []\n\n const add = (pattern: unknown) => {\n if (typeof pattern === 'string' && pattern !== '') {\n patterns.push(\n (path.isAbsolute(pattern)\n ? pattern\n : path.resolve(process.cwd(), pattern)\n ).replace(/\\\\/g, '/'),\n )\n }\n }\n\n for (const value of [configObj.source, configObj.include]) {\n if (Array.isArray(value)) value.forEach(add)\n else add(value)\n }\n\n return patterns\n}\n\n// Parse token files to watch\n//\n// The patterns alone. Expanding them into the paths a watcher takes, and\n// remembering them for `watchChange` to filter against, is the caller's: the\n// second is state of one plugin instance, and this module holds none.\nexport async function watchPatternsOf(\n {\n log,\n root,\n watch,\n }: {\n log: Log\n root: string\n watch: UnpluginStyleDictionaryOptions['watch']\n },\n resolvedConfigs: ResolvedConfig[],\n): Promise<string[]> {\n const filesToWatch = new Set<string>()\n\n for (const item of resolvedConfigs) {\n if (item.file) {\n filesToWatch.add(item.file.replace(/\\\\/g, '/'))\n }\n\n const configObj = await readConfigObject(item, log)\n\n if (configObj) {\n for (const pattern of sourcePatternsOf(configObj)) {\n filesToWatch.add(pattern)\n }\n }\n }\n\n // Add manually configured watch files\n if (watch) {\n const extraWatches = Array.isArray(watch) ? watch : [watch]\n for (const pattern of extraWatches) {\n const absolutePattern = path.isAbsolute(pattern)\n ? pattern\n : path.resolve(root, pattern)\n filesToWatch.add(absolutePattern.replace(/\\\\/g, '/'))\n }\n }\n\n return Array.from(filesToWatch)\n}\n\n// The leading run of a pattern that contains no glob character —\n// `/p/tokens` for `/p/tokens/**/*.json`. Registering it alongside the files\n// that match today is what makes a token file created tomorrow visible:\n// watching only the current matches can never see a path that did not exist\n// when the watcher was built.\nfunction staticParentOf(pattern: string): string {\n const segments = pattern.split('/')\n const firstGlob = segments.findIndex((segment) =>\n GLOB_CHARACTERS.test(segment),\n )\n\n return firstGlob === -1\n ? path.posix.dirname(pattern)\n : segments.slice(0, firstGlob).join('/')\n}\n"],"mappings":";;;;;;AAsBA,MAAM,kBAAkB;AASxB,eAAsB,eACpB,UACA,KACmB;CACnB,MAAM,wBAAQ,IAAI,IAAY;CAC9B,MAAM,QAAkB,CAAC;CAEzB,KAAK,MAAM,WAAW,UACpB,IAAI,gBAAgB,KAAK,OAAO,GAAG;EACjC,MAAM,KAAK,OAAO;EAKlB,MAAM,SAAS,eAAe,OAAO;EACrC,IAAI,UAAU,GAAG,WAAW,MAAM,GAAG,MAAM,IAAI,MAAM;CACvD,OACE,MAAM,IAAI,OAAO;CAIrB,IAAI,MAAM,SAAS,GACjB,IAAI;EAIF,KAAK,MAAM,SAAS,MAAM,KAAK,OAAO,EAAE,UAAU,KAAK,CAAC,GACtD,MAAM,IAAI,MAAM,QAAQ,OAAO,GAAG,CAAC;CAEvC,SAAS,KAAK;EACZ,IAAI,oCAAoC,aAAa,GAAG,KAAK,OAAO;CACtE;CAGF,OAAO,MAAM,KAAK,KAAK;AACzB;AAQA,eAAsB,wBACpB,UACmB;CACnB,MAAM,SAAmB,CAAC;CAE1B,KAAK,MAAM,WAAW,UAAU;EAI9B,IAAI,CAAC,gBAAgB,KAAK,OAAO,GAAG;GAClC,IAAI,CAAC,GAAG,WAAW,OAAO,GAAG,OAAO,KAAK,OAAO;GAChD;EACF;EAEA,IAAI;GAEF,KAAI,MADkB,KAAK,CAAC,OAAO,GAAG,EAAE,UAAU,KAAK,CAAC,EAAA,CAC5C,WAAW,GAAG,OAAO,KAAK,OAAO;EAC/C,QAAQ,CAGR;CACF;CAEA,OAAO;AACT;AAgBA,SAAgB,iBAAiB,WAA6B;CAC5D,MAAM,WAAqB,CAAC;CAE5B,MAAM,OAAO,YAAqB;EAChC,IAAI,OAAO,YAAY,YAAY,YAAY,IAC7C,SAAS,MACN,KAAK,WAAW,OAAO,IACpB,UACA,KAAK,QAAQ,QAAQ,IAAI,GAAG,OAAO,EAAA,CACrC,QAAQ,OAAO,GAAG,CACtB;CAEJ;CAEA,KAAK,MAAM,SAAS,CAAC,UAAU,QAAQ,UAAU,OAAO,GACtD,IAAI,MAAM,QAAQ,KAAK,GAAG,MAAM,QAAQ,GAAG;MACtC,IAAI,KAAK;CAGhB,OAAO;AACT;AAOA,eAAsB,gBACpB,EACE,KACA,MACA,SAMF,iBACmB;CACnB,MAAM,+BAAe,IAAI,IAAY;CAErC,KAAK,MAAM,QAAQ,iBAAiB;EAClC,IAAI,KAAK,MACP,aAAa,IAAI,KAAK,KAAK,QAAQ,OAAO,GAAG,CAAC;EAGhD,MAAM,YAAY,MAAM,iBAAiB,MAAM,GAAG;EAElD,IAAI,WACF,KAAK,MAAM,WAAW,iBAAiB,SAAS,GAC9C,aAAa,IAAI,OAAO;CAG9B;CAGA,IAAI,OAAO;EACT,MAAM,eAAe,MAAM,QAAQ,KAAK,IAAI,QAAQ,CAAC,KAAK;EAC1D,KAAK,MAAM,WAAW,cAAc;GAClC,MAAM,kBAAkB,KAAK,WAAW,OAAO,IAC3C,UACA,KAAK,QAAQ,MAAM,OAAO;GAC9B,aAAa,IAAI,gBAAgB,QAAQ,OAAO,GAAG,CAAC;EACtD;CACF;CAEA,OAAO,MAAM,KAAK,YAAY;AAChC;AAOA,SAAS,eAAe,SAAyB;CAC/C,MAAM,WAAW,QAAQ,MAAM,GAAG;CAClC,MAAM,YAAY,SAAS,WAAW,YACpC,gBAAgB,KAAK,OAAO,CAC9B;CAEA,OAAO,cAAc,KACjB,KAAK,MAAM,QAAQ,OAAO,IAC1B,SAAS,MAAM,GAAG,SAAS,CAAC,CAAC,KAAK,GAAG;AAC3C"}
1
+ {"version":3,"file":"patterns.js","names":[],"sources":["../src/patterns.ts"],"sourcesContent":["// The `source` and `include` patterns a configuration reads, the watch list\n// derived from them, and the paths a watcher is actually handed for it.\n//\n// Nothing here holds state. What a plugin instance owns — its root, its\n// logger, its `watch` option — is handed in on each call.\n\nimport type { Config } from 'style-dictionary'\n\nimport fs from 'node:fs'\nimport path from 'node:path'\nimport { escapePath, glob } from 'tinyglobby'\n\nimport type { Log, ResolvedConfig } from './config.js'\nimport type { UnpluginStyleDictionaryOptions } from './types.js'\n\nimport { readConfigObject } from './config.js'\nimport { errorMessage } from './errors.js'\n\n// A pattern is a glob when any of these appear in it. Deliberately the set\n// picomatch and tinyglobby act on, since those two are what match and expand\n// here — a path containing one of these characters literally is not\n// distinguishable from a pattern, and would not be matchable either.\n//\n// That is why it is only ever asked of what a consumer wrote. The plugin\n// makes a relative pattern absolute by prefixing a directory of its own\n// choosing, and that directory's characters are no part of the pattern.\nconst GLOB_CHARACTERS = /[!*?[\\]{}]/\n\n// The patterns a watch list is made of, split by what each one is. The split\n// is decided once, from the pattern as the consumer wrote it, and never again\n// from the absolute string: that string carries the project's own path, and a\n// project in `Dropbox (Personal)` or `app [v2]` would read as a glob.\n//\n// `literals` are absolute paths, matched by equality and registered as they\n// are. `globs` are absolute patterns in picomatch's syntax, in which the part\n// the plugin prefixed is escaped.\nexport interface WatchPatterns {\n globs: string[]\n literals: string[]\n}\n\n// What a watcher is handed, and what a changed path is tested against, are\n// not the same list, and conflating them is why a glob source was watched by\n// nothing at all. Every watcher in play takes filenames rather than\n// patterns: Vite's chokidar and rollup's `FileWatcher` are both constructed\n// with `disableGlobbing: true`, Vite's `addWatchFile` drops anything that\n// fails `fs.existsSync`, and webpack never globs `fileDependencies`. So the\n// patterns stay for matching and the paths are expanded for registering.\nexport async function expandPatterns(\n { globs, literals }: WatchPatterns,\n log: Log,\n): Promise<string[]> {\n const paths = new Set<string>(literals)\n\n for (const pattern of globs) {\n // Watching the directory as well as its current contents. chokidar\n // reports a creation inside a watched directory, which is the only way a\n // token file added later is ever noticed.\n const parent = staticParentOf(pattern)\n if (parent && fs.existsSync(parent)) paths.add(parent)\n }\n\n if (globs.length > 0) {\n try {\n // tinyglobby matches with picomatch, which is what\n // `matchesWatchedFile` tests with, so what is registered here and what\n // is accepted there cannot disagree.\n for (const match of await glob(globs, { absolute: true })) {\n paths.add(match.replace(/\\\\/g, '/'))\n }\n } catch (err) {\n log(`Failed to expand watch patterns: ${errorMessage(err)}`, 'error')\n }\n }\n\n return Array.from(paths)\n}\n\n// Which of a configuration's own `source`/`include` patterns match no file on\n// disk. Only for diagnosis: it is the emptiness of the resolved token set that\n// decides whether a build fails, because only that catches every route to an\n// empty set. This names the pattern at fault, which the token count cannot, and\n// it reports a mistyped pattern in a configuration whose others still match —\n// where nothing fails at all and one platform quietly loses its tokens.\nexport async function patternsMatchingNothing({\n globs,\n literals,\n}: WatchPatterns): Promise<string[]> {\n // A literal path is a `stat`, not a glob, which keeps the common case off\n // the filesystem walk.\n const barren = literals.filter((literal) => !fs.existsSync(literal))\n\n for (const pattern of globs) {\n try {\n const matched = await glob([pattern], { absolute: true })\n if (matched.length === 0) barren.push(pattern)\n } catch {\n // A pattern that cannot even be globbed is the build's problem to\n // report; saying it twice, in a diagnostic, helps nobody.\n }\n }\n\n return barren\n}\n\n// Where `file` is once every symbolic link on its path is followed, spelled\n// with forward slashes like every other path handed to a watcher here.\n// `undefined` when nothing is there, which a literal is allowed to be: it is\n// registered whether or not it exists yet.\nexport function realpathOf(file: string): string | undefined {\n try {\n return fs.realpathSync(file).replace(/\\\\/g, '/')\n } catch {\n return undefined\n }\n}\n\n// The patterns one configuration reads, resolved the way the build resolves\n// them. The watch list, the up-to-date check and the empty-token-set message\n// all read a configuration's patterns through here, so they cannot disagree\n// about which files it reads.\n//\n// Against the working directory, because that is where Style Dictionary\n// resolves them: `combineJSON` globs each pattern with no `cwd` of its own.\n// Resolving against the configuration file's directory instead is how the\n// watch list came to name paths the build never reads — a configuration in a\n// subdirectory built correctly and watched nothing at all.\n//\n// An empty pattern is skipped, because Style Dictionary reads nothing from one.\n// Resolved, it would be the working directory itself, and the watch list used\n// to register exactly that for an empty entry in a `source` array.\nexport function sourcePatternsOf(configObj: Config): WatchPatterns {\n const patterns: WatchPatterns = { globs: [], literals: [] }\n\n const add = (pattern: unknown) => {\n if (typeof pattern === 'string' && pattern !== '') {\n addAnchored(patterns, process.cwd(), pattern)\n }\n }\n\n for (const value of [configObj.source, configObj.include]) {\n if (Array.isArray(value)) value.forEach(add)\n else add(value)\n }\n\n return patterns\n}\n\n// The `watch` option's entries, resolved the way #369 settled: a relative one\n// against `root`, since it is an option of this plugin rather than a path\n// inside a configuration. The watch list and the up-to-date check both read\n// them through here.\nexport function watchOptionPatterns(\n root: string,\n watch: UnpluginStyleDictionaryOptions['watch'],\n): WatchPatterns {\n const patterns: WatchPatterns = { globs: [], literals: [] }\n\n for (const pattern of watch ? (Array.isArray(watch) ? watch : [watch]) : []) {\n addAnchored(patterns, root, pattern)\n }\n\n return patterns\n}\n\n// Parse token files to watch\n//\n// The patterns alone. Expanding them into the paths a watcher takes, and\n// remembering them for `watchChange` to filter against, is the caller's: the\n// second is state of one plugin instance, and this module holds none.\nexport async function watchPatternsOf(\n {\n log,\n root,\n watch,\n }: {\n log: Log\n root: string\n watch: UnpluginStyleDictionaryOptions['watch']\n },\n resolvedConfigs: ResolvedConfig[],\n): Promise<WatchPatterns> {\n const globs = new Set<string>()\n const literals = new Set<string>()\n\n const add = (patterns: WatchPatterns) => {\n for (const pattern of patterns.globs) globs.add(pattern)\n for (const literal of patterns.literals) literals.add(literal)\n }\n\n for (const item of resolvedConfigs) {\n // A path, whatever characters its directories happen to contain.\n if (item.file) literals.add(item.file.replace(/\\\\/g, '/'))\n\n const configObj = await readConfigObject(item, log)\n if (configObj) add(sourcePatternsOf(configObj))\n }\n\n add(watchOptionPatterns(root, watch))\n\n return { globs: Array.from(globs), literals: Array.from(literals) }\n}\n\n// One pattern as a consumer wrote it, made absolute against `base` and filed\n// under what it is.\n//\n// Whether it is a glob is read off what they wrote, before anything is\n// prefixed, and of a relative glob only the prefix is escaped. The rest is\n// theirs, glob characters included, exactly as `GLOB_CHARACTERS` says. The\n// prefix is `base` joined with the pattern's leading static segments,\n// resolved together so a `./` or `../` still settles against `base`.\n//\n// An absolute glob is taken as written. Every character in it is the\n// consumer's, and escaping any would be guessing which they meant as syntax.\nfunction addAnchored(into: WatchPatterns, base: string, written: string): void {\n const pattern = written.replace(/\\\\/g, '/')\n\n if (!GLOB_CHARACTERS.test(pattern)) {\n into.literals.push(path.resolve(base, pattern).replace(/\\\\/g, '/'))\n return\n }\n\n if (path.isAbsolute(pattern)) {\n into.globs.push(pattern)\n return\n }\n\n const segments = pattern.split('/')\n const firstGlob = segments.findIndex((segment) =>\n GLOB_CHARACTERS.test(segment),\n )\n const prefix = path\n .resolve(base, segments.slice(0, firstGlob).join('/'))\n .replace(/\\\\/g, '/')\n\n into.globs.push(\n path.posix.join(escapePath(prefix), segments.slice(firstGlob).join('/')),\n )\n}\n\n// The leading run of a pattern that contains no glob character —\n// `/p/tokens` for `/p/tokens/**/*.json`. Registering it alongside the files\n// that match today is what makes a token file created tomorrow visible:\n// watching only the current matches can never see a path that did not exist\n// when the watcher was built.\n//\n// An escaped character is not a glob character, so the prefix `addAnchored`\n// escaped never ends the run, and the result is unescaped: it is a path on\n// disk, handed to `fs.existsSync` and to the watcher. Before the prefix was\n// escaped, a project in `app [v2]` cut the run at its own directory, and the\n// directory holding every project beside it was registered instead.\nfunction staticParentOf(pattern: string): string {\n const segments = pattern.split('/')\n const firstGlob = segments.findIndex((segment) =>\n GLOB_CHARACTERS.test(segment.replace(/\\\\./g, '')),\n )\n\n const parent =\n firstGlob === -1\n ? path.posix.dirname(pattern)\n : segments.slice(0, firstGlob).join('/')\n\n return parent.replace(/\\\\(.)/g, '$1')\n}\n"],"mappings":"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"}
@@ -3,13 +3,13 @@ import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import zlib from "node:zlib";
5
5
  //#region src/size-report.ts
6
- function reportSizes({ root, stdoutColour }, generatedFiles) {
6
+ function sizeTable({ colour, root }, generatedFiles) {
7
7
  const fileInfos = [];
8
8
  for (const filePath of generatedFiles) if (fs.existsSync(filePath)) {
9
9
  const displayPath = path.relative(root, filePath).replace(/\\/g, "/");
10
10
  const dir = path.dirname(displayPath);
11
11
  const base = path.basename(displayPath);
12
- const coloredPath = dir === "." ? paint("32", base, stdoutColour) : paint("90", `${dir}/`, stdoutColour) + paint("32", base, stdoutColour);
12
+ const coloredPath = dir === "." ? paint("32", base, colour) : paint("90", `${dir}/`, colour) + paint("32", base, colour);
13
13
  try {
14
14
  const sizeStr = `${(fs.statSync(filePath).size / 1024).toFixed(2)} kB`;
15
15
  const content = fs.readFileSync(filePath);
@@ -22,17 +22,19 @@ function reportSizes({ root, stdoutColour }, generatedFiles) {
22
22
  });
23
23
  } catch {}
24
24
  }
25
+ const lines = [];
25
26
  if (fileInfos.length > 0) {
26
27
  const longestPathLength = Math.max(...fileInfos.map((f) => f.relativeDisplayPath.length), 0);
27
28
  const longestSizeLength = Math.max(...fileInfos.map((f) => f.sizeStr.length), 0);
28
29
  for (const info of fileInfos) {
29
30
  const pathPadding = " ".repeat(Math.max(2, longestPathLength - info.relativeDisplayPath.length + 2));
30
31
  const sizePadded = info.sizeStr.padStart(longestSizeLength);
31
- console.log(info.coloredPath + pathPadding + paint("90", `${sizePadded} │ gzip: ${info.gzipSizeStr}`, stdoutColour));
32
+ lines.push(info.coloredPath + pathPadding + paint("90", `${sizePadded} │ gzip: ${info.gzipSizeStr}`, colour));
32
33
  }
33
34
  }
35
+ return lines;
34
36
  }
35
37
  //#endregion
36
- export { reportSizes };
38
+ export { sizeTable };
37
39
 
38
40
  //# sourceMappingURL=size-report.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"size-report.js","names":[],"sources":["../src/size-report.ts"],"sourcesContent":["import fs from 'node:fs'\nimport path from 'node:path'\nimport zlib from 'node:zlib'\n\nimport { paint } from './colour.js'\n\n// The size-and-gzip table, in a function of its own so that the compile\n// `try` in `runBuilds` can stop before it. Everything here is presentation\n// over files Style Dictionary has already finished writing, so a throw from\n// it is a reporting bug and nothing more.\n//\n// `root` and whether stdout takes colour belong to the plugin instance, so\n// they are handed in rather than held.\nexport function reportSizes(\n { root, stdoutColour }: { root: string; stdoutColour: boolean },\n generatedFiles: Set<string>,\n): void {\n const fileInfos: Array<{\n coloredPath: string\n gzipSizeStr: string\n relativeDisplayPath: string\n sizeStr: string\n }> = []\n\n for (const filePath of generatedFiles) {\n if (fs.existsSync(filePath)) {\n const displayPath = path.relative(root, filePath).replace(/\\\\/g, '/')\n const dir = path.dirname(displayPath)\n const base = path.basename(displayPath)\n // The table goes to stdout, so it follows stdout's decision — which\n // is not always stderr's, since the two are redirected separately.\n const coloredPath =\n dir === '.'\n ? paint('32', base, stdoutColour)\n : paint('90', `${dir}/`, stdoutColour) +\n paint('32', base, stdoutColour)\n\n try {\n const stats = fs.statSync(filePath)\n const bytes = stats.size\n const sizeStr = `${(bytes / 1024).toFixed(2)} kB`\n\n const content = fs.readFileSync(filePath)\n const gzipBytes = zlib.gzipSync(content).length\n const gzipSizeStr = `${(gzipBytes / 1024).toFixed(2)} kB`\n\n fileInfos.push({\n coloredPath,\n gzipSizeStr,\n relativeDisplayPath: displayPath,\n sizeStr,\n })\n } catch {\n // One unreadable destination costs its row rather than the table.\n // Deliberately narrower than the caller's `catch`: it covers the\n // three filesystem and gzip calls above and not the arithmetic\n // below, so a padding bug is reported rather than quietly printing\n // short.\n }\n }\n }\n\n if (fileInfos.length > 0) {\n const longestPathLength = Math.max(\n ...fileInfos.map((f) => f.relativeDisplayPath.length),\n 0,\n )\n const longestSizeLength = Math.max(\n ...fileInfos.map((f) => f.sizeStr.length),\n 0,\n )\n\n for (const info of fileInfos) {\n const pathPadding = ' '.repeat(\n Math.max(2, longestPathLength - info.relativeDisplayPath.length + 2),\n )\n const sizePadded = info.sizeStr.padStart(longestSizeLength)\n console.log(\n info.coloredPath +\n pathPadding +\n paint(\n '90',\n `${sizePadded} │ gzip: ${info.gzipSizeStr}`,\n stdoutColour,\n ),\n )\n }\n }\n}\n"],"mappings":";;;;;AAaA,SAAgB,YACd,EAAE,MAAM,gBACR,gBACM;CACN,MAAM,YAKD,CAAC;CAEN,KAAK,MAAM,YAAY,gBACrB,IAAI,GAAG,WAAW,QAAQ,GAAG;EAC3B,MAAM,cAAc,KAAK,SAAS,MAAM,QAAQ,CAAC,CAAC,QAAQ,OAAO,GAAG;EACpE,MAAM,MAAM,KAAK,QAAQ,WAAW;EACpC,MAAM,OAAO,KAAK,SAAS,WAAW;EAGtC,MAAM,cACJ,QAAQ,MACJ,MAAM,MAAM,MAAM,YAAY,IAC9B,MAAM,MAAM,GAAG,IAAI,IAAI,YAAY,IACnC,MAAM,MAAM,MAAM,YAAY;EAEpC,IAAI;GAGF,MAAM,UAAU,IAFF,GAAG,SAAS,QACR,CAAC,CAAC,OACQ,KAAA,CAAM,QAAQ,CAAC,EAAE;GAE7C,MAAM,UAAU,GAAG,aAAa,QAAQ;GAExC,MAAM,cAAc,IADF,KAAK,SAAS,OAAO,CAAC,CAAC,SACL,KAAA,CAAM,QAAQ,CAAC,EAAE;GAErD,UAAU,KAAK;IACb;IACA;IACA,qBAAqB;IACrB;GACF,CAAC;EACH,QAAQ,CAMR;CACF;CAGF,IAAI,UAAU,SAAS,GAAG;EACxB,MAAM,oBAAoB,KAAK,IAC7B,GAAG,UAAU,KAAK,MAAM,EAAE,oBAAoB,MAAM,GACpD,CACF;EACA,MAAM,oBAAoB,KAAK,IAC7B,GAAG,UAAU,KAAK,MAAM,EAAE,QAAQ,MAAM,GACxC,CACF;EAEA,KAAK,MAAM,QAAQ,WAAW;GAC5B,MAAM,cAAc,IAAI,OACtB,KAAK,IAAI,GAAG,oBAAoB,KAAK,oBAAoB,SAAS,CAAC,CACrE;GACA,MAAM,aAAa,KAAK,QAAQ,SAAS,iBAAiB;GAC1D,QAAQ,IACN,KAAK,cACH,cACA,MACE,MACA,GAAG,WAAW,WAAW,KAAK,eAC9B,YACF,CACJ;EACF;CACF;AACF"}
1
+ {"version":3,"file":"size-report.js","names":[],"sources":["../src/size-report.ts"],"sourcesContent":["import fs from 'node:fs'\nimport path from 'node:path'\nimport zlib from 'node:zlib'\n\nimport { paint } from './colour.js'\n\n// The size-and-gzip table's lines, in a function of its own so that the\n// compile `try` in `runBuilds` can stop before it. Everything here is\n// presentation over files Style Dictionary has already finished writing, so a\n// throw from it is a reporting bug and nothing more.\n//\n// It returns the lines rather than printing them, because where they go is\n// the instance's to decide: a host's `info` channel where one exists, stdout\n// otherwise. `colour` says whether to paint them, which only stdout ever asks\n// for — a host renders its own output.\nexport function sizeTable(\n { colour, root }: { colour: boolean; root: string },\n generatedFiles: Set<string>,\n): string[] {\n const fileInfos: Array<{\n coloredPath: string\n gzipSizeStr: string\n relativeDisplayPath: string\n sizeStr: string\n }> = []\n\n for (const filePath of generatedFiles) {\n if (fs.existsSync(filePath)) {\n const displayPath = path.relative(root, filePath).replace(/\\\\/g, '/')\n const dir = path.dirname(displayPath)\n const base = path.basename(displayPath)\n const coloredPath =\n dir === '.'\n ? paint('32', base, colour)\n : paint('90', `${dir}/`, colour) + paint('32', base, colour)\n\n try {\n const stats = fs.statSync(filePath)\n const bytes = stats.size\n const sizeStr = `${(bytes / 1024).toFixed(2)} kB`\n\n const content = fs.readFileSync(filePath)\n const gzipBytes = zlib.gzipSync(content).length\n const gzipSizeStr = `${(gzipBytes / 1024).toFixed(2)} kB`\n\n fileInfos.push({\n coloredPath,\n gzipSizeStr,\n relativeDisplayPath: displayPath,\n sizeStr,\n })\n } catch {\n // One unreadable destination costs its row rather than the table.\n // Deliberately narrower than the caller's `catch`: it covers the\n // three filesystem and gzip calls above and not the arithmetic\n // below, so a padding bug is reported rather than quietly printing\n // short.\n }\n }\n }\n\n const lines: string[] = []\n\n if (fileInfos.length > 0) {\n const longestPathLength = Math.max(\n ...fileInfos.map((f) => f.relativeDisplayPath.length),\n 0,\n )\n const longestSizeLength = Math.max(\n ...fileInfos.map((f) => f.sizeStr.length),\n 0,\n )\n\n for (const info of fileInfos) {\n const pathPadding = ' '.repeat(\n Math.max(2, longestPathLength - info.relativeDisplayPath.length + 2),\n )\n const sizePadded = info.sizeStr.padStart(longestSizeLength)\n lines.push(\n info.coloredPath +\n pathPadding +\n paint('90', `${sizePadded} │ gzip: ${info.gzipSizeStr}`, colour),\n )\n }\n }\n\n return lines\n}\n"],"mappings":";;;;;AAeA,SAAgB,UACd,EAAE,QAAQ,QACV,gBACU;CACV,MAAM,YAKD,CAAC;CAEN,KAAK,MAAM,YAAY,gBACrB,IAAI,GAAG,WAAW,QAAQ,GAAG;EAC3B,MAAM,cAAc,KAAK,SAAS,MAAM,QAAQ,CAAC,CAAC,QAAQ,OAAO,GAAG;EACpE,MAAM,MAAM,KAAK,QAAQ,WAAW;EACpC,MAAM,OAAO,KAAK,SAAS,WAAW;EACtC,MAAM,cACJ,QAAQ,MACJ,MAAM,MAAM,MAAM,MAAM,IACxB,MAAM,MAAM,GAAG,IAAI,IAAI,MAAM,IAAI,MAAM,MAAM,MAAM,MAAM;EAE/D,IAAI;GAGF,MAAM,UAAU,IAFF,GAAG,SAAS,QACR,CAAC,CAAC,OACQ,KAAA,CAAM,QAAQ,CAAC,EAAE;GAE7C,MAAM,UAAU,GAAG,aAAa,QAAQ;GAExC,MAAM,cAAc,IADF,KAAK,SAAS,OAAO,CAAC,CAAC,SACL,KAAA,CAAM,QAAQ,CAAC,EAAE;GAErD,UAAU,KAAK;IACb;IACA;IACA,qBAAqB;IACrB;GACF,CAAC;EACH,QAAQ,CAMR;CACF;CAGF,MAAM,QAAkB,CAAC;CAEzB,IAAI,UAAU,SAAS,GAAG;EACxB,MAAM,oBAAoB,KAAK,IAC7B,GAAG,UAAU,KAAK,MAAM,EAAE,oBAAoB,MAAM,GACpD,CACF;EACA,MAAM,oBAAoB,KAAK,IAC7B,GAAG,UAAU,KAAK,MAAM,EAAE,QAAQ,MAAM,GACxC,CACF;EAEA,KAAK,MAAM,QAAQ,WAAW;GAC5B,MAAM,cAAc,IAAI,OACtB,KAAK,IAAI,GAAG,oBAAoB,KAAK,oBAAoB,SAAS,CAAC,CACrE;GACA,MAAM,aAAa,KAAK,QAAQ,SAAS,iBAAiB;GAC1D,MAAM,KACJ,KAAK,cACH,cACA,MAAM,MAAM,GAAG,WAAW,WAAW,KAAK,eAAe,MAAM,CACnE;EACF;CACF;CAEA,OAAO;AACT"}
package/dist/types.d.ts CHANGED
@@ -11,14 +11,21 @@ import { Config } from "style-dictionary";
11
11
  * build (e.g. `tsdown`/`rolldown build` without `--watch`) only builds once, in
12
12
  * `buildStart`.
13
13
  *
14
- * Everything the plugin says goes through the host rather than to the console:
15
- * Vite's `config.logger`, the plugin context under rollup and rolldown, and
16
- * `compilation.warnings` under webpack, which is what puts a failed compile in
17
- * `stats.toJson()`. A failure is reported on the warning channel and never the
18
- * error one — rollup's `this.error` aborts the bundle, and that decision is
19
- * `failOnError`'s alone. Where no host offers a channel the console is used,
20
- * with colour gated on `NO_COLOR`, `FORCE_COLOR` and whether the stream is a
21
- * terminal.
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.
22
29
  *
23
30
  * The three `onBuild*` hooks are called synchronously and their return value
24
31
  * is not awaited, so a build never waits for one. A hook may still be written
@@ -66,8 +73,8 @@ export interface StyleDictionaryConfigContext {
66
73
  /**
67
74
  * Whether the host will keep rebuilding.
68
75
  *
69
- * `true` under Vite's dev server, `rollup --watch`, `rolldown.watch()` and
70
- * `webpack --watch`; `false` for a one-shot build. It is read from the
76
+ * `true` under Vite's dev server and `vite build --watch`, `rollup --watch`,
77
+ * `rolldown.watch()` and `webpack --watch`; `false` for a one-shot build. It is read from the
71
78
  * host — the plugin context's `meta.watchMode` on the three rollup-shaped
72
79
  * targets, and `compiler.watchMode` on webpack — rather than inferred from
73
80
  * `command`, because `rollup --watch` both watches and builds.
@@ -83,8 +90,28 @@ export interface UnpluginStyleDictionaryOptions {
83
90
  * 4,000-token, two-platform configuration — and under Vite it runs inside
84
91
  * `server.listen()`, so the dev server does not accept a connection until
85
92
  * it finishes whether or not a token changed. A configuration is treated as
86
- * up to date when every file it declares exists and is newer than every
87
- * file it reads, its own config file included.
93
+ * up to date when every file its last compile wrote exists and is as that
94
+ * compile left it, and nothing it reads, its own config file included, has
95
+ * changed since that compile read it. A file it declares and Style
96
+ * Dictionary declined to write — a filter matching no tokens — does not stop
97
+ * it being skipped, since that file was never going to be there.
98
+ *
99
+ * "Since" means since the moment that compile began reading, so a token
100
+ * saved while a rebuild is compiling is rebuilt rather than taken for part
101
+ * of the output the rebuild went on to write. What each compile read is
102
+ * kept in `node_modules/.cache/unplugin-style-dictionary` beside the nearest
103
+ * `package.json`, so a restart still knows it. Where nothing is recorded,
104
+ * the files a configuration reads are compared against the files it
105
+ * declares instead.
106
+ *
107
+ * A compile is also recorded against the configuration that ran it: its
108
+ * contents, the hooks it names as Style Dictionary's registry holds them —
109
+ * each `format`, `filter`, `fileHeader`, transform, transform group,
110
+ * parser and preprocessor it refers to by name — and the Style Dictionary
111
+ * version. A new body registered under a name it uses, or an upgrade,
112
+ * rebuilds even though no file moved. What a hook's code calls from another
113
+ * module is not part of that code, so an edit there is not seen; naming the
114
+ * module in `watch` makes the next start rebuild.
88
115
  *
89
116
  * Two things are never skipped, because neither can be told from the
90
117
  * filesystem:
@@ -93,7 +120,9 @@ export interface UnpluginStyleDictionaryOptions {
93
120
  * object or a function.** There is no config file to stat, so an edit to
94
121
  * the object inside `vite.config.ts` moves no mtime. Within one process
95
122
  * the resolved configuration is compared against the one that was last
96
- * built; across processes there is nothing to compare, so it builds.
123
+ * built. An earlier process's record is not trusted for it, because its
124
+ * functions are compared by their source and what they close over can
125
+ * differ from one process to the next, so it builds.
97
126
  * - **A platform declaring `actions`.** An action writes what no
98
127
  * `destination` names, so a skip would leave its work undone.
99
128
  *
@@ -131,8 +160,9 @@ export interface UnpluginStyleDictionaryOptions {
131
160
  *
132
161
  * **`false` turns discovery off.** Two of the four names are modules rather
133
162
  * than data, and reading a module means running it: a `sd.config.js` in the
134
- * root is imported, freshly, on every watch event. Validation cannot prevent
135
- * that, because the check can only look at what the import returned — so a
163
+ * root is imported when it is first read, and again whenever it or a file
164
+ * named in `watch` changes. Validation cannot prevent that, because the
165
+ * check can only look at what the import returned — so a
136
166
  * project that names its configuration explicitly, or has none, should say
137
167
  * `config: false` rather than rely on there being nothing to find.
138
168
  */
@@ -212,15 +242,17 @@ export interface UnpluginStyleDictionaryOptions {
212
242
  */
213
243
  logLevel?: 'info' | 'silent' | 'verbose' | 'warn';
214
244
  /**
215
- * Called once a build has finished, with every file it declares and how long
216
- * it took in milliseconds.
245
+ * Called once a build has finished, with every file it declares that is on
246
+ * disk and how long it took in milliseconds.
217
247
  *
218
248
  * The paths are absolute and platform-native, sorted so two runs of the same
219
249
  * configuration hand back the same order. They are what the build declares
220
250
  * rather than what it wrote this time: a configuration skipped by `cache`
221
251
  * contributes its destinations too, because they are on disk and current,
222
252
  * and a post-processing step that ignored them would leave half the output
223
- * untouched on a rebuild that changed one file.
253
+ * untouched on a rebuild that changed one file. A declared file Style
254
+ * Dictionary declined to write — a filter matching no tokens — is left out,
255
+ * since there is nothing there to open.
224
256
  *
225
257
  * This is where formatting the generated files, type-checking them, or
226
258
  * telling something else they have landed belongs.
@@ -356,6 +388,11 @@ export interface UnpluginStyleDictionaryOptions {
356
388
  * else, name a file here relative to the root, and in `source` relative to
357
389
  * where the build runs.
358
390
  *
391
+ * A configuration module is imported again when a file named here changes,
392
+ * so a data file it reads when it loads is picked up. A module that a
393
+ * configuration or a token module itself imports is evaluated once per
394
+ * process, though: naming it here buys a rebuild, not a re-read.
395
+ *
359
396
  * What a change to a watched file then triggers is the host's to decide —
360
397
  * see the interface documentation above.
361
398
  */
@@ -1,13 +1,24 @@
1
- import { expandPatterns, sourcePatternsOf } from "./patterns.js";
1
+ import { expandPatterns, sourcePatternsOf, watchOptionPatterns } from "./patterns.js";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
+ import StyleDictionary from "style-dictionary";
5
+ import { createHash } from "node:crypto";
4
6
  //#region src/up-to-date.ts
5
- function configFingerprint(root, item) {
7
+ const RECORDS_VERSION = 3;
8
+ function configFingerprint(root, item, configObj) {
9
+ let serialised;
6
10
  try {
7
- return JSON.stringify([root, item.file ?? item.config], (_key, value) => typeof value === "function" ? `[fn]${String(value)}` : value);
11
+ serialised = JSON.stringify([
12
+ root,
13
+ item.file ?? null,
14
+ configObj,
15
+ namedHooksOf(configObj),
16
+ StyleDictionary.VERSION
17
+ ], (_key, value) => typeof value === "function" ? `[fn]${String(value)}` : value);
8
18
  } catch {
9
19
  return null;
10
20
  }
21
+ return createHash("sha256").update(serialised).digest("hex");
11
22
  }
12
23
  function declaredDestinations(configObj, only) {
13
24
  const destinations = [];
@@ -16,38 +27,150 @@ function declaredDestinations(configObj, only) {
16
27
  for (const [, platform] of selected) for (const file of platform.files ?? []) if (file.destination) destinations.push(writtenDestination(platform.buildPath, file.destination));
17
28
  return destinations;
18
29
  }
19
- async function isUpToDate({ compiledFingerprints, log, root, watch }, item, configObj, only) {
30
+ function isUpToDate({ destinationRecords, fingerprint, newestSource }, item, configObj, only) {
20
31
  if (Object.values(configObj.platforms ?? {}).some((platform) => (platform.actions?.length ?? 0) > 0)) return false;
21
32
  const destinations = declaredDestinations(configObj, only);
22
33
  if (destinations.length === 0) return false;
23
- const extraWatches = watch ? Array.isArray(watch) ? watch : [watch] : [];
24
- const sources = await expandPatterns([...sourcePatternsOf(configObj), ...extraWatches.map((pattern) => (path.isAbsolute(pattern) ? pattern : path.resolve(root, pattern)).replace(/\\/g, "/"))], log);
34
+ if (newestSource === null) return false;
35
+ for (const destination of destinations) {
36
+ const record = destinationRecords.get(destination);
37
+ const writtenByThis = fingerprint !== null && record?.fingerprint === fingerprint;
38
+ if (record !== void 0 && !writtenByThis) return false;
39
+ if (!item.file && (!writtenByThis || record.persisted === true)) return false;
40
+ if (writtenByThis && record.written === null) {
41
+ if (record.newestSource === null || newestSource > record.newestSource) return false;
42
+ continue;
43
+ }
44
+ const stats = statOrNull(destination);
45
+ if (!stats) return false;
46
+ if (writtenByThis && record.written !== stats.mtimeMs) return false;
47
+ const lastRead = writtenByThis ? record.newestSource : null;
48
+ if (lastRead === null ? stats.mtimeMs <= newestSource : newestSource > lastRead) return false;
49
+ }
50
+ return true;
51
+ }
52
+ async function newestSourceOf({ log, root, watch }, item, configObj) {
53
+ const read = sourcePatternsOf(configObj);
54
+ const extra = watchOptionPatterns(root, watch);
55
+ const sources = await expandPatterns({
56
+ globs: [...read.globs, ...extra.globs],
57
+ literals: [...read.literals, ...extra.literals]
58
+ }, log);
25
59
  if (item.file) sources.push(item.file.replace(/\\/g, "/"));
26
- if (sources.length === 0) return false;
60
+ if (sources.length === 0) return null;
27
61
  let newestSource = -Infinity;
28
62
  let sawFile = false;
29
63
  for (const source of sources) {
30
64
  const stats = statOrNull(source);
31
- if (!stats) return false;
65
+ if (!stats) return null;
32
66
  if (stats.isDirectory()) continue;
33
67
  sawFile = true;
34
68
  newestSource = Math.max(newestSource, stats.mtimeMs);
35
69
  }
36
- if (!sawFile) return false;
37
- let oldestDestination = Infinity;
38
- for (const destination of destinations) {
39
- const stats = statOrNull(destination);
40
- if (!stats) return false;
41
- oldestDestination = Math.min(oldestDestination, stats.mtimeMs);
70
+ return sawFile ? newestSource : null;
71
+ }
72
+ function readPersistedRecords(file) {
73
+ const records = /* @__PURE__ */ new Map();
74
+ let parsed;
75
+ try {
76
+ parsed = JSON.parse(fs.readFileSync(file, "utf8"));
77
+ } catch {
78
+ return records;
79
+ }
80
+ if (typeof parsed !== "object" || parsed === null || !("version" in parsed) || parsed.version !== RECORDS_VERSION || !("records" in parsed) || typeof parsed.records !== "object" || parsed.records === null) return records;
81
+ const stored = parsed.records;
82
+ for (const destination of Object.keys(stored)) {
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, {
85
+ fingerprint: value.fingerprint,
86
+ newestSource: value.newestSource,
87
+ persisted: true,
88
+ written: value.written
89
+ });
90
+ }
91
+ return records;
92
+ }
93
+ function recordOf(destination, fingerprint, newestSource, wrote) {
94
+ if (!wrote) return {
95
+ fingerprint,
96
+ newestSource,
97
+ written: null
98
+ };
99
+ const stats = statOrNull(destination);
100
+ return stats ? {
101
+ fingerprint,
102
+ newestSource,
103
+ written: stats.mtimeMs
104
+ } : null;
105
+ }
106
+ function recordsFileFor(root) {
107
+ let directory = root;
108
+ while (!fs.existsSync(path.join(directory, "package.json"))) {
109
+ const parent = path.dirname(directory);
110
+ if (parent === directory) {
111
+ directory = root;
112
+ break;
113
+ }
114
+ directory = parent;
115
+ }
116
+ return path.join(directory, "node_modules", ".cache", "unplugin-style-dictionary", "records");
117
+ }
118
+ function writePersistedRecords(file, recorded) {
119
+ const merged = readPersistedRecords(file);
120
+ for (const [destination, record] of recorded) merged.set(destination, record);
121
+ const records = {};
122
+ for (const [destination, { fingerprint, newestSource, written }] of merged) if (written === null ? statOrNull(path.dirname(destination)) : statOrNull(destination)) records[destination] = {
123
+ fingerprint,
124
+ newestSource,
125
+ written
126
+ };
127
+ const temporary = `${file}.${String(process.pid)}.tmp`;
128
+ try {
129
+ fs.mkdirSync(path.dirname(file), { recursive: true });
130
+ fs.writeFileSync(temporary, JSON.stringify({
131
+ records,
132
+ version: RECORDS_VERSION
133
+ }));
134
+ fs.renameSync(temporary, file);
135
+ } catch {
136
+ try {
137
+ fs.rmSync(temporary, { force: true });
138
+ } catch {}
42
139
  }
43
- if (oldestDestination <= newestSource) return false;
44
- if (item.file) return true;
45
- const fingerprint = configFingerprint(root, item);
46
- return fingerprint !== null && compiledFingerprints.has(fingerprint);
47
140
  }
48
141
  function writtenDestination(buildPath, destination) {
49
142
  return path.resolve(process.cwd(), buildPath ? path.join(buildPath, destination) : destination);
50
143
  }
144
+ function namedHooksOf(configObj) {
145
+ const own = configObj.hooks ?? {};
146
+ const registry = StyleDictionary.hooks;
147
+ const named = {};
148
+ const add = (kind, name) => {
149
+ if (typeof name !== "string") return;
150
+ const hook = own[kind]?.[name] ?? registry[kind][name];
151
+ named[kind] ??= {};
152
+ named[kind][name] = hook ?? null;
153
+ };
154
+ for (const name of configObj.parsers ?? []) add("parsers", name);
155
+ for (const name of configObj.preprocessors ?? []) add("preprocessors", name);
156
+ for (const platform of Object.values(configObj.platforms ?? {})) {
157
+ for (const name of platform.preprocessors ?? []) add("preprocessors", name);
158
+ for (const transform of platform.transforms ?? []) add("transforms", transform);
159
+ const group = platform.transformGroup;
160
+ if (group !== void 0) {
161
+ add("transformGroups", group);
162
+ const members = own.transformGroups?.[group] ?? registry.transformGroups[group];
163
+ if (Array.isArray(members)) for (const name of members) add("transforms", name);
164
+ }
165
+ add("fileHeaders", platform.options?.fileHeader);
166
+ for (const file of platform.files ?? []) {
167
+ add("formats", file.format);
168
+ add("filters", file.filter);
169
+ add("fileHeaders", file.options?.fileHeader);
170
+ }
171
+ }
172
+ return named;
173
+ }
51
174
  function statOrNull(file) {
52
175
  try {
53
176
  return fs.statSync(file);
@@ -56,6 +179,6 @@ function statOrNull(file) {
56
179
  }
57
180
  }
58
181
  //#endregion
59
- export { configFingerprint, declaredDestinations, isUpToDate, writtenDestination };
182
+ export { configFingerprint, declaredDestinations, isUpToDate, newestSourceOf, readPersistedRecords, recordOf, recordsFileFor, writePersistedRecords, writtenDestination };
60
183
 
61
184
  //# sourceMappingURL=up-to-date.js.map
@@ -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 fingerprints of what this process\n// has compiled, which are process-wide and live in `index.ts` beside\n// `compilesInFlight`. What a plugin instance owns is handed in on each call.\n\nimport type { Config } from 'style-dictionary'\n\nimport fs from 'node:fs'\nimport path from 'node:path'\n\nimport type { Log, ResolvedConfig } from './config.js'\nimport type { UnpluginStyleDictionaryOptions } from './types.js'\n\nimport { expandPatterns, sourcePatternsOf } from './patterns.js'\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.\nexport function configFingerprint(\n root: string,\n item: ResolvedConfig,\n): null | string {\n try {\n return JSON.stringify(\n [root, item.file ?? item.config],\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\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 the same\n// files — the ones Style Dictionary actually wrote.\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 every file a configuration declares is already newer than every\n// file it reads, so its compile can be skipped.\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 async function isUpToDate(\n {\n compiledFingerprints,\n log,\n root,\n watch,\n }: {\n compiledFingerprints: ReadonlySet<string>\n log: Log\n root: string\n watch: UnpluginStyleDictionaryOptions['watch']\n },\n item: ResolvedConfig,\n configObj: Config,\n only?: string[],\n): Promise<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 // `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 extraWatches = watch ? (Array.isArray(watch) ? watch : [watch]) : []\n\n const sources = await expandPatterns(\n [\n ...sourcePatternsOf(configObj),\n ...extraWatches.map((pattern) =>\n (path.isAbsolute(pattern)\n ? pattern\n : path.resolve(root, pattern)\n ).replace(/\\\\/g, '/'),\n ),\n ],\n log,\n )\n if (item.file) sources.push(item.file.replace(/\\\\/g, '/'))\n\n if (sources.length === 0) return false\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 false\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 if (!sawFile) return false\n\n let oldestDestination = Infinity\n for (const destination of destinations) {\n const stats = statOrNull(destination)\n if (!stats) return false\n oldestDestination = Math.min(oldestDestination, stats.mtimeMs)\n }\n\n if (oldestDestination <= newestSource) return false\n\n // A configuration given as a path has its own file among the sources\n // above, so an edit to it has already been accounted for and the skip\n // holds across processes.\n if (item.file) return true\n\n // One given as an object or a function has not. Only this process knows\n // what it looked like when those destinations were written, so the skip\n // holds only against a fingerprint recorded here.\n const fingerprint = configFingerprint(root, item)\n\n return fingerprint !== null && compiledFingerprints.has(fingerprint)\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// `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":";;;;AAqBA,SAAgB,kBACd,MACA,MACe;CACf,IAAI;EACF,OAAO,KAAK,UACV,CAAC,MAAM,KAAK,QAAQ,KAAK,MAAM,IAC9B,MAAM,UACL,OAAO,UAAU,aAAa,OAAO,OAAO,KAAK,MAAM,KAC3D;CACF,QAAQ;EAGN,OAAO;CACT;AACF;AAgBA,SAAgB,qBACd,WACA,MACU;CACV,MAAM,eAAyB,CAAC;CAEhC,MAAM,UAAU,OAAO,QAAQ,UAAU,aAAa,CAAC,CAAC;CACxD,MAAM,WAAW,OACb,QAAQ,QAAQ,CAAC,UAAU,KAAK,SAAS,IAAI,CAAC,IAC9C;CAEJ,KAAK,MAAM,GAAG,aAAa,UACzB,KAAK,MAAM,QAAQ,SAAS,SAAS,CAAC,GACpC,IAAI,KAAK,aACP,aAAa,KACX,mBAAmB,SAAS,WAAW,KAAK,WAAW,CACzD;CAKN,OAAO;AACT;AASA,eAAsB,WACpB,EACE,sBACA,KACA,MACA,SAOF,MACA,WACA,MACkB;CAMlB,IAHmB,OAAO,OAAO,UAAU,aAAa,CAAC,CAAC,CAAC,CAAC,MACzD,cAAc,SAAS,SAAS,UAAU,KAAK,CAErC,GAAG,OAAO;CAEvB,MAAM,eAAe,qBAAqB,WAAW,IAAI;CACzD,IAAI,aAAa,WAAW,GAAG,OAAO;CAMtC,MAAM,eAAe,QAAS,MAAM,QAAQ,KAAK,IAAI,QAAQ,CAAC,KAAK,IAAK,CAAC;CAEzE,MAAM,UAAU,MAAM,eACpB,CACE,GAAG,iBAAiB,SAAS,GAC7B,GAAG,aAAa,KAAK,aAClB,KAAK,WAAW,OAAO,IACpB,UACA,KAAK,QAAQ,MAAM,OAAO,EAAA,CAC5B,QAAQ,OAAO,GAAG,CACtB,CACF,GACA,GACF;CACA,IAAI,KAAK,MAAM,QAAQ,KAAK,KAAK,KAAK,QAAQ,OAAO,GAAG,CAAC;CAEzD,IAAI,QAAQ,WAAW,GAAG,OAAO;CAEjC,IAAI,eAAe;CACnB,IAAI,UAAU;CAEd,KAAK,MAAM,UAAU,SAAS;EAC5B,MAAM,QAAQ,WAAW,MAAM;EAC/B,IAAI,CAAC,OAAO,OAAO;EASnB,IAAI,MAAM,YAAY,GAAG;EAEzB,UAAU;EACV,eAAe,KAAK,IAAI,cAAc,MAAM,OAAO;CACrD;CAKA,IAAI,CAAC,SAAS,OAAO;CAErB,IAAI,oBAAoB;CACxB,KAAK,MAAM,eAAe,cAAc;EACtC,MAAM,QAAQ,WAAW,WAAW;EACpC,IAAI,CAAC,OAAO,OAAO;EACnB,oBAAoB,KAAK,IAAI,mBAAmB,MAAM,OAAO;CAC/D;CAEA,IAAI,qBAAqB,cAAc,OAAO;CAK9C,IAAI,KAAK,MAAM,OAAO;CAKtB,MAAM,cAAc,kBAAkB,MAAM,IAAI;CAEhD,OAAO,gBAAgB,QAAQ,qBAAqB,IAAI,WAAW;AACrE;AAgBA,SAAgB,mBACd,WACA,aACQ;CACR,OAAO,KAAK,QACV,QAAQ,IAAI,GACZ,YAAY,KAAK,KAAK,WAAW,WAAW,IAAI,WAClD;AACF;AAIA,SAAS,WAAW,MAA+B;CACjD,IAAI;EACF,OAAO,GAAG,SAAS,IAAI;CACzB,QAAQ;EACN,OAAO;CACT;AACF"}
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,16 +1,24 @@
1
+ import path from "node:path";
1
2
  import picomatch from "picomatch";
2
3
  //#region src/watch-filter.ts
3
- function isWatchedSource(file, patterns, generatedDestinations) {
4
- return !generatedDestinations.has(file.replace(/\\/g, "/")) && matchesWatchedFile(file, patterns);
4
+ function isWatchedSource(file, { globs, literals }, generatedDestinations) {
5
+ const normalizedFile = file.replace(/\\/g, "/");
6
+ return !generatedDestinations.has(normalizedFile) && (literals.includes(normalizedFile) || matchesWatchedFile(normalizedFile, globs));
5
7
  }
6
8
  function matchesWatchedFile(file, patterns) {
7
9
  const normalizedFile = file.replace(/\\/g, "/");
8
- return patterns.some((pattern) => {
9
- const normalizedPattern = pattern.replace(/\\/g, "/");
10
- return normalizedPattern === normalizedFile || picomatch.isMatch(normalizedFile, normalizedPattern);
11
- });
10
+ return patterns.some((pattern) => pattern === normalizedFile || picomatch.isMatch(normalizedFile, pattern));
11
+ }
12
+ function registeredSpellingOf(file, linkedPaths) {
13
+ if (linkedPaths.size === 0) return file;
14
+ const normalizedFile = file.replace(/\\/g, "/");
15
+ for (let real = normalizedFile;; real = path.posix.dirname(real)) {
16
+ const registered = linkedPaths.get(real);
17
+ if (registered !== void 0) return registered + normalizedFile.slice(real.length);
18
+ if (path.posix.dirname(real) === real) return file;
19
+ }
12
20
  }
13
21
  //#endregion
14
- export { isWatchedSource, matchesWatchedFile };
22
+ export { isWatchedSource, matchesWatchedFile, registeredSpellingOf };
15
23
 
16
24
  //# sourceMappingURL=watch-filter.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"watch-filter.js","names":[],"sources":["../src/watch-filter.ts"],"sourcesContent":["import picomatch from 'picomatch'\n\n// Whether a changed file is a token or config source rather than something\n// this plugin just wrote. Both watch entry points ask through here, so\n// neither can react to its own output.\n//\n// `generatedDestinations` is the plugin instance's own record of what it\n// wrote, so it is handed in rather than held: one module serves every\n// instance in the process, and each one's output is its own.\nexport function isWatchedSource(\n file: string,\n patterns: string[],\n generatedDestinations: ReadonlySet<string>,\n): boolean {\n return (\n !generatedDestinations.has(file.replace(/\\\\/g, '/')) &&\n matchesWatchedFile(file, patterns)\n )\n}\n\n// Whether `file` matches one of the resolved config/token watch patterns.\n// Shared by the Vite-specific `configureServer` watcher and the universal\n// `watchChange` hook — both need it, and both must skip files that don't\n// match: without this filter, `watchChange` reacts to *any* changed\n// module-graph file, including this plugin's own generated output (since\n// consuming code imports it). Every regenerate is itself a \"change\", which\n// without filtering re-triggers a rebuild forever.\n//\n// Matching a pattern is only half of that, and this function is only the half\n// it can answer. A `buildPath` inside a `source` directory is a supported\n// layout, and under any correct matcher its output matches the very glob that\n// produced it — so the caller also subtracts what the last build wrote. See\n// `isWatchedSource` above, and `generatedDestinations` in the factory.\n//\n// The patterns are Style Dictionary's own `source` and `include` globs, so the\n// filter has to admit exactly what the build reads — which is why the matching\n// is a real globber's rather than hand-rolled. The version this replaces was\n// wrong in both directions at once: it stripped `/**` out of a pattern and\n// prefix-matched the remainder, so `tokens/**/*.json` matched nothing sitting\n// directly in `tokens/` and `tokens/**` matched a `tokens-backup/` sibling,\n// while its regex branch mapped every `*` to `.*` — crossing `/` — and tested\n// it unanchored, so generated output under a watched directory matched its own\n// source glob and rebuilt forever.\n//\n// picomatch rather than `path.matchesGlob`, which would need no dependency at\n// all: that function is documented experimental, and on Node 20 — the floor\n// `engines` declares — it prints `ExperimentalWarning: glob is an experimental\n// feature and might change at any time` into the consumer's build output. The\n// dependency is free in practice, since `unplugin` depends on the same\n// picomatch and is already installed wherever this plugin is. Its `dot: false`\n// default is deliberate: it is what glob, and so Style Dictionary, reads\n// sources with, so a dotfile is invisible to the filter and to the build alike.\nexport function matchesWatchedFile(file: string, patterns: string[]): boolean {\n const normalizedFile = file.replace(/\\\\/g, '/')\n\n return patterns.some((pattern) => {\n const normalizedPattern = pattern.replace(/\\\\/g, '/')\n\n // A config file reaches this function as its own literal path, which is\n // both the common case and the one shape that is not a glob at all.\n return (\n normalizedPattern === normalizedFile ||\n picomatch.isMatch(normalizedFile, normalizedPattern)\n )\n })\n}\n"],"mappings":";;AASA,SAAgB,gBACd,MACA,UACA,uBACS;CACT,OACE,CAAC,sBAAsB,IAAI,KAAK,QAAQ,OAAO,GAAG,CAAC,KACnD,mBAAmB,MAAM,QAAQ;AAErC;AAkCA,SAAgB,mBAAmB,MAAc,UAA6B;CAC5E,MAAM,iBAAiB,KAAK,QAAQ,OAAO,GAAG;CAE9C,OAAO,SAAS,MAAM,YAAY;EAChC,MAAM,oBAAoB,QAAQ,QAAQ,OAAO,GAAG;EAIpD,OACE,sBAAsB,kBACtB,UAAU,QAAQ,gBAAgB,iBAAiB;CAEvD,CAAC;AACH"}
1
+ {"version":3,"file":"watch-filter.js","names":[],"sources":["../src/watch-filter.ts"],"sourcesContent":["import path from 'node:path'\nimport picomatch from 'picomatch'\n\nimport type { WatchPatterns } from './patterns.js'\n\n// Whether a changed file is a token or config source rather than something\n// this plugin just wrote. Both watch entry points ask through here, so\n// neither can react to its own output.\n//\n// `generatedDestinations` is the plugin instance's own record of what it\n// wrote, so it is handed in rather than held: one module serves every\n// instance in the process, and each one's output is its own.\n//\n// A literal is a path, so it is compared as one. A config file reaches here\n// that way, and so does a token file named without a glob, and neither may be\n// read as a pattern: the project's own directory can hold characters a glob\n// treats as syntax.\nexport function isWatchedSource(\n file: string,\n { globs, literals }: WatchPatterns,\n generatedDestinations: ReadonlySet<string>,\n): boolean {\n const normalizedFile = file.replace(/\\\\/g, '/')\n\n return (\n !generatedDestinations.has(normalizedFile) &&\n (literals.includes(normalizedFile) ||\n matchesWatchedFile(normalizedFile, globs))\n )\n}\n\n// Whether `file` matches one of the resolved config/token watch patterns.\n// Shared by the Vite-specific `configureServer` watcher and the universal\n// `watchChange` hook — both need it, and both must skip files that don't\n// match: without this filter, `watchChange` reacts to *any* changed\n// module-graph file, including this plugin's own generated output (since\n// consuming code imports it). Every regenerate is itself a \"change\", which\n// without filtering re-triggers a rebuild forever.\n//\n// Matching a pattern is only half of that, and this function is only the half\n// it can answer. A `buildPath` inside a `source` directory is a supported\n// layout, and under any correct matcher its output matches the very glob that\n// produced it — so the caller also subtracts what the last build wrote. See\n// `isWatchedSource` above, and `generatedDestinations` in the factory.\n//\n// The patterns are Style Dictionary's own `source` and `include` globs, so the\n// filter has to admit exactly what the build reads — which is why the matching\n// is a real globber's rather than hand-rolled. The version this replaces was\n// wrong in both directions at once: it stripped `/**` out of a pattern and\n// prefix-matched the remainder, so `tokens/**/*.json` matched nothing sitting\n// directly in `tokens/` and `tokens/**` matched a `tokens-backup/` sibling,\n// while its regex branch mapped every `*` to `.*` — crossing `/` — and tested\n// it unanchored, so generated output under a watched directory matched its own\n// source glob and rebuilt forever.\n//\n// picomatch rather than `path.matchesGlob`, which would need no dependency at\n// all: that function is documented experimental, and on Node 20 — the floor\n// `engines` declares — it prints `ExperimentalWarning: glob is an experimental\n// feature and might change at any time` into the consumer's build output. The\n// dependency is free in practice, since `unplugin` depends on the same\n// picomatch and is already installed wherever this plugin is. Its `dot: false`\n// default is deliberate: it is what glob, and so Style Dictionary, reads\n// sources with, so a dotfile is invisible to the filter and to the build alike.\n//\n// **The patterns are used exactly as given.** A backslash in one is an escape:\n// the part of a pattern the plugin prefixed is escaped, and every producer\n// already writes separators as `/`. Rewriting backslashes to slashes here\n// turned each escape into a separator, so a project in `Dropbox (Personal)`\n// matched none of its own token files.\nexport function matchesWatchedFile(file: string, patterns: string[]): boolean {\n const normalizedFile = file.replace(/\\\\/g, '/')\n\n return patterns.some(\n (pattern) =>\n // An absolute path a consumer wrote with a glob character in it is filed\n // as a glob, and still names itself.\n pattern === normalizedFile || picomatch.isMatch(normalizedFile, pattern),\n )\n}\n\n// The spelling the watch patterns use for `file`, which a host watching a\n// registered realpath reports by where a link leads rather than by the link.\n// `linkedPaths` maps each registered path that runs through a link from its\n// realpath back to the spelling it was registered under, and is the plugin\n// instance's, so it is handed in rather than held.\n//\n// The lookup walks up from `file` rather than asking about it alone, because\n// a pattern's static parent directory is registered so that a token file\n// created in it later is noticed — and that file arrives spelled under the\n// directory's realpath, which only the directory is a key for.\nexport function registeredSpellingOf(\n file: string,\n linkedPaths: ReadonlyMap<string, string>,\n): string {\n if (linkedPaths.size === 0) return file\n\n const normalizedFile = file.replace(/\\\\/g, '/')\n\n for (let real = normalizedFile; ; real = path.posix.dirname(real)) {\n const registered = linkedPaths.get(real)\n if (registered !== undefined) {\n return registered + normalizedFile.slice(real.length)\n }\n\n if (path.posix.dirname(real) === real) return file\n }\n}\n"],"mappings":";;;AAiBA,SAAgB,gBACd,MACA,EAAE,OAAO,YACT,uBACS;CACT,MAAM,iBAAiB,KAAK,QAAQ,OAAO,GAAG;CAE9C,OACE,CAAC,sBAAsB,IAAI,cAAc,MACxC,SAAS,SAAS,cAAc,KAC/B,mBAAmB,gBAAgB,KAAK;AAE9C;AAwCA,SAAgB,mBAAmB,MAAc,UAA6B;CAC5E,MAAM,iBAAiB,KAAK,QAAQ,OAAO,GAAG;CAE9C,OAAO,SAAS,MACb,YAGC,YAAY,kBAAkB,UAAU,QAAQ,gBAAgB,OAAO,CAC3E;AACF;AAYA,SAAgB,qBACd,MACA,aACQ;CACR,IAAI,YAAY,SAAS,GAAG,OAAO;CAEnC,MAAM,iBAAiB,KAAK,QAAQ,OAAO,GAAG;CAE9C,KAAK,IAAI,OAAO,iBAAkB,OAAO,KAAK,MAAM,QAAQ,IAAI,GAAG;EACjE,MAAM,aAAa,YAAY,IAAI,IAAI;EACvC,IAAI,eAAe,KAAA,GACjB,OAAO,aAAa,eAAe,MAAM,KAAK,MAAM;EAGtD,IAAI,KAAK,MAAM,QAAQ,IAAI,MAAM,MAAM,OAAO;CAChD;AACF"}