emily-css 1.2.4 → 1.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,35 @@ All notable changes to `emily-css` are documented here.
4
4
 
5
5
  ---
6
6
 
7
+ ## v1.2.6 — May 2026
8
+
9
+ **Fix migration scanner false positives by ignoring prose tokens, JS property access, and dynamic placeholders while keeping valid utility classes and arbitrary value support.**
10
+
11
+ ### Fixed
12
+ - fix: tighten migration class filtering
13
+
14
+ ---
15
+ ## v1.2.5 — May 2026
16
+
17
+ **Node 18+ Compatibility and Docs/CLI Alignment**
18
+
19
+ ### Fixed
20
+ - fix(release): align Node support, CLI scripts, and README with current behavior
21
+
22
+ ---
23
+ ## v1.3.0 -- May 2026
24
+
25
+ ### Added
26
+ - **ARIA state variants**: Added `aria-checked:` variant prefix, generating CSS rules targeting `[aria-checked="true"]`. Completes the ARIA variant set alongside `aria-expanded`, `aria-selected`, `aria-current`, and `aria-disabled`.
27
+ - **Data-state variants**: Added `data-checked:`, `data-unchecked:`, `data-active:`, and `data-inactive:` variant prefixes targeting `[data-state="*"]` selectors. Used by Radix UI, Headless UI, and similar headless component libraries. Completes the data-state set alongside `data-open:` and `data-closed:`.
28
+ - **`emily-css info` command**: New CLI command. Shows a project overview without triggering a build -- version, framework detection, output paths, CSS file sizes, source globs, file count, colour/spacing/font summary.
29
+ - **Doctor contrast warnings**: `emily-css doctor` now checks configured brand colour shades against standard light and dark backgrounds using the WCAG relative luminance formula. Flags shade/background pairings below 4.5:1 contrast. Non-blocking (warning, not error).
30
+
31
+ ### Changed
32
+ - `BASE_VARIANTS` in `src/constants.js` updated to include the five new variant names so `doctor` and `manifest` recognise them.
33
+
34
+ ---
35
+
7
36
  ## v1.2.4 — May 2026
8
37
 
9
38
  **chore: release v1.2.4**
@@ -426,9 +455,4 @@ All notable changes to `emily-css` are documented here.
426
455
  - 11,844 utility classes generated from `emily.config.json`
427
456
  - OKLCH colour scale generation — one hex in, 10-shade scale out
428
457
  - Responsive variants (`sm:` `md:` `lg:` `xl:` `2xl:`)
