@invisibleloop/pulse 0.20.37 → 0.20.39

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invisibleloop/pulse",
3
- "version": "0.20.37",
3
+ "version": "0.20.39",
4
4
  "type": "module",
5
5
  "description": "AI-first frontend framework. The spec is the source of truth.",
6
6
  "license": "MIT",
@@ -61,7 +61,7 @@
61
61
  "scripts": {
62
62
  "dev": "node --watch src/cli/dev.js",
63
63
  "build": "node scripts/build.js",
64
- "test": "node src/spec/schema.test.js && node src/runtime/runtime.test.js && node src/runtime/navigate.test.js && node src/runtime/morph.test.js && node src/runtime/ssr.test.js && node src/runtime/image.test.js && node src/server/server.test.js && node src/cli/cli.test.js && node src/cli/validate.test.js && node src/cli/diagnose.test.js && node src/cli/check-bundles.test.js && node src/cli/review.test.js && node src/cli/process-utils.test.js && node src/cli/stamp.test.js && node src/ui/ui.test.js && node src/testing/testing.test.js && node src/store/store.test.js && node src/md/md.test.js && node scripts/strip-server.test.js && node scripts/css-purge.test.js && node src/mcp/mcp.test.js",
64
+ "test": "node src/spec/schema.test.js && node src/runtime/runtime.test.js && node src/runtime/navigate.test.js && node src/runtime/morph.test.js && node src/runtime/ssr.test.js && node src/runtime/image.test.js && node src/server/server.test.js && node src/cli/cli.test.js && node src/cli/validate.test.js && node src/cli/diagnose.test.js && node src/cli/check-bundles.test.js && node src/cli/review.test.js && node src/cli/process-utils.test.js && node src/cli/stamp.test.js && node src/ui/ui.test.js && node src/testing/testing.test.js && node src/store/store.test.js && node src/md/md.test.js && node scripts/strip-server.test.js && node scripts/css-purge.test.js && node src/mcp/mcp.test.js && node src/mcp/guards.test.js",
65
65
  "typecheck": "tsc --noEmit",
66
66
  "docs": "node --watch src/cli/dev.js --root ../../docs --port 4000",
67
67
  "docs:start": "node ../../docs/server.js"
@@ -1 +1 @@
1
- 0.20.37
1
+ 0.20.39
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Input guards for MCP tools.
3
+ *
4
+ * The MCP server runs locally, but its tool arguments are chosen by an AI agent
5
+ * that may have read untrusted content (a fetched page, a pasted brief). These
6
+ * helpers keep a steered tool call inside the project and off internal networks.
7
+ */
8
+
9
+ import fs from 'fs'
10
+ import path from 'path'
11
+ import net from 'net'
12
+ import dns from 'dns/promises'
13
+
14
+ /**
15
+ * Resolve a caller-supplied file path and require it to live inside `root`.
16
+ * Relative paths resolve against `root`. Symlinks are followed before the check
17
+ * so a link inside the project cannot point outside it.
18
+ *
19
+ * @returns {string} absolute path inside root
20
+ * @throws {Error} when the path escapes root
21
+ */
22
+ export function resolveInRoot(root, file) {
23
+ const rootReal = fs.realpathSync(root)
24
+ const abs = path.resolve(root, file)
25
+
26
+ // realpath the deepest existing ancestor so not-yet-created files are still checked
27
+ let probe = abs
28
+ while (!fs.existsSync(probe)) {
29
+ const parent = path.dirname(probe)
30
+ if (parent === probe) break
31
+ probe = parent
32
+ }
33
+ const real = path.join(fs.realpathSync(probe), path.relative(probe, abs))
34
+
35
+ const rel = path.relative(rootReal, real)
36
+ if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) {
37
+ throw new Error(`path must be inside the project (${root}): ${file}`)
38
+ }
39
+ return abs
40
+ }
41
+
42
+ /** True for http(s) URLs that point at this machine (localhost / 127.0.0.0/8 / ::1). */
43
+ export function isLoopbackUrl(rawUrl) {
44
+ let u
45
+ try { u = new URL(rawUrl) } catch { return false }
46
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return false
47
+ const host = u.hostname.replace(/^\[|\]$/g, '').toLowerCase()
48
+ return host === 'localhost' || host.endsWith('.localhost') || host === '::1' || /^127\./.test(host)
49
+ }
50
+
51
+ /** True when an IP literal is loopback, private, link-local, CGNAT, multicast or otherwise non-public. */
52
+ export function isPrivateAddress(ip) {
53
+ if (net.isIPv4(ip)) {
54
+ const [a, b] = ip.split('.').map(Number)
55
+ return a === 0 || a === 10 || a === 127 ||
56
+ (a === 100 && b >= 64 && b <= 127) ||
57
+ (a === 169 && b === 254) ||
58
+ (a === 172 && b >= 16 && b <= 31) ||
59
+ (a === 192 && b === 168) ||
60
+ a >= 224
61
+ }
62
+ if (net.isIPv6(ip)) {
63
+ const v = ip.toLowerCase()
64
+ const mapped = v.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/)
65
+ if (mapped) return isPrivateAddress(mapped[1])
66
+ return v === '::' || v === '::1' ||
67
+ /^f[cd]/.test(v) || // fc00::/7 unique local
68
+ /^fe[89ab]/.test(v) || // fe80::/10 link-local
69
+ /^ff/.test(v) // multicast
70
+ }
71
+ return true // not an IP — treat as unsafe
72
+ }
73
+
74
+ /**
75
+ * Require a URL to be http(s) and resolve only to public addresses.
76
+ * Call before every request, including each redirect hop.
77
+ *
78
+ * @throws {Error} when the URL is not http(s) or resolves to a non-public address
79
+ */
80
+ export async function assertPublicUrl(rawUrl) {
81
+ const u = rawUrl instanceof URL ? rawUrl : new URL(rawUrl)
82
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') {
83
+ throw new Error(`only http(s) URLs are allowed (got ${u.protocol})`)
84
+ }
85
+ const host = u.hostname.replace(/^\[|\]$/g, '')
86
+ if (net.isIP(host)) {
87
+ if (isPrivateAddress(host)) throw new Error(`${host} is not a public address`)
88
+ return
89
+ }
90
+ const addrs = await dns.lookup(host, { all: true })
91
+ if (!addrs.length || addrs.some(a => isPrivateAddress(a.address))) {
92
+ throw new Error(`${host} resolves to a non-public address`)
93
+ }
94
+ }
package/src/mcp/server.js CHANGED
@@ -36,9 +36,11 @@ import { z } from 'zod'
36
36
  import path from 'path'
