New:Socket for Asana Is Now Available.Learn more
Sign In

strata-css

Package Overview
Dependencies
Maintainers
1
Versions
42
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

strata-css - npm Package Compare versions

Comparing version
1.5.13
to
1.6.13
+17
-0
CHANGELOG.md

@@ -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

@@ -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')

@@ -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