429
- - State variants (`hover:` `focus-visible:` `active:` `disabled:` `dark:`)
430
- - Purge system — strips unused classes, ~97% file size reduction
431
- - Interactive setup wizard (`npx emily-css init`)
432
- - 72 tests, all passing
433
-
434
- ---
458
+ - State variants (`hover:` `focus-visible:` `active:` `disab
package/README.md CHANGED
@@ -12,7 +12,7 @@ emilyCSS lets you define design tokens in `emily.config.json` and generate stati
12
12
  - Framework-agnostic output (`dist/emily.css` and `dist/emily.min.css`)
13
13
  - Accessibility-focused utility coverage (focus rings, visually-hidden helpers, motion-aware variants)
14
14
  - Tooling support with manifest and IntelliSense JSON generation
15
- - CommonJS package with Node 22+ compatibility
15
+ - CommonJS package with Node 18+ compatibility
16
16
 
17
17
  ## Install and basic workflow
18
18
 
@@ -36,19 +36,36 @@ npx emily-css init
36
36
  npx emily-css build
37
37
  npx emily-css build --profile
38
38
  npx emily-css watch
39
+ npx emily-css info
39
40
  npx emily-css doctor
40
41
  npx emily-css migrate
41
42
  npx emily-css migrate --import-colours
43
+ npx emily-css manifest
42
44
  npx emily-css showcase
43
45
  npx emily-css help
44
46
  npx emily-css version
45
47
  ```
46
48
 
49
+ Equivalent npm scripts (when added by `emily-css init`):
50
+
51
+ ```bash
52
+ npm run emily:build
53
+ npm run emily:watch
54
+ npm run emily:doctor
55
+ npm run emily:migrate
56
+ npm run emily:info
57
+ npm run emily:manifest
58
+ npm run emily:showcase
59
+ npm run emily:version
60
+ npm run emily:help
61
+ ```
62
+
47
63
  ## Doctor and migrate
48
64
 
49
65
  - `doctor` checks for unknown EmilyCSS classes and variants.
50
66
  - `doctor` now also reports non-failing accessibility warnings (for example obvious focus-removal or same-token text/background patterns).
51
67
  - `migrate` is report-only and helps plan Tailwind-to-Emily migrations without modifying files.
68
+ - For best migrate accuracy, generate the full framework/manifest first (`emily-css build --keep-full` or enable `manifest: true`).
52
69
 
53
70
  ## Manifest and IntelliSense JSON
54
71
 
@@ -95,13 +112,9 @@ These files are intended for tooling, audits, and editor integrations. A VSCode
95
112
  ## Notes on compatibility
96
113
 
97
114
  - Package format: CommonJS
98
- - Runtime support: Node 22+
115
+ - Runtime support: Node 18+
99
116
  - ESM-only major upgrades are intentionally avoided where they would break compatibility
100
117
 
101
- ## Documentation stubs
102
-
103
- Starter docs are available in [`docs/`](./docs) for installation, configuration, variants, accessibility, doctor, migrate, manifest, and IntelliSense.
104
-
105
118
  ## License
106
119
 
107
120
  MIT
package/bin/emilyui.js CHANGED
@@ -13,6 +13,7 @@ const usageText = `
13
13
  emily-css build Generate production CSS to the configured output path
14
14
  --profile Print coarse build timing information
15
15
  emily-css watch Dev mode: rebuild on changes
16
+ emily-css info Show project config and CSS stats
16
17
  emily-css doctor Scan project files for unknown EmilyCSS classes
17
18
  emily-css migrate Generate a Tailwind-to-EmilyCSS migration report
18
19
  --import-colours Detect Tailwind colour palettes and suggest importedPalettes config
@@ -35,6 +36,9 @@ if (command === "init") {
35
36
  require("../src/watch.js");
36
37
  } else if (command === "showcase") {
37
38
  require("../src/showcase.js");
39
+ } else if (command === "info") {
40
+ const { info } = require("../src/info.js");
41
+ info();
38
42
  } else if (command === "doctor") {
39
43
  const { doctor } = require("../src/doctor.js");
40
44
  const result = doctor();
@@ -69,6 +73,7 @@ if (command === "init") {
69
73
  emily-css build Generate production CSS to the configured output path
70
74
  --profile Print coarse build timing information
71
75
  emily-css watch Dev mode: watch for changes and rebuild
76
+ emily-css info Show project config, output paths, and CSS stats
72
77
  emily-css doctor Scan project files for unknown EmilyCSS classes
73
78
  emily-css migrate Generate a Tailwind-to-EmilyCSS migration report
74
79
  --import-colours Detect Tailwind colour palettes and suggest importedPalettes config
@@ -80,7 +85,12 @@ if (command === "init") {
80
85
  npm scripts (added by init):
81
86
  npm run emily:build Same as emily-css build
82
87
  npm run emily:watch Same as emily-css watch
88
+ npm run emily:doctor Same as emily-css doctor
89
+ npm run emily:migrate Same as emily-css migrate
90
+ npm run emily:info Same as emily-css info
91
+ npm run emily:manifest Same as emily-css manifest
83
92
  npm run emily:showcase Same as emily-css showcase
93
+ npm run emily:version Same as emily-css version
84
94
  npm run emily:help Same as emily-css help
85
95
 
86
96
  Docs: https://emilyui.dev
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "emily-css",
3
- "version": "1.2.4",
3
+ "version": "1.2.6",
4
4
  "description": "A config-driven utility CSS framework. Define your brand once, generate the CSS.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -26,6 +26,11 @@
26
26
  "ship": "node scripts/ship.js",
27
27
  "emily:build": "emily-css build",
28
28
  "emily:watch": "emily-css watch",
29
+ "emily:doctor": "emily-css doctor",
30
+ "emily:migrate": "emily-css migrate",
31
+ "emily:info": "emily-css info",
32
+ "emily:manifest": "emily-css manifest",
33
+ "emily:version": "emily-css version",
29
34
  "emily:help": "emily-css help"
30
35
  },
31
36
  "keywords": [
@@ -42,7 +47,7 @@
42
47
  "author": "Andy Terry",
43
48
  "license": "MIT",
44
49
  "engines": {
45
- "node": ">=22.0.0"
50
+ "node": ">=18.0.0"
46
51
  },
47
52
  "devDependencies": {
48
53
  "nodemon": "^3.1.14"
package/src/constants.js CHANGED
@@ -46,10 +46,15 @@ const BASE_VARIANTS = [
46
46
  'motion-safe',
47
47
  'aria-expanded',
48
48
  'aria-selected',
49
+ 'aria-checked',
49
50
  'aria-current',
50
51
  'aria-disabled',
51
52
  'data-open',
52
53
  'data-closed',
54
+ 'data-checked',
55
+ 'data-unchecked',
56
+ 'data-active',
57
+ 'data-inactive',
53
58
  'dark',
54
59
  'forced-colors',
55
60
  ];
package/src/doctor.js CHANGED
@@ -4,8 +4,9 @@ const fs = require("fs");
4
4
  const path = require("path");
5
5
  const fg = require("fast-glob");
6
6
  const { extractClassNames } = require("./purge.js");
7
- const { ensureFullFramework, generateManifest } = require("./index.js");
7
+ const { ensureFullFramework, generateManifest, generateColourScale } = require("./index.js");
8
8
  const { DEFAULT_EXTENSIONS } = require("./constants.js");
9
+ const { resolvePurgeConfig } = require("./purgeConfig.js");
9
10
  const {
10
11
  getConfig,
11
12
  getFullCssPath,
@@ -111,18 +112,20 @@ function suggestClassName(className, utilitySet, variantSet) {
111
112
  }
112
113
 
113
114
  function getFilesToScan(config) {
114
- const extensions = config?.purge?.extensions || DEFAULT_EXTENSIONS;
115
- const ignore = config?.purge?.ignore || [];
115
+ const resolvedPurgeConfig = resolvePurgeConfig(config);
116
+ const extensions = resolvedPurgeConfig.extensions || DEFAULT_EXTENSIONS;
117
+ const ignore = resolvedPurgeConfig.ignore || [];
118
+ const sourceGlobs = resolvedPurgeConfig.sourceGlobs || [];
116
119
 
117
- if (config?.purge?.sourceGlobs && config.purge.sourceGlobs.length > 0) {
118
- return fg.sync(config.purge.sourceGlobs, {
120
+ if (sourceGlobs.length > 0) {
121
+ return fg.sync(sourceGlobs, {
119
122
  ignore,
120
123
  onlyFiles: true,
121
124
  unique: true,
122
125
  });
123
126
  }
124
127
 
125
- const sourceDir = config?.purge?.sourceDir || ".";
128
+ const sourceDir = resolvedPurgeConfig.sourceDir || ".";
126
129
  const scanDir = path.isAbsolute(sourceDir)
127
130
  ? sourceDir
128
131
  : path.join(process.cwd(), sourceDir);
@@ -244,6 +247,79 @@ function createAccessibilityWarnings(filePath, content) {
244
247
  return warnings;
245
248
  }
246
249
 
250
+ // ─── Colour contrast helpers ──────────────────────────────────────────────────
251
+
252
+ function hexToRelativeLuminance(hex) {
253
+ const toLinear = (c) => {
254
+ const v = parseInt(c, 16) / 255;
255
+ return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
256
+ };
257
+ const r = toLinear(hex.slice(1, 3));
258
+ const g = toLinear(hex.slice(3, 5));
259
+ const b = toLinear(hex.slice(5, 7));
260
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
261
+ }
262
+
263
+ function contrastRatio(hexA, hexB) {
264
+ const lA = hexToRelativeLuminance(hexA);
265
+ const lB = hexToRelativeLuminance(hexB);
266
+ const lighter = Math.max(lA, lB);
267
+ const darker = Math.min(lA, lB);
268
+ return (lighter + 0.05) / (darker + 0.05);
269
+ }
270
+
271
+ // Check configured brand colours against standard light/dark backgrounds.
272
+ // Shades 60–80 are the most common text-on-light use case.
273
+ // Shades 20–40 are the most common text-on-dark use case.
274
+ function createContrastWarnings(config) {
275
+ const warnings = [];
276
+ const colours = config.colours || {};
277
+ const LIGHT_BG = '#FAFAFA';
278
+ const DARK_BG = '#1A1A1A';
279
+ const THRESHOLD_NORMAL = 4.5;
280
+
281
+ Object.entries(colours).forEach(([name, baseHex]) => {
282
+ let scale;
283
+ try {
284
+ scale = generateColourScale(baseHex);
285
+ } catch {
286
+ return;
287
+ }
288
+
289
+ // Shades typically used as text on light backgrounds
290
+ [60, 70, 80].forEach((shade) => {
291
+ const hex = scale[shade];
292
+ if (!hex) return;
293
+ const ratio = contrastRatio(hex, LIGHT_BG);
294
+ if (ratio < THRESHOLD_NORMAL) {
295
+ warnings.push({
296
+ file: 'emily.config.json',
297
+ reason: 'low-contrast-token',
298
+ className: `text-${name}-${shade}`,
299
+ message: `text-${name}-${shade} on a light background has ~${ratio.toFixed(1)}:1 contrast (WCAG AA needs 4.5:1 for normal text). Consider using a darker shade.`,
300
+ });
301
+ }
302
+ });
303
+
304
+ // Shades typically used as text on dark backgrounds
305
+ [20, 30, 40].forEach((shade) => {
306
+ const hex = scale[shade];
307
+ if (!hex) return;
308
+ const ratio = contrastRatio(hex, DARK_BG);
309
+ if (ratio < THRESHOLD_NORMAL) {
310
+ warnings.push({
311
+ file: 'emily.config.json',
312
+ reason: 'low-contrast-token',
313
+ className: `text-${name}-${shade}`,
314
+ message: `text-${name}-${shade} on a dark background has ~${ratio.toFixed(1)}:1 contrast (WCAG AA needs 4.5:1 for normal text). Consider using a lighter shade.`,
315
+ });
316
+ }
317
+ });
318
+ });
319
+
320
+ return warnings;
321
+ }
322
+
247
323
  function doctor() {
248
324
  const config = getConfig();
249
325
 
@@ -304,8 +380,10 @@ function doctor() {
304
380
  }
305
381
  });
306
382
 
383
+ warnings.push(...createContrastWarnings(config));
384
+
307
385
  if (issues.length === 0 && warnings.length === 0) {
308
- console.log(" EmilyCSS doctor found no class issues");
386
+ console.log("\u2713 EmilyCSS doctor found no class issues");
309
387
  return { ok: true, issues: [], warnings: [], exitCode: 0 };
310
388
  }
311
389
 
@@ -347,4 +425,7 @@ module.exports = {
347
425
  doctor,
348
426
  normaliseClassForManifest,
349
427
  suggestClassName,
428
+ hexToRelativeLuminance,
429
+ contrastRatio,
430
+ createContrastWarnings,
350
431
  };
package/src/index.js CHANGED
@@ -849,12 +849,17 @@ function generateSemanticColourUtilities(semanticColours) {
849
849
 
850
850
  function addAriaDataVariants(css) {
851
851
  const variants = [
852
- { name: 'aria-expanded', selector: '[aria-expanded="true"]' },
853
- { name: 'aria-selected', selector: '[aria-selected="true"]' },
854
- { name: 'aria-current', selector: '[aria-current="page"]' },
855
- { name: 'aria-disabled', selector: '[aria-disabled="true"]' },
856
- { name: 'data-open', selector: '[data-state="open"]' },
857
- { name: 'data-closed', selector: '[data-state="closed"]' },
852
+ { name: 'aria-expanded', selector: '[aria-expanded="true"]' },
853
+ { name: 'aria-selected', selector: '[aria-selected="true"]' },
854
+ { name: 'aria-checked', selector: '[aria-checked="true"]' },
855
+ { name: 'aria-current', selector: '[aria-current="page"]' },
856
+ { name: 'aria-disabled', selector: '[aria-disabled="true"]' },
857
+ { name: 'data-open', selector: '[data-state="open"]' },
858
+ { name: 'data-closed', selector: '[data-state="closed"]' },
859
+ { name: 'data-checked', selector: '[data-state="checked"]' },
860
+ { name: 'data-unchecked', selector: '[data-state="unchecked"]' },
861
+ { name: 'data-active', selector: '[data-state="active"]' },
862
+ { name: 'data-inactive', selector: '[data-state="inactive"]' },
858
863
  ];
859
864
 
860
865
  let variantCss = css;
@@ -1828,6 +1833,7 @@ module.exports = {
1828
1833
  oklchToHex,
1829
1834
  generateColourScale,
1830
1835
  generateAllColours,
1836
+ generateFontCSS,
1831
1837
  generateSpacing,
1832
1838
  generateBorderUtilities,
1833
1839
  generateColourUtilities,
@@ -1840,6 +1846,4 @@ module.exports = {
1840
1846
  addAriaDataVariants,
1841
1847
  addResponsiveVariants,
1842
1848
  generateManifest,
1843
- generateFontCSS,
1844
- codeUtilities,
1845
1849
  };
package/src/info.js ADDED
@@ -0,0 +1,94 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const fg = require('fast-glob');
6
+ const {
7
+ getConfig,
8
+ getFullCssPath,
9
+ getProductionCssPath,
10
+ } = require('./config.js');
11
+ const { resolvePurgeConfig } = require('./purgeConfig.js');
12
+
13
+ function formatBytes(bytes) {
14
+ if (bytes < 1024) return `${bytes} B`;
15
+ return `${(bytes / 1024).toFixed(1)} KB`;
16
+ }
17
+
18
+ function detectFramework(config) {
19
+ if (config.framework) return config.framework;
20
+ // fall back to output path heuristic
21
+ const outputPath = typeof config.output === 'string' ? config.output : (config.output && config.output.path) || '';
22
+ const out = outputPath.toLowerCase();
23
+ if (out.includes('public')) return 'Nuxt / Vue / Next.js';
24
+ return 'Static / Generic';
25
+ }
26
+
27
+ function getSourceGlobs(config) {
28
+ return resolvePurgeConfig(config).sourceGlobs;
29
+ }
30
+
31
+ function countSourceFiles(globs) {
32
+ try {
33
+ return fg.sync(globs, { onlyFiles: true, unique: true }).length;
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+
39
+ function info() {
40
+ let config;
41
+ try {
42
+ config = getConfig();
43
+ } catch (err) {
44
+ console.error('emily-css info: could not load emily.config.json');
45
+ console.error(err.message);
46
+ process.exitCode = 1;
47
+ return;
48
+ }
49
+
50
+ const packageJson = require(path.join(__dirname, '..', 'package.json'));
51
+ const fullCssPath = getFullCssPath(config);
52
+ const productionCssPath = getProductionCssPath(config);
53
+ const globs = getSourceGlobs(config);
54
+ const fileCount = countSourceFiles(globs);
55
+
56
+ const fullSizeStr = fs.existsSync(fullCssPath)
57
+ ? formatBytes(fs.statSync(fullCssPath).size)
58
+ : 'not built';
59
+
60
+ const prodSizeStr = fs.existsSync(productionCssPath)
61
+ ? formatBytes(fs.statSync(productionCssPath).size)
62
+ : 'not built';
63
+
64
+ const colourNames = config.colours ? Object.keys(config.colours) : [];
65
+ const semanticNames = config.semanticColours ? Object.keys(config.semanticColours) : [];
66
+ const colourSummary = colourNames.length
67
+ ? colourNames.join(', ') + (semanticNames.length ? ` + ${semanticNames.length} semantic` : '')
68
+ : 'none';
69
+
70
+ const fontConfig = config.fontFamily || {};
71
+ const headingFont = typeof fontConfig === 'object' ? (fontConfig.heading || 'system') : fontConfig;
72
+ const bodyFont = typeof fontConfig === 'object' ? (fontConfig.body || 'system') : fontConfig;
73
+
74
+ const lines = [
75
+ `emily-css v${packageJson.version}`,
76
+ '',
77
+ ` Project: ${detectFramework(config)}`,
78
+ ` Config: emily.config.json`,
79
+ ` Output: ${path.relative(process.cwd(), fullCssPath)} (full)`,
80
+ ` ${path.relative(process.cwd(), productionCssPath)} (production)`,
81
+ ` CSS size: ${fullSizeStr} (full) / ${prodSizeStr} (production)`,
82
+ ` Source globs: ${globs.join(', ')}`,
83
+ ` Files found: ${fileCount !== null ? fileCount : 'unknown'}`,
84
+ '',
85
+ ` Colours: ${colourSummary}`,
86
+ ` Spacing: ${config.spacing && config.spacing.scale ? Object.keys(config.spacing.scale).length : 0} steps`,
87
+ ` Fonts: ${headingFont} (heading), ${bodyFont} (body)`,
88
+ '',
89
+ ];
90
+
91
+ console.log(lines.join('\n'));
92
+ }
93
+
94
+ module.exports = { info };
package/src/init.js CHANGED
@@ -154,6 +154,11 @@ function addEmilyScriptsToPackageJson() {
154
154
  const scripts = {
155
155
  "emily:build": "emily-css build",
156
156
  "emily:watch": "emily-css watch",
157
+ "emily:doctor": "emily-css doctor",
158
+ "emily:migrate": "emily-css migrate",
159
+ "emily:info": "emily-css info",
160
+ "emily:manifest": "emily-css manifest",
161
+ "emily:version": "emily-css version",
157
162
  "emily:help": "emily-css help",
158
163
  "emily:showcase": "emily-css showcase",
159
164
  };
@@ -288,7 +293,7 @@ function detectProject() {
288
293
  sourceDir: ".",
289
294
  outputPath: "dist/emily.min.css",
290
295
  sourceGlobs: [
291
- "./**/*.{html,htm,twig,njk,liquid,hbs,php,astro,svelte,vue,js,ts}",
296
+ "./**/*.{html,htm,twig,njk,liquid,hbs,php,astro,svelte,vue,blade.php,jinja,jinja2,j2}",
292
297
  ],
293
298
  linkHint: '<link rel="stylesheet" href="./dist/emily.min.css">',
294
299
  };
@@ -325,6 +330,8 @@ function createDefaultConfig({
325
330
  fullCss: "dist/emily.css",
326
331
  },
327
332
 
333
+ manifest: true,
334
+
328
335
  colours,
329
336
 
330
337
  semanticColours: {
@@ -721,6 +728,11 @@ async function init() {
721
728
  ? "\n\nScripts added:\n" +
722
729
  chalk.cyan(" npm run emily:build\n") +
723
730
  chalk.cyan(" npm run emily:watch\n") +
731
+ chalk.cyan(" npm run emily:doctor\n") +
732
+ chalk.cyan(" npm run emily:migrate\n") +
733
+ chalk.cyan(" npm run emily:info\n") +
734
+ chalk.cyan(" npm run emily:manifest\n") +
735
+ chalk.cyan(" npm run emily:version\n") +
724
736
  chalk.cyan(" npm run emily:showcase\n") +
725
737
  chalk.cyan(" npm run emily:help")
726
738
  : ""),
package/src/migrate.js CHANGED
@@ -6,6 +6,7 @@ const { extractClassNames, getAllFiles } = require('./purge.js');
6
6
  const { generateManifest } = require('./manifest.js');
7
7
  const { normaliseClassForManifest, suggestClassName } = require('./doctor.js');
8
8
  const { DEFAULT_EXTENSIONS } = require('./constants.js');
9
+ const { resolvePurgeConfig } = require('./purgeConfig.js');
9
10
  const MIGRATION_DEFAULT_EXTENSIONS = [...DEFAULT_EXTENSIONS, '.mdx'];
10
11
 
11
12
  const TAILWIND_MAPPINGS = {
@@ -573,6 +574,13 @@ function isLikelyUtilityClass(className) {
573
574
  if (/[;()={},`]/.test(className)) return false;
