tokvista 1.10.0 → 1.11.0

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.
@@ -3,7 +3,7 @@
3
3
  // src/bin/tokvista.ts
4
4
  import { spawn } from "child_process";
5
5
  import { existsSync, readdirSync } from "fs";
6
- import { readFile, writeFile } from "fs/promises";
6
+ import { readFile as readFile2, writeFile } from "fs/promises";
7
7
  import { createServer } from "http";
8
8
  import path from "path";
9
9
  import { createInterface } from "readline";
@@ -1088,6 +1088,134 @@ function convertTokenFormat(tokens, targetFormat) {
1088
1088
  }
1089
1089
  }
1090
1090
 
1091
+ // src/bin/scanner.ts
1092
+ import { readdir, readFile } from "fs/promises";
1093
+ import { join, extname } from "path";
1094
+ function isRecord7(value) {
1095
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1096
+ }
1097
+ function isTokenLike5(obj) {
1098
+ return isRecord7(obj) && "value" in obj;
1099
+ }
1100
+ function extractTokenNames(tokens) {
1101
+ const tokenMap = /* @__PURE__ */ new Map();
1102
+ function walk(node, path2 = []) {
1103
+ if (!isRecord7(node)) return;
1104
+ if (path2.length === 0 && Object.keys(node).some((k) => k.includes("/"))) {
1105
+ Object.values(node).forEach((val) => walk(val, []));
1106
+ return;
1107
+ }
1108
+ if (isTokenLike5(node)) {
1109
+ const tokenPath = path2.join(".");
1110
+ const cssVar = `--${path2.join("-")}`;
1111
+ tokenMap.set(cssVar, tokenPath);
1112
+ return;
1113
+ }
1114
+ Object.entries(node).forEach(([key, val]) => {
1115
+ walk(val, [...path2, key]);
1116
+ });
1117
+ }
1118
+ walk(tokens);
1119
+ return tokenMap;
1120
+ }
1121
+ async function scanFile(filePath, tokenVars) {
1122
+ const content = await readFile(filePath, "utf8");
1123
+ const lines = content.split("\n");
1124
+ const usedVars = /* @__PURE__ */ new Set();
1125
+ const hardcodedColors = [];
1126
+ const hardcodedSpacing = [];
1127
+ const cssVarPattern = /var\((--[\w-]+)\)/g;
1128
+ const hexColorPattern = /#[0-9A-Fa-f]{3,8}\b/g;
1129
+ const rgbPattern = /rgba?\([^)]+\)/g;
1130
+ const spacingPattern = /\b(\d+(?:\.\d+)?(?:px|rem|em))\b/g;
1131
+ lines.forEach((line, index) => {
1132
+ let match;
1133
+ while ((match = cssVarPattern.exec(line)) !== null) {
1134
+ const varName = match[1];
1135
+ if (tokenVars.has(varName)) {
1136
+ usedVars.add(varName);
1137
+ }
1138
+ }
1139
+ while ((match = hexColorPattern.exec(line)) !== null) {
1140
+ const color = match[0];
1141
+ if (!line.includes("0x") && !line.includes("\\u")) {
1142
+ hardcodedColors.push({ line: index + 1, value: color });
1143
+ }
1144
+ }
1145
+ while ((match = rgbPattern.exec(line)) !== null) {
1146
+ hardcodedColors.push({ line: index + 1, value: match[0] });
1147
+ }
1148
+ if (line.includes("padding") || line.includes("margin") || line.includes("gap") || line.includes("width") || line.includes("height")) {
1149
+ while ((match = spacingPattern.exec(line)) !== null) {
1150
+ hardcodedSpacing.push({ line: index + 1, value: match[1] });
1151
+ }
1152
+ }
1153
+ });
1154
+ return { usedVars, hardcodedColors, hardcodedSpacing };
1155
+ }
1156
+ async function scanDirectory(dir, tokenVars, extensions) {
1157
+ const usedVars = /* @__PURE__ */ new Set();
1158
+ const hardcodedColors = [];
1159
+ const hardcodedSpacing = [];
1160
+ let filesScanned = 0;
1161
+ async function scan(currentDir) {
1162
+ try {
1163
+ const entries = await readdir(currentDir, { withFileTypes: true });
1164
+ for (const entry of entries) {
1165
+ const fullPath = join(currentDir, entry.name);
1166
+ if (entry.isDirectory()) {
1167
+ if (["node_modules", ".git", "dist", "build", ".next", "coverage"].includes(entry.name)) {
1168
+ continue;
1169
+ }
1170
+ await scan(fullPath);
1171
+ } else if (entry.isFile()) {
1172
+ const ext = extname(entry.name);
1173
+ if (extensions.has(ext)) {
1174
+ const result = await scanFile(fullPath, tokenVars);
1175
+ filesScanned++;
1176
+ result.usedVars.forEach((v) => usedVars.add(v));
1177
+ result.hardcodedColors.forEach((h) => hardcodedColors.push({ file: fullPath, ...h }));
1178
+ result.hardcodedSpacing.forEach((h) => hardcodedSpacing.push({ file: fullPath, ...h }));
1179
+ }
1180
+ }
1181
+ }
1182
+ } catch (error) {
1183
+ }
1184
+ }
1185
+ await scan(dir);
1186
+ return { usedVars, hardcodedColors, hardcodedSpacing, filesScanned };
1187
+ }
1188
+ async function scanTokenUsage(tokensPath, scanDir, tokens) {
1189
+ const detection = detectTokenFormat(tokens);
1190
+ let normalizedTokens = tokens;
1191
+ if (detection.format !== "token-studio" && detection.format !== "unknown") {
1192
+ normalizedTokens = normalizeTokenFormat(tokens, detection.format);
1193
+ }
1194
+ const tokenMap = extractTokenNames(normalizedTokens);
1195
+ const tokenVars = new Set(tokenMap.keys());
1196
+ const extensions = /* @__PURE__ */ new Set([".css", ".scss", ".sass", ".less", ".tsx", ".jsx", ".ts", ".js", ".vue", ".svelte"]);
1197
+ const scanResult = await scanDirectory(scanDir, tokenVars, extensions);
1198
+ const usedTokens = [];
1199
+ const unusedTokens = [];
1200
+ tokenMap.forEach((tokenPath, cssVar) => {
1201
+ if (scanResult.usedVars.has(cssVar)) {
1202
+ usedTokens.push(tokenPath);
1203
+ } else {
1204
+ unusedTokens.push(tokenPath);
1205
+ }
1206
+ });
1207
+ return {
1208
+ totalTokens: tokenMap.size,
1209
+ usedTokens,
1210
+ unusedTokens,
1211
+ hardcodedColors: scanResult.hardcodedColors.slice(0, 50),
1212
+ // Limit to 50
1213
+ hardcodedSpacing: scanResult.hardcodedSpacing.slice(0, 50),
1214
+ // Limit to 50
1215
+ filesScanned: scanResult.filesScanned
1216
+ };
1217
+ }
1218
+
1091
1219
  // src/bin/watcher.ts