37
37
  import fs from 'fs'
38
38
  import http from 'http'
39
+ import https from 'https'
39
40
  import { spawn, spawnSync } from 'child_process'
40
41
 
41
42
  import { loadPages } from '../cli/discover.js'
43
+ import { resolveInRoot, isLoopbackUrl, assertPublicUrl } from './guards.js'
42
44
  import { validateContent, validateFile, formatValidationResult } from '../cli/validate.js'
43
45
  import { readJournal, formatJournal, resolveEntries, formatResolveResult } from '../cli/diagnose.js'
44
46
  import { writeStamp, formatStampResult } from '../cli/stamp.js'
@@ -821,6 +823,9 @@ server.registerTool(
821
823
  return text('Error: provide only one of content or file, not both')
822
824
  }
823
825
 
826
+ if (file) {
827
+ try { file = resolveInRoot(ROOT, file) } catch (e) { return text(`Error: ${e.message}`) }
828
+ }
824
829
  const result = file ? await validateFile(file, ROOT) : await validateContent(content, ROOT)
825
830
  return text(formatValidationResult(result, { nextSteps: true }))
826
831
  }
@@ -1045,6 +1050,10 @@ The generated file uses @invisibleloop/pulse/testing (renderSync / render). Do N
1045
1050
  },
1046
1051
  },
