strata-css
Advanced tools
+17
-0
@@ -5,2 +5,19 @@ # Changelog | ||
| ## [1.6.13] — 2026-08-04 | ||
| ### Fixed | ||
| - **Classes used only inside `className={...}` expressions never generated CSS.** The scanner matched exactly two shapes — `className="literal"` and `className={"literal"}` — because its regex required a quote immediately after `{`. Every other form was invisible: `clsx()`, `cn()`, `classnames()`, ternaries, arrays, template literals. A class used only in one of those produced no CSS, with no error and no warning; it appeared to work only when the same class happened to also exist as a plain literal elsewhere in the tree. The scanner now walks the whole braced expression and treats every string literal inside it as a class candidate — including template-literal static chunks and strings nested in `${...}` interpolations — without hardcoding helper names, so `clsx`/`cn`/`cx`/`classnames` and any other wrapper work identically. Present since 1.0.0. | ||
| - **`element.className = '...'` assignments were skipped.** The attribute pattern required `=` with no surrounding whitespace, so runtime assignments like `backdrop.className = 'modal-backdrop'` were missed — including in Strata's own modal and offcanvas packages, whose backdrop classes were silently absent from generated CSS. | ||
| - **An edited `strata.config.js` never took effect without a process restart.** `loadConfig()` used bare `require()`, which memoises for the lifetime of the process, so new `content` globs or `safelist` entries were silently ignored in dev servers and watch sessions. The module cache is now busted on change, keyed on mtime + size, and cleared outright by `invalidate()`. Three `cachedConfig*` variables had been declared for this since 1.0.0 but never used. | ||
| ### Added | ||
| - **`safelist` in `strata.config.js` now actually works.** It had been documented as the escape hatch for dynamic class construction since 1.0.0 but was never implemented — following the documentation produced a silent no-op. Entries may contain several space-separated class names and go through the normal registry lookup, so arbitrary values and responsive variants are supported. Applied inside `generate()`, the single choke point shared by the PostCSS plugin and the CLI build path. | ||
| - **`npm test`** — the repository had three test files and no way to run them. Now runs the full suite (287 assertions) and exits non-zero on failure. | ||
| ### Tests | ||
| - `test/scanner.js` — new suite locking in every className shape the scanner must understand, each asserted via a class that appears in that shape *and nowhere else* in the fixture, so an unsupported shape cannot be masked by an incidental occurrence elsewhere. Verified to fail (15 of 22) against the pre-fix scanner. Covers safelist, config reload, unbalanced braces, and escaped quotes. | ||
| - Repaired two assertions in `test/verify.js` that had been failing since 1.4.4 — they asserted `offcanvas-start` and `body.modal-open`, both intentionally replaced (by `data-st-side` and the `:has()` scroll lock respectively) without the tests being updated. A permanently red suite is why the scanner bug went unnoticed for so long. | ||
| --- | ||
| ## [1.5.13] — 2026-08-03 | ||
@@ -7,0 +24,0 @@ |
+2
-1
| { | ||
| "name": "strata-css", | ||
| "version": "1.5.13", | ||
| "version": "1.6.13", | ||
| "_versioningNote": "Stable: 1.0.0 / 1.1.0 / 2.0.0 | Beta: 1.1.0-beta.1 / 1.1.0-beta.2", | ||
@@ -29,2 +29,3 @@ "description": "A modern CSS framework combining Bootstrap components with Tailwind JIT processing", | ||
| "build:chart": "tsc -p src/components/modules/chart/tsconfig.json", | ||
| "test": "node bin/strata.js --build && node test/scanner.js && node test/dependency-tracking.js && node test/verify.js", | ||
| "benchmark": "node --expose-gc benchmark/run.js", | ||
@@ -31,0 +32,0 @@ "publish:stable": "npm publish --tag latest", |
@@ -65,3 +65,22 @@ /** | ||
| for (const cls of classNames) { | ||
| // Safelist — classes no scanner can ever discover because they are built at | ||
| // runtime from variables (`btn-${variant}`), injected by a CMS, or arrive in | ||
| // markup Strata never sees. Applied here, at the single choke point both the | ||
| // PostCSS plugin and the CLI build path funnel through, so it cannot be | ||
| // wired up in one and silently missed in the other. | ||
| // Entries may contain multiple space-separated class names. | ||
| const safelist = Array.isArray(config.safelist) ? config.safelist : [] | ||
| let effective = classNames | ||
| if (safelist.length) { | ||
| effective = new Set(classNames) | ||
| for (const entry of safelist) { | ||
| if (typeof entry !== 'string') continue | ||
| for (const part of entry.split(/\s+/)) { | ||
| const t = part.trim() | ||
| if (t) effective.add(t) | ||
| } | ||
| } | ||
| } | ||
| for (const cls of effective) { | ||
| const result = lookup(cls) | ||
@@ -68,0 +87,0 @@ if (!result) continue |
+44
-14
@@ -23,17 +23,44 @@ /** | ||
| let cachedConfigPath = null | ||
| let cachedConfigMtime = 0 | ||
| let cachedConfigMtime = null | ||
| function loadConfig(cwd) { | ||
| const configPath = path.resolve(cwd, 'strata.config.js') | ||
| function resolveConfigPath(cwd) { | ||
| const configPathCjs = path.resolve(cwd, 'strata.config.cjs') | ||
| if (fs.existsSync(configPathCjs)) return configPathCjs | ||
| const configPath = path.resolve(cwd, 'strata.config.js') | ||
| if (fs.existsSync(configPath)) return configPath | ||
| return null | ||
| } | ||
| if (fs.existsSync(configPathCjs)) { | ||
| try { return require(configPathCjs) } catch {} | ||
| function loadConfig(cwd) { | ||
| const resolved = resolveConfigPath(cwd) | ||
| if (!resolved) return {} | ||
| // mtime resolution is coarse, so pair it with size — two edits in the same | ||
| // millisecond that change the file's length are still detected. invalidate() | ||
| // clears this cache outright, which is the authoritative signal. | ||
| let stamp = '0' | ||
| try { | ||
| const st = fs.statSync(resolved) | ||
| stamp = `${st.mtimeMs}:${st.size}` | ||
| } catch {} | ||
| if (cachedConfig && cachedConfigPath === resolved && cachedConfigMtime === stamp) { | ||
| return cachedConfig | ||
| } | ||
| if (fs.existsSync(configPath)) { | ||
| try { return require(configPath) } catch {} | ||
| try { | ||
| // Bust Node's module cache before re-reading. require() memoises for the | ||
| // lifetime of the process, so without this an edited strata.config.js | ||
| // (new content globs, new safelist entries) silently never takes effect | ||
| // in a dev server or watch session — the very staleness class of bug the | ||
| // dependency-tracking fix in 1.5.13 set out to eliminate. | ||
| delete require.cache[require.resolve(resolved)] | ||
| const config = require(resolved) | ||
| cachedConfig = config | ||
| cachedConfigPath = resolved | ||
| cachedConfigMtime = stamp | ||
| return config | ||
| } catch { | ||
| return {} | ||
| } | ||
| return {} | ||
| } | ||
@@ -106,7 +133,3 @@ | ||
| } | ||
| const configPath = path.resolve(cwd, 'strata.config.js') | ||
| const configPathCjs = path.resolve(cwd, 'strata.config.cjs') | ||
| const resolvedConfigPath = fs.existsSync(configPathCjs) ? configPathCjs | ||
| : fs.existsSync(configPath) ? configPath | ||
| : null | ||
| const resolvedConfigPath = resolveConfigPath(cwd) | ||
| if (resolvedConfigPath) { | ||
@@ -201,2 +224,9 @@ result.messages.push({ | ||
| cachedCSS = null | ||
| // Drop the memoised config too. mtime alone is not a sound invalidation | ||
| // signal — its resolution is coarse enough that a config edited twice in | ||
| // quick succession can report an unchanged timestamp — and invalidate() | ||
| // explicitly means "something on disk changed, trust nothing". | ||
| cachedConfig = null | ||
| cachedConfigPath = null | ||
| cachedConfigMtime = null | ||
| const { clearFileCache } = require('./scanner/scanner') | ||
@@ -203,0 +233,0 @@ const { clearResultCache } = require('./registry/registry') |
+140
-7
@@ -16,4 +16,114 @@ /** | ||
| const CLASS_PATTERN = /class(?:Name)?=(?:["'`]([^"'`]+)["'`]|\{["'`]([^"'`]+)["'`]\})/g | ||
| // Finds where a class attribute's value begins. Deliberately NOT anchored with | ||
| // \b: `wrapperClassName="..."`, `itemClassName={...}` and similar forwarded | ||
| // props have always been scanned, and consumers rely on that. | ||
| const CLASS_ATTR_PATTERN = /class(?:Name)?\s*=\s*/g | ||
| // Hard cap on how far into a single {...} value we scan. Guards against an | ||
| // unbalanced brace (or minified/generated source) walking the rest of a file. | ||
| const MAX_EXPR_LEN = 8192 | ||
| // Returns the index just past the string literal starting at `i`. | ||
| // Template literals are walked including their ${...} interpolations so that | ||
| // brace counting in the caller stays correct. | ||
| function skipQuoted(text, i, end) { | ||
| const quote = text[i] | ||
| i++ | ||
| if (quote === '`') { | ||
| while (i < end) { | ||
| const c = text[i] | ||
| if (c === '\\') { i += 2; continue } | ||
| if (c === '$' && text[i + 1] === '{') { | ||
| i += 2 | ||
| let depth = 1 | ||
| while (i < end && depth > 0) { | ||
| const d = text[i] | ||
| if (d === '"' || d === "'" || d === '`') { i = skipQuoted(text, i, end); continue } | ||
| if (d === '{') depth++ | ||
| else if (d === '}') depth-- | ||
| i++ | ||
| } | ||
| continue | ||
| } | ||
| if (c === '`') return i + 1 | ||
| i++ | ||
| } | ||
| return i | ||
| } | ||
| while (i < end) { | ||
| const c = text[i] | ||
| if (c === '\\') { i += 2; continue } | ||
| if (c === quote) return i + 1 | ||
| i++ | ||
| } | ||
| return i | ||
| } | ||
| // Index of the '}' matching the '{' at `start`, or -1 if unbalanced/too long. | ||
| function findExprEnd(text, start, end) { | ||
| const limit = Math.min(end, start + MAX_EXPR_LEN) | ||
| let depth = 0 | ||
| let i = start | ||
| while (i < limit) { | ||
| const c = text[i] | ||
| if (c === '"' || c === "'" || c === '`') { i = skipQuoted(text, i, limit); continue } | ||
| if (c === '{') depth++ | ||
| else if (c === '}') { depth--; if (depth === 0) return i } | ||
| i++ | ||
| } | ||
| return -1 | ||
| } | ||
| // Emits every string literal found in text[start,end) via `emit`. | ||
| // Covers plain strings, template-literal static chunks, and — by recursing — | ||
| // strings nested inside ${...} interpolations. This is what makes | ||
| // className={clsx('a', cond ? 'b' : 'c')} and `base ${x ? 'on' : 'off'}` | ||
| // visible to the scanner without hardcoding helper names like clsx/cn/cx. | ||
| function collectStrings(text, start, end, emit) { | ||
| let i = start | ||
| while (i < end) { | ||
| const c = text[i] | ||
| if (c === '"' || c === "'") { | ||
| const quote = c | ||
| i++ | ||
| const from = i | ||
| while (i < end && text[i] !== quote) { | ||
| if (text[i] === '\\') i++ | ||
| i++ | ||
| } | ||
| emit(text.slice(from, i)) | ||
| i++ | ||
| continue | ||
| } | ||
| if (c === '`') { | ||
| i++ | ||
| let chunk = i | ||
| while (i < end && text[i] !== '`') { | ||
| if (text[i] === '\\') { i += 2; continue } | ||
| if (text[i] === '$' && text[i + 1] === '{') { | ||
| emit(text.slice(chunk, i)) | ||
| const exprStart = i + 2 | ||
| let depth = 1 | ||
| i += 2 | ||
| while (i < end && depth > 0) { | ||
| const d = text[i] | ||
| if (d === '"' || d === "'" || d === '`') { i = skipQuoted(text, i, end); continue } | ||
| if (d === '{') depth++ | ||
| else if (d === '}') depth-- | ||
| i++ | ||
| } | ||
| collectStrings(text, exprStart, i - 1, emit) | ||
| chunk = i | ||
| continue | ||
| } | ||
| i++ | ||
| } | ||
| emit(text.slice(chunk, i)) | ||
| i++ | ||
| continue | ||
| } | ||
| i++ | ||
| } | ||
| } | ||
| // File content cache — keyed by path, stores { mtime, classes } | ||
@@ -62,8 +172,4 @@ const fileCache = new Map() | ||
| const classes = new Set() | ||
| CLASS_PATTERN.lastIndex = 0 | ||
| let match | ||
| while ((match = CLASS_PATTERN.exec(content)) !== null) { | ||
| const str = match[1] || match[2] | ||
| if (!str) continue | ||
| const emit = (str) => { | ||
| if (!str) return | ||
| const parts = str.split(/\s+/) | ||
@@ -76,2 +182,29 @@ for (let i = 0; i < parts.length; i++) { | ||
| const n = content.length | ||
| CLASS_ATTR_PATTERN.lastIndex = 0 | ||
| let match | ||
| while ((match = CLASS_ATTR_PATTERN.exec(content)) !== null) { | ||
| const i = match.index + match[0].length | ||
| if (i >= n) break | ||
| const c = content[i] | ||
| // class="a b c" / class='a b c' / class=`a b c` | ||
| if (c === '"' || c === "'" || c === '`') { | ||
| const close = skipQuoted(content, i, n) | ||
| collectStrings(content, i, close, emit) | ||
| CLASS_ATTR_PATTERN.lastIndex = close | ||
| continue | ||
| } | ||
| // className={...} — any expression, not just a bare string literal | ||
| if (c === '{') { | ||
| const close = findExprEnd(content, i, n) | ||
| if (close === -1) continue | ||
| collectStrings(content, i + 1, close, emit) | ||
| CLASS_ATTR_PATTERN.lastIndex = close + 1 | ||
| continue | ||
| } | ||
| } | ||
| fileCache.set(filePath, { mtime, classes }) | ||
@@ -78,0 +211,0 @@ return classes |
No tests
QualityPackage does not have any tests. This is a strong signal of a poorly maintained or low quality package.
312915
3.15%5922
3.01%1
-50%11
-8.33%