574
575
  if (!/[a-zA-Z]/.test(className)) return false;
575
576
  if (!/^[a-zA-Z0-9:#_./\-[\]]+$/.test(className)) return false;
577
+
578
+ /*
579
+ * Ignore obvious prose / JS expressions
580
+ */
581
+ if (/[.]+$/.test(className)) return false;
582
+ if (/^[[][^:\]]+[]]$/.test(className)) return false;
583
+ if (/^[a-zA-Z]+\.[a-zA-Z0-9_$]+$/.test(className)) return false;
576
584
  if (/^[a-z]+$/.test(className) && !SINGLE_WORD_UTILITY_ALLOWLIST.has(className)) return false;
577
585
  if (!hasUtilityLikeSyntax(className) && !SINGLE_WORD_UTILITY_ALLOWLIST.has(className)) return false;
578
586
 
@@ -740,8 +748,11 @@ function migrateClasses(input, options = {}) {
740
748
  }
741
749
 
742
750
  function getFilesToScan(config, options = {}) {
743
- const extensions = (config && config.purge && config.purge.extensions) || MIGRATION_DEFAULT_EXTENSIONS;
744
- const ignore = (config && config.purge && config.purge.ignore) || [];
751
+ const resolvedPurgeConfig = resolvePurgeConfig(config || {});
752
+ const extensions = Array.from(
753
+ new Set([...(resolvedPurgeConfig.extensions || MIGRATION_DEFAULT_EXTENSIONS), '.mdx']),
754
+ );
755
+ const ignore = resolvedPurgeConfig.ignore || [];
745
756
 
746
757
  if (options.sourceGlobs && options.sourceGlobs.length > 0) {
747
758
  return fg.sync(options.sourceGlobs, {
@@ -752,8 +763,8 @@ function getFilesToScan(config, options = {}) {
752
763
  });
753
764
  }
754
765
 
755
- if (config && config.purge && config.purge.sourceGlobs && config.purge.sourceGlobs.length > 0) {
756
- return fg.sync(config.purge.sourceGlobs, {
766
+ if (resolvedPurgeConfig.sourceGlobs && resolvedPurgeConfig.sourceGlobs.length > 0) {
767
+ return fg.sync(resolvedPurgeConfig.sourceGlobs, {
757
768
  ignore,
758
769
  onlyFiles: true,
759
770
  unique: true,
@@ -763,7 +774,7 @@ function getFilesToScan(config, options = {}) {
763
774
 
764
775
  const sourceDir =
765
776
  options.sourceDir ||
766
- (config && config.purge && config.purge.sourceDir) ||
777
+ resolvedPurgeConfig.sourceDir ||
767
778
  '.';
768
779
 
769
780
  const scanDir = path.isAbsolute(sourceDir)
@@ -879,4 +890,4 @@ module.exports = {
879
890
  migrateClasses,
880
891
  loadManifest,
881
892
  generateMigrationReport,
882
- };
893
+ };
package/src/purge.js CHANGED
@@ -4,6 +4,7 @@ const fs = require("fs");
4
4
  const path = require("path");
5
5
  const fg = require("fast-glob");
6
6
  const { DEFAULT_EXTENSIONS, DEFAULT_PURGE_IGNORE } = require("./constants.js");
7
+ const { resolvePurgeConfig } = require("./purgeConfig.js");
7
8
 
8
9
  function getAllFiles(dir, extensions = DEFAULT_EXTENSIONS) {
9
10
  const absoluteDir = path.isAbsolute(dir) ? dir : path.resolve(dir);
@@ -175,22 +176,27 @@ function purgeBlock(block, usedClasses) {
175
176
  }
176
177
 
177
178
  function getFilesForPurge(scanDir, config, extensions) {
178
- if (config?.purge?.sourceGlobs && config.purge.sourceGlobs.length) {
179
+ const resolvedPurgeConfig = resolvePurgeConfig(config);
180
+ const sourceGlobs = resolvedPurgeConfig.sourceGlobs || [];
181
+
182
+ if (sourceGlobs.length) {
179
183
  console.log(`\n🔍 Scanning using sourceGlobs`);
180
- config.purge.sourceGlobs.forEach((glob) => console.log(` - ${glob}`));
184
+ sourceGlobs.forEach((glob) => console.log(` - ${glob}`));
181
185
  console.log(` Extensions: ${extensions.join(", ")}`);
182
186
 
183
- return fg.sync(config.purge.sourceGlobs, {
184
- ignore: config.purge.ignore || [],
187
+ return fg.sync(sourceGlobs, {
188
+ ignore: resolvedPurgeConfig.ignore || [],
185
189
  onlyFiles: true,
186
190
  unique: true,
187
191
  });
188
192
  }
189
193
 
190
- console.log(`\n🔍 Scanning fallback directory: ${scanDir}`);
194
+ const fallbackScanDir = resolvedPurgeConfig.sourceDir || scanDir;
195
+
196
+ console.log(`\n🔍 Scanning fallback directory: ${fallbackScanDir}`);
191
197
  console.log(` Extensions: ${extensions.join(", ")}`);
192
198
 
193
- return getAllFiles(scanDir, extensions);
199
+ return getAllFiles(fallbackScanDir, extensions);
194
200
  }
195
201
 
196
202
  function printFileSummary(files, extensions) {
@@ -214,7 +220,8 @@ function printFileSummary(files, extensions) {
214
220
  }
215
221
 
216
222
  function purgeCSS(css, scanDir, config) {
217
- const extensions = config?.purge?.extensions || DEFAULT_EXTENSIONS;
223
+ const resolvedPurgeConfig = resolvePurgeConfig(config);
224
+ const extensions = resolvedPurgeConfig.extensions || DEFAULT_EXTENSIONS;
218
225
  const files = getFilesForPurge(scanDir, config, extensions);
219
226
 
220
227
  printFileSummary(files, extensions);
@@ -0,0 +1,191 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { DEFAULT_EXTENSIONS, DEFAULT_PURGE_IGNORE } = require('./constants.js');
6
+
7
+ const LEGACY_STATIC_GENERIC_GLOB = './**/*.{html,htm,twig,njk,liquid,hbs,php,astro,svelte,vue,js,ts}';
8
+
9
+ const PROJECT_PURGE_PRESETS = {
10
+ Nuxt: {
11
+ sourceDir: '.',
12
+ sourceGlobs: [
13
+ './components/**/*.{vue,js,ts}',
14
+ './pages/**/*.vue',
15
+ './layouts/**/*.vue',
16
+ './app.vue',
17
+ ],
18
+ },
19
+ 'Next.js': {
20
+ sourceDir: '.',
21
+ sourceGlobs: [
22
+ './app/**/*.{js,jsx,ts,tsx}',
23
+ './pages/**/*.{js,jsx,ts,tsx}',
24
+ './components/**/*.{js,jsx,ts,tsx}',
25
+ './src/**/*.{js,jsx,ts,tsx}',
26
+ ],
27
+ },
28
+ React: {
29
+ sourceDir: './src',
30
+ sourceGlobs: [
31
+ './src/**/*.{js,jsx,ts,tsx,html}',
32
+ './components/**/*.{js,jsx,ts,tsx}',
33
+ './public/**/*.html',
34
+ ],
35
+ },
36
+ 'Vue/Vite': {
37
+ sourceDir: './src',
38
+ sourceGlobs: [
39
+ './src/**/*.{vue,js,jsx,ts,tsx,html}',
40
+ './index.html',
41
+ ],
42
+ },
43
+ Astro: {
44
+ sourceDir: './src',
45
+ sourceGlobs: [
46
+ './src/**/*.{astro,html,md,mdx,js,jsx,ts,tsx,vue,svelte}',
47
+ ],
48
+ },
49
+ Drupal: {
50
+ sourceDir: '.',
51
+ sourceGlobs: [
52
+ './web/themes/custom/**/*.{twig,js,ts}',
53
+ './templates/**/*.html.twig',
54
+ './components/**/*.twig',
55
+ './**/*.theme',
56
+ ],
57
+ },
58
+ 'Static/Generic': {
59
+ sourceDir: '.',
60
+ sourceGlobs: [
61
+ './**/*.{html,htm,twig,njk,liquid,hbs,php,astro,svelte,vue,blade.php,jinja,jinja2,j2}',
62
+ ],
63
+ },
64
+ };
65
+
66
+ function unique(values) {
67
+ return Array.from(new Set((values || []).filter(Boolean)));
68
+ }
69
+
70
+ function readPackageJson(cwd) {
71
+ const packagePath = path.join(cwd, 'package.json');
72
+ if (!fs.existsSync(packagePath)) return null;
73
+
74
+ try {
75
+ return JSON.parse(fs.readFileSync(packagePath, 'utf8'));
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+
81
+ function hasDependency(packageJson, dependencyName) {
82
+ if (!packageJson) return false;
83
+
84
+ return Boolean(
85
+ packageJson.dependencies?.[dependencyName] ||
86
+ packageJson.devDependencies?.[dependencyName],
87
+ );
88
+ }
89
+
90
+ function detectProjectType(cwd = process.cwd()) {
91
+ const packageJson = readPackageJson(cwd);
92
+
93
+ if (
94
+ fs.existsSync(path.join(cwd, 'nuxt.config.ts')) ||
95
+ fs.existsSync(path.join(cwd, 'nuxt.config.js')) ||
96
+ hasDependency(packageJson, 'nuxt')
97
+ ) {
98
+ return 'Nuxt';
99
+ }
100
+
101
+ if (hasDependency(packageJson, 'next')) {
102
+ return 'Next.js';
103
+ }
104
+
105
+ if (hasDependency(packageJson, 'react')) {
106
+ return 'React';
107
+ }
108
+
109
+ if (
110
+ hasDependency(packageJson, 'vue') ||
111
+ fs.existsSync(path.join(cwd, 'vite.config.ts')) ||
112
+ fs.existsSync(path.join(cwd, 'vite.config.js'))
113
+ ) {
114
+ return 'Vue/Vite';
115
+ }
116
+
117
+ if (
118
+ hasDependency(packageJson, 'astro') ||
119
+ fs.existsSync(path.join(cwd, 'astro.config.mjs'))
120
+ ) {
121
+ return 'Astro';
122
+ }
123
+
124
+ let rootFiles = [];
125
+ try {
126
+ rootFiles = fs.readdirSync(cwd);
127
+ } catch {
128
+ return 'Static/Generic';
129
+ }
130
+
131
+ const hasDrupalInfoFile = rootFiles.some((file) => file.endsWith('.info.yml'));
132
+
133
+ if (
134
+ hasDrupalInfoFile ||
135
+ fs.existsSync(path.join(cwd, 'web/core'))
136
+ ) {
137
+ return 'Drupal';
138
+ }
139
+
140
+ return 'Static/Generic';
141
+ }
142
+
143
+ function normaliseGlob(glob) {
144
+ return String(glob || '').replace(/\\/g, '/').trim();
145
+ }
146
+
147
+ function isLegacyStaticGenericGlob(sourceGlobs) {
148
+ if (!Array.isArray(sourceGlobs) || sourceGlobs.length !== 1) return false;
149
+ const [onlyGlob] = sourceGlobs.map(normaliseGlob);
150
+ return onlyGlob === LEGACY_STATIC_GENERIC_GLOB;
151
+ }
152
+
153
+ function resolvePurgeConfig(config = {}, options = {}) {
154
+ const purge = config.purge || {};
155
+ const cwd = options.cwd || process.cwd();
156
+ const detectedProjectType = detectProjectType(cwd);
157
+ const projectType = purge.projectType || detectedProjectType;
158
+ const preset = PROJECT_PURGE_PRESETS[projectType] || PROJECT_PURGE_PRESETS['Static/Generic'];
159
+
160
+ const configuredSourceGlobs = Array.isArray(purge.sourceGlobs)
161
+ ? purge.sourceGlobs.filter(Boolean)
162
+ : [];
163
+
164
+ let sourceGlobs = configuredSourceGlobs.length > 0
165
+ ? configuredSourceGlobs
166
+ : preset.sourceGlobs;
167
+
168
+ if (projectType === 'Static/Generic' && isLegacyStaticGenericGlob(sourceGlobs)) {
169
+ sourceGlobs = preset.sourceGlobs;
170
+ }
171
+
172
+ const sourceDir = purge.sourceDir || preset.sourceDir || '.';
173
+
174
+ return {
175
+ projectType,
176
+ sourceDir,
177
+ sourceGlobs,
178
+ ignore: unique([...DEFAULT_PURGE_IGNORE, ...(purge.ignore || [])]),
179
+ extensions: Array.isArray(purge.extensions) && purge.extensions.length > 0
180
+ ? purge.extensions
181
+ : DEFAULT_EXTENSIONS,
182
+ };
183
+ }
184
+
185
+ module.exports = {
186
+ PROJECT_PURGE_PRESETS,
187
+ LEGACY_STATIC_GENERIC_GLOB,
188
+ detectProjectType,
189
+ resolvePurgeConfig,
190
+ };
191
+