1047
1052
  async ({ file }) => {
1053
+ try { file = resolveInRoot(ROOT, file) } catch (e) { return text(`Error: ${e.message}`) }
1054
+ if (!file.endsWith('.js') || file.endsWith('.test.js')) {
1055
+ return text('Error: file must be a spec .js file (not a .test.js file)')
1056
+ }
1048
1057
  if (!fs.existsSync(file)) {
1049
1058
  return text(`File not found: ${file}`)
1050
1059
  }
@@ -1207,6 +1216,9 @@ server.registerTool(
1207
1216
  inputSchema: { url: z.string().describe('Full URL, e.g. http://localhost:3000/about') },
1208
1217
  },
1209
1218
  ({ url }) => new Promise(resolve => {
1219
+ if (!isLoopbackUrl(url) || !url.startsWith('http:')) {
1220
+ return resolve(text('Error: pulse_fetch_page only fetches the local dev server (http://localhost:PORT/...)'))
1221
+ }
1210
1222
  const req = http.get(url, { timeout: 10_000 }, res => {
1211
1223
  const chunks = []
1212
1224
  res.on('data', d => chunks.push(d))
@@ -1399,7 +1411,7 @@ any feature build.
1399
1411
 
1400
1412
  let source = content
1401
1413
  if (file) {
1402
- if (!path.isAbsolute(file)) file = path.resolve(process.cwd(), file)
1414
+ try { file = resolveInRoot(ROOT, file) } catch (e) { return text(`Error: ${e.message}`) }
1403
1415
  if (!fs.existsSync(file)) return text(`File not found: ${file}`)
1404
1416
  source = fs.readFileSync(file, 'utf8')
1405
1417
  }
@@ -1575,12 +1587,15 @@ server.registerTool(
1575
1587
  const inSync = installed === staticAsset
1576
1588
 
1577
1589
  // Fetch latest from npm registry
1578
- const req = http.get('http://registry.npmjs.org/@invisibleloop/pulse/latest', { timeout: 5000 }, res => {
1590
+ const req = https.get('https://registry.npmjs.org/@invisibleloop/pulse/latest', { timeout: 5000 }, res => {
1579
1591
  const chunks = []
1580
1592
  res.on('data', d => chunks.push(d))
1581
1593
  res.on('end', () => {
1582
1594
  let latest = 'unknown'
1583
- try { latest = JSON.parse(Buffer.concat(chunks).toString()).version } catch { /* ignore */ }
1595
+ try {
1596
+ const v = JSON.parse(Buffer.concat(chunks).toString()).version
1597
+ if (/^\d+\.\d+\.\d+(-[\w.]+)?$/.test(v)) latest = v // never echo an unvalidated string
1598
+ } catch { /* ignore */ }
1584
1599
 
1585
1600
  const lines = [
1586
1601
  `Installed package : v${installed}`,
@@ -3351,9 +3366,11 @@ Call it with the external URLs you plan to use, before your first Lighthouse run
3351
3366
  let blocked = null
3352
3367
  try {
3353
3368
  for (let hop = 0; hop < MAX_HOPS; hop++) {
3369
+ await assertPublicUrl(url) // every hop: block internal/metadata addresses
3354
3370
  const res = await fetch(url, { method: 'HEAD', redirect: 'manual', signal: AbortSignal.timeout(5000) })
3355
3371
  if (res.status >= 300 && res.status < 400 && res.headers.get('location')) {
3356
3372
  url = new URL(res.headers.get('location'), url)
3373
+ await assertPublicUrl(url)
3357
3374
  chain.push(url.origin)
3358
3375
  continue
3359
3376
  }
@@ -3362,6 +3379,7 @@ Call it with the external URLs you plan to use, before your first Lighthouse run
3362
3379
  const get = await fetch(url, { method: 'GET', redirect: 'manual', headers: { Range: 'bytes=0-0' }, signal: AbortSignal.timeout(5000) })
3363
3380
  if (get.status >= 300 && get.status < 400 && get.headers.get('location')) {
3364
3381
  url = new URL(get.headers.get('location'), url)
3382
+ await assertPublicUrl(url)
3365
3383
  chain.push(url.origin)
3366
3384
  continue
3367
3385
  }
@@ -3413,6 +3431,7 @@ Run this immediately after writing a theme file — before production build and
3413
3431
  let source = css || ''
3414
3432
 
3415
3433
  if (!source && file) {
3434
+ try { file = resolveInRoot(ROOT, file) } catch (e) { return text(`Error: ${e.message}`) }
3416
3435
  if (!fs.existsSync(file)) return text(`File not found: ${file}`)
3417
3436
  source = fs.readFileSync(file, 'utf8')
3418
3437
  }
@@ -148,7 +148,7 @@ export function renderToStream(spec, ctx = {}, nonce = '') {
148
148
  const mergedAllState = applyCtxExtras(mergeStoreKeys(spec, allServerState, ctx.store), ctx)
149
149
  if (spec.hydrate && Object.keys(mergedAllState).length > 0) {
150
150
  controller.enqueue(encode(
151
- `<script nonce="${nonce}">window.__PULSE_SERVER__ = ${JSON.stringify(mergedAllState)};</script>`
151
+ `<script nonce="${nonce}">window.__PULSE_SERVER__ = ${safeJson(mergedAllState)};</script>`
152
152
  ))
153
153
  }
154
154
 
@@ -275,7 +275,7 @@ export function buildStoreScript(spec, storeState, nonce, livePath = '') {
275
275
  // mount() connects only when the page actually subscribes to store keys.
276
276
  const liveFlag = livePath ? `window.__PULSE_LIVE__='${livePath}';` : ''
277
277
  if (storeState && Object.keys(storeState).length > 0) {
278
- return `<script nonce="${nonce}">window.__PULSE_NONCE__='${nonce}';${liveFlag}window.__PULSE_STORE__=${JSON.stringify(storeState)};window.__updatePulseStore__=function(s){window.__PULSE_STORE__=Object.assign(window.__PULSE_STORE__||{},s);};</script>`
278
+ return `<script nonce="${nonce}">window.__PULSE_NONCE__='${nonce}';${liveFlag}window.__PULSE_STORE__=${safeJson(storeState)};window.__updatePulseStore__=function(s){window.__PULSE_STORE__=Object.assign(window.__PULSE_STORE__||{},s);};</script>`
279
279
  }
280
280
  return `<script nonce="${nonce}">window.__PULSE_NONCE__='${nonce}';${liveFlag}</script>`
281
281
  }
@@ -373,7 +373,7 @@ export function wrapDocument({ content, spec = {}, serverState = {}, storeState
373
373
  const deferredStyleLinks = (meta.deferredStyles || []).length > 0
374
374
  ? `<script nonce="${nonce}">(function(){${
375
375
  (meta.deferredStyles || []).map(href =>
376
- `var l=document.createElement('link');l.rel='stylesheet';l.href='${esc(href)}';document.head.appendChild(l);`
376
+ `var l=document.createElement('link');l.rel='stylesheet';l.href=${safeJson(href)};document.head.appendChild(l);`
377
377
  ).join('')
378
378
  }})();</script>`
379
379
  : ''
@@ -383,14 +383,14 @@ export function wrapDocument({ content, spec = {}, serverState = {}, storeState
383
383
  .join('\n ')
384
384
 
385
385
  const schemaScript = meta.schema
386
- ? `<script type="application/ld+json">${JSON.stringify(meta.schema)}</script>`
386
+ ? `<script type="application/ld+json">${safeJson(meta.schema)}</script>`
387
387
  : ''
388
388
 
389
389
  // Serialise server state into the page so the client runtime can read it
390
390
  // without making a second request. Only needed when the page is hydrated —
391
391
  // purely server-rendered pages have no JS to read it.
392
392
  const serverStateScript = spec.hydrate && Object.keys(serverState).length > 0
393
- ? `<script nonce="${nonce}">window.__PULSE_SERVER__ = ${JSON.stringify(serverState)};</script>`
393
+ ? `<script nonce="${nonce}">window.__PULSE_SERVER__ = ${safeJson(serverState)};</script>`
394
394
  : ''
395
395
 
396
396
  const storeStateScript = buildStoreScript(spec, storeState, nonce, livePath)
@@ -677,6 +677,22 @@ function isBundle(hydratePath) {
677
677
  return typeof hydratePath === 'string' && hydratePath.startsWith('/dist/')
678
678
  }
679
679
 
680
+ /**
681
+ * JSON.stringify for embedding inside an inline <script>. Plain JSON.stringify
682
+ * leaves `<`, `>` and `&` alone, so a value containing "</script>" (or "<!--")
683
+ * ends the script element early and the rest is parsed as HTML. Escaping those
684
+ * as \uXXXX keeps the output valid JS/JSON with the same parsed value. U+2028/9
685
+ * are escaped too (line terminators in JS string literals before ES2019).
686
+ */
687
+ export function safeJson(value) {
688
+ return JSON.stringify(value)
689
+ ?.replace(/</g, '\\u003c')
690
+ .replace(/>/g, '\\u003e')
691
+ .replace(/&/g, '\\u0026')
692
+ .replace(/\u2028/g, '\\u2028')
693
+ .replace(/\u2029/g, '\\u2029')
694
+ }
695
+
680
696
  /**
681
697
  * Escape HTML special characters for safe attribute insertion.
682
698
  */
@@ -21,7 +21,7 @@ import { createRequire } from 'module'
21
21
 
22
22
  const _require = createRequire(import.meta.url)
23
23
  export const version = _require('../../package.json').version
24
- import { renderToString, renderToStream, renderToNavStream, wrapDocument, resolveServerState, buildStoreScript, buildHydrateScript, buildStylePreconnects } from '../runtime/ssr.js'
24
+ import { renderToString, renderToStream, renderToNavStream, wrapDocument, resolveServerState, buildStoreScript, buildHydrateScript, buildStylePreconnects, safeJson } from '../runtime/ssr.js'
25
25
  import { validateSpec } from '../spec/schema.js'
26
26
  import { validateStore, resolveStoreState } from '../store/index.js'
27
27
 
@@ -1422,7 +1422,7 @@ async function handleStreamResponse(spec, ctx, req, res, extraBody = '', dev = f
1422
1422
  ).join('')
1423
1423
  }})();</script>`
1424
1424
  : '',
1425
- meta.schema ? ` <script type="application/ld+json">${JSON.stringify(meta.schema)}</script>` : '',
1425
+ meta.schema ? ` <script type="application/ld+json">${safeJson(meta.schema)}</script>` : '',
1426
1426
  ].filter(Boolean).join('\n')
1427
1427
 
1428
1428
  const bodyAttr = [
@@ -1,147 +0,0 @@
1
- /**
2
- * CSS purge/minify tests — run with: node scripts/css-purge.test.js
3
- *
4
- * Regression focus: @import URLs containing `;` and `&` in their query string
5
- * (Google Fonts) were truncated by the production build — the block parser
6
- * split the statement at the `;` inside the quoted URL, silently breaking the
7
- * theme in production while dev (which serves CSS unprocessed) looked fine.
8
- */
9
-
10
- import { test } from 'node:test'
11
- import assert from 'node:assert/strict'
12
- import { parseCssBlocks, purgeCss, minifyCss, extractUsedClasses, selectorUsed } from './css-purge.js'
13
-
14
- const FONT_IMPORT = `@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap');`
15
-
16
- // ---------------------------------------------------------------------------
17
- // The @import regression
18
- // ---------------------------------------------------------------------------
19
-
20
- test('parseCssBlocks: does not split @import at a ; inside a quoted URL', () => {
21
- const css = `${FONT_IMPORT}\n:root { --accent: #e25; }`
22
- const blocks = parseCssBlocks(css)
23
- assert.equal(blocks.length, 2, `Expected 2 blocks, got ${blocks.length}: ${JSON.stringify(blocks.map(b => b.raw))}`)
24
- assert.equal(blocks[0].type, 'at-simple')
25
- assert.equal(blocks[0].raw, FONT_IMPORT)
26
- })
27
-
28
- test('parseCssBlocks: handles double-quoted URLs and escapes', () => {
29
- const css = `@import url("https://example.com/a;b&c=d");\n.x { color: red; }`
30
- const blocks = parseCssBlocks(css)
31
- assert.equal(blocks[0].raw, `@import url("https://example.com/a;b&c=d");`)
32
- })
33
-
34
- test('purgeCss + minifyCss: Google Fonts @import survives the full pipeline intact', () => {
35
- const css = `${FONT_IMPORT}\n:root { --font: 'Inter', sans-serif; }\n.unused { color: red; }`
36
- const out = minifyCss(purgeCss(css, new Set([])))
37
- assert.ok(
38
- out.includes(`url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap')`),
39
- `@import URL was mangled by the build pipeline. Output: ${out}`
40
- )
41
- })
42
-
43
- test('minifyCss: quoted strings are untouched by punctuation collapsing', () => {
44
- const css = `.x { content: "a ; b : c , d" ; }`
45
- const out = minifyCss(css)
46
- assert.ok(out.includes(`"a ; b : c , d"`), `Quoted string was altered: ${out}`)
47
- })
48
-
49
- // ---------------------------------------------------------------------------
50
- // Behaviour preserved through the extraction refactor
51
- // ---------------------------------------------------------------------------
52
-
53
- test('purgeCss: drops unused class rules, keeps used ones', () => {
54
- const css = `.used { color: red; }\n.unused { color: blue; }`
55
- const out = purgeCss(css, new Set(['used']))
56
- assert.ok(out.includes('.used'))
57
- assert.ok(!out.includes('.unused'))
58
- })
59
-
60
- test('purgeCss: element and :root rules are always kept', () => {
61
- const css = `:root { --x: 1; }\nbody { margin: 0; }`
62
- const out = purgeCss(css, new Set([]))
63
- assert.ok(out.includes(':root'))
64
- assert.ok(out.includes('body'))
65
- })
66
-
67
- test('purgeCss: recurses into @media and keeps @font-face', () => {
68
- const css = `@media (min-width: 600px) { .used { color: red; } .unused { color: blue; } }\n@font-face { font-family: X; src: url('/x.woff2'); }`
69
- const out = purgeCss(css, new Set(['used']))
70
- assert.ok(out.includes('.used'))
71
- assert.ok(!out.includes('.unused'))
72
- assert.ok(out.includes('@font-face'))
73
- })
74
-
75
- test('selectorUsed: rightmost class is the subject', () => {
76
- assert.equal(selectorUsed('.nav .link', new Set(['link'])), true)
77
- assert.equal(selectorUsed('.nav .link', new Set(['nav'])), false)
78
- assert.equal(selectorUsed('.btn:hover', new Set(['btn'])), true)
79
- })
80
-
81
- test('extractUsedClasses: finds class attributes and hyphenated JS strings', () => {
82
- const used = extractUsedClasses(
83
- ['<div class="hero card">'],
84
- [`el.classList.add('ui-btn--active')`]
85
- )
86
- assert.ok(used.has('hero'))
87
- assert.ok(used.has('card'))
88
- assert.ok(used.has('ui-btn--active'))
89
- })
90
-
91
- test('minifyCss: calc() expressions keep their operator spacing', () => {
92
- const out = minifyCss(`.x { width: calc(100% - 2rem); }`)
93
- assert.ok(out.includes('calc(100% - 2rem)'), `calc broken: ${out}`)
94
- })
95
-
96
- // ---------------------------------------------------------------------------
97
- // Runtime state classes — classList arguments
98
- // ---------------------------------------------------------------------------
99
- // Regression: the JS string scan only keeps hyphenated names (to avoid keeping
100
- // every English word), so runtime state classes like classList.add('open') were
101
- // never collected — and selectorUsed checks the rightmost class token, so
102
- // `.docs-sidebar.open { … }` was purged from production CSS. The mobile docs
103
- // hamburger toggled the class, but the rule that slides the sidebar in was gone.
104
-
105
- test('classList method arguments are collected regardless of hyphens', () => {
106
- const used = extractUsedClasses([], [
107
- `sidebar.classList.add('open')`,
108
- `overlay.classList.remove("visible")`,
109
- `el.classList.toggle('active', isOn)`,
110
- `el.classList.replace('open', 'closed')`,
111
- ])
112
- for (const cls of ['open', 'visible', 'active', 'closed']) {
113
- assert.ok(used.has(cls), `Expected classList arg "${cls}" to be collected`)
114
- }
115
- })
116
-
117
- test('multiple classList arguments in one call are all collected', () => {
118
- const used = extractUsedClasses([], [`el.classList.add('one', 'two-x', 'three')`])
119
- for (const cls of ['one', 'two-x', 'three']) assert.ok(used.has(cls), cls)
120
- })
121
-
122
- // Regression: class name immediately followed by ${ (no space before the expression)
123
- // was not extracted — "saved-palette${...}" yielded "saved-palette${expanded" as a
124
- // single token, so the rule was silently purged in prod while dev (no purging) looked fine.
125
- test('class name immediately before ${ is extracted correctly', () => {
126
- const js = [
127
- // No space before ${ — the broken pattern
128
- `const v = \`<div class="saved-palette\${expanded ? ' saved-palette--expanded' : ''}"></div>\``,
129
- // Multiple static classes, last one butts into ${
130
- `const v = \`<button class="btn btn--primary\${loading ? ' btn--loading' : ''}"></button>\``,
131
- ]
132
- const used = extractUsedClasses([], js)
133
- for (const cls of ['saved-palette', 'btn', 'btn--primary']) {
134
- assert.ok(used.has(cls), `Expected "${cls}" to be extracted but it was missing`)
135
- }
136
- })
137
-
138
- test('runtime-toggled compound rule survives the purge', () => {
139
- const css = `.docs-sidebar { transform: translateX(-100%); }\n.docs-sidebar.open { transform: translateX(0); }\n.unused { color: red; }`
140
- const used = extractUsedClasses(
141
- [`<aside class="docs-sidebar">`],
142
- [`document.querySelector('.docs-sidebar').classList.add('open')`]
143
- )
144
- const out = purgeCss(css, used)
145
- assert.ok(out.includes('.docs-sidebar.open'), `Compound state rule was purged: ${out}`)
146
- assert.ok(!out.includes('.unused'), 'Unused rule should still be purged')
147
- })