1092
1220
  import { watch } from "fs";
1093
1221
  function watchFile(filePath, onChange) {
@@ -1123,6 +1251,7 @@ Usage:
1123
1251
  tokvista diff <old.json> <new.json>
1124
1252
  tokvista convert <tokens.json> --to <w3c|style-dictionary|supernova> [--output <file>]
1125
1253
  tokvista build <tokens.json> --output-dir <dir> [--skip-validation]
1254
+ tokvista scan <directory> [--tokens tokens.json]
1126
1255
 
1127
1256
  Arguments:
1128
1257
  tokens.json Path to your tokens file (overrides config.tokens)
@@ -1261,6 +1390,9 @@ function parseArgs(args) {
1261
1390
  if (args[0] === "build") {
1262
1391
  return parseBuildArgs(args.slice(1));
1263
1392
  }
1393
+ if (args[0] === "scan") {
1394
+ return parseScanArgs(args.slice(1));
1395
+ }
1264
1396
  return parseServeArgs(args);
1265
1397
  }
1266
1398
  function parseValidateArgs(args) {
@@ -1394,6 +1526,37 @@ function parseBuildArgs(args) {
1394
1526
  if (!outputDir) throw new Error("--output-dir is required");
1395
1527
  return { command: "build", tokenFileArg, outputDir, skipValidation };
1396
1528
  }
1529
+ function parseScanArgs(args) {
1530
+ let scanDir;
1531
+ let tokenFileArg;
1532
+ for (let index = 0; index < args.length; index += 1) {
1533
+ const arg = args[index];
1534
+ if (arg === "-h" || arg === "--help") {
1535
+ printHelp();
1536
+ process.exit(0);
1537
+ }
1538
+ if (arg === "--tokens") {
1539
+ const next = args[index + 1];
1540
+ if (!next) throw new Error("Missing value for --tokens");
1541
+ tokenFileArg = next;
1542
+ index += 1;
1543
+ continue;
1544
+ }
1545
+ if (arg.startsWith("--tokens=")) {
1546
+ tokenFileArg = arg.slice("--tokens=".length);
1547
+ continue;
1548
+ }
1549
+ if (arg.startsWith("-")) {
1550
+ throw new Error(`Unknown option: ${arg}`);
1551
+ }
1552
+ if (scanDir) {
1553
+ throw new Error(`Only one directory is supported. Unexpected value: "${arg}"`);
1554
+ }
1555
+ scanDir = arg;
1556
+ }
1557
+ if (!scanDir) throw new Error("Directory to scan is required");
1558
+ return { command: "scan", scanDir, tokenFileArg };
1559
+ }
1397
1560
  function parseExportArgs(args) {
1398
1561
  let tokenFileArg;
1399
1562
  let format;
@@ -1460,7 +1623,7 @@ async function resolveDefaultInitTitle(cwd) {
1460
1623
  return "My Design System";
1461
1624
  }
1462
1625
  try {
1463
- const raw = await readFile(packageJsonPath, "utf8");
1626
+ const raw = await readFile2(packageJsonPath, "utf8");
1464
1627
  const parsed = JSON.parse(raw);
1465
1628
  if (typeof parsed.name === "string") {
1466
1629
  return formatTitleFromPackageName(parsed.name);
@@ -1700,7 +1863,7 @@ async function resolveLogoForRuntime(logoPathValue, configDir, cwd) {
1700
1863
  if (!existsSync(resolvedLogoPath)) {
1701
1864
  throw new Error(`Logo file not found: ${resolvedLogoPath}`);
1702
1865
  }
1703
- const content = await readFile(resolvedLogoPath);
1866
+ const content = await readFile2(resolvedLogoPath);
1704
1867
  const mimeType = toDataUrlMimeType(resolvedLogoPath);
1705
1868
  return `data:${mimeType};base64,${content.toString("base64")}`;
1706
1869
  }
@@ -1788,7 +1951,7 @@ async function loadConfigFromFile(configPath) {
1788
1951
  const sourceLabel = path.basename(configPath);
1789
1952
  const extension = path.extname(configPath).toLowerCase();
1790
1953
  if (extension === ".json") {
1791
- const raw = await readFile(configPath, "utf8");
1954
+ const raw = await readFile2(configPath, "utf8");
1792
1955
  try {
1793
1956
  return normalizeConfigObject(JSON.parse(raw), sourceLabel);
1794
1957
  } catch (error) {
@@ -1796,7 +1959,7 @@ async function loadConfigFromFile(configPath) {
1796
1959
  }
1797
1960
  }
1798
1961
  if (extension === ".ts") {
1799
- const raw = await readFile(configPath, "utf8");
1962
+ const raw = await readFile2(configPath, "utf8");
1800
1963
  const parsed = parseTsConfigSource(raw, sourceLabel);
1801
1964
  return normalizeConfigObject(parsed, sourceLabel);
1802
1965
  }
@@ -1937,7 +2100,7 @@ function openBrowser(url) {
1937
2100
  });
1938
2101
  }
1939
2102
  async function readTokens(tokenPath) {
1940
- const raw = await readFile(tokenPath, "utf8");
2103
+ const raw = await readFile2(tokenPath, "utf8");
1941
2104
  try {
1942
2105
  const parsed = JSON.parse(raw);
1943
2106
  if (!parsed || typeof parsed !== "object") {
@@ -1958,8 +2121,8 @@ async function runServeCommand(cwd, options) {
1958
2121
  }
1959
2122
  const runtimeConfig = await buildRuntimeConfig(config, configPath, cwd);
1960
2123
  const [css, appBundle] = await Promise.all([
1961
- readFile(resolveDistAsset("styles.css"), "utf8"),
1962
- readFile(resolveDistAsset("cli/browser.js"), "utf8")
2124
+ readFile2(resolveDistAsset("styles.css"), "utf8"),
2125
+ readFile2(resolveDistAsset("cli/browser.js"), "utf8")
1963
2126
  ]);
1964
2127
  let cachedTokens = await readTokens(resolvedTokenPath);
1965
2128
  const getHtml = () => buildHtml(cachedTokens, runtimeConfig, css, appBundle, options.watch);
@@ -2209,6 +2372,63 @@ async function runBuildCommand(cwd, options) {
2209
2372
  \u2705 Build complete: ${outputDir}
2210
2373
  `);
2211
2374
  }
2375
+ async function runScanCommand(cwd, options) {
2376
+ const scanDir = path.resolve(cwd, options.scanDir);
2377
+ const tokenPath = options.tokenFileArg ? path.resolve(cwd, options.tokenFileArg) : path.resolve(cwd, "tokens.json");
2378
+ if (!existsSync(scanDir)) {
2379
+ throw new Error(`Directory not found: ${scanDir}`);
2380
+ }
2381
+ if (!existsSync(tokenPath)) {
2382
+ throw new Error(`Token file not found: ${tokenPath}`);
2383
+ }
2384
+ const tokens = await readTokens(tokenPath);
2385
+ console.log(`
2386
+ Scanning ${scanDir} for token usage...
2387
+ `);
2388
+ const result = await scanTokenUsage(tokenPath, scanDir, tokens);
2389
+ const usagePercent = (result.usedTokens.length / result.totalTokens * 100).toFixed(1);
2390
+ console.log(`\u{1F4CA} Token Usage Report
2391
+ `);
2392
+ console.log(`Files scanned: ${result.filesScanned}`);
2393
+ console.log(`Total tokens: ${result.totalTokens}`);
2394
+ console.log(`Used tokens: ${result.usedTokens.length} (${usagePercent}%)`);
2395
+ console.log(`Unused tokens: ${result.unusedTokens.length}
2396
+ `);
2397
+ if (result.unusedTokens.length > 0) {
2398
+ console.log(`\u26A0\uFE0F Unused Tokens (safe to remove):`);
2399
+ result.unusedTokens.slice(0, 20).forEach((token) => {
2400
+ console.log(` - ${token}`);
2401
+ });
2402
+ if (result.unusedTokens.length > 20) {
2403
+ console.log(` ... and ${result.unusedTokens.length - 20} more`);
2404
+ }
2405
+ console.log("");
2406
+ }
2407
+ if (result.hardcodedColors.length > 0) {
2408
+ console.log(`\u{1F3A8} Hardcoded Colors (should use tokens):`);
2409
+ result.hardcodedColors.slice(0, 10).forEach(({ file, line, value }) => {
2410
+ const relPath = path.relative(cwd, file);
2411
+ console.log(` ${relPath}:${line} - ${value}`);
2412
+ });
2413
+ if (result.hardcodedColors.length > 10) {
2414
+ console.log(` ... and ${result.hardcodedColors.length - 10} more`);
2415
+ }
2416
+ console.log("");
2417
+ }
2418
+ if (result.hardcodedSpacing.length > 0) {
2419
+ console.log(`\u{1F4CF} Hardcoded Spacing (should use tokens):`);
2420
+ result.hardcodedSpacing.slice(0, 10).forEach(({ file, line, value }) => {
2421
+ const relPath = path.relative(cwd, file);
2422
+ console.log(` ${relPath}:${line} - ${value}`);
2423
+ });
2424
+ if (result.hardcodedSpacing.length > 10) {
2425
+ console.log(` ... and ${result.hardcodedSpacing.length - 10} more`);
2426
+ }
2427
+ console.log("");
2428
+ }
2429
+ console.log(`\u2705 Scan complete
2430
+ `);
2431
+ }
2212
2432
  async function main() {
2213
2433
  try {
2214
2434
  const options = parseArgs(process.argv.slice(2));
@@ -2240,6 +2460,10 @@ async function main() {
2240
2460
  await runBuildCommand(cwd, options);
2241
2461
  return;
2242
2462
  }
2463
+ if (options.command === "scan") {
2464
+ await runScanCommand(cwd, options);
2465
+ return;
2466
+ }
2243
2467
  await runServeCommand(cwd, options);
2244
2468
  } catch (error) {
2245
2469
  console.error(error.message);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokvista",
3
- "version": "1.10.0",
3
+ "version": "1.11.0",
4
4
  "description": "Interactive visual documentation for design tokens.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",