@invisibleloop/pulse 0.17.1 → 0.17.3

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.
Files changed (43) hide show
  1. package/.claude/commands/build-page.md +1 -1
  2. package/.claude/commands/verify.md +6 -4
  3. package/.claude/pulse-checklist.md +10 -0
  4. package/CLAUDE.md +56 -2
  5. package/docs/.claude/pulse-checklist.md +10 -0
  6. package/docs/public/pulse-ui.css +8 -2
  7. package/docs/server.js +2 -0
  8. package/docs/src/lib/nav.js +2 -0
  9. package/docs/src/pages/actions.js +2 -0
  10. package/docs/src/pages/error-pages.js +98 -0
  11. package/docs/src/pages/forms.js +128 -0
  12. package/docs/src/pages/guard.js +1 -0
  13. package/docs/src/pages/routing.js +7 -9
  14. package/docs/src/pages/server-api.js +2 -1
  15. package/examples/.claude/pulse-checklist.md +10 -0
  16. package/examples/public/pulse-ui.css +52 -2
  17. package/package.json +2 -2
  18. package/public/.pulse-ui-version +1 -1
  19. package/public/pulse-ui.css +8 -2
  20. package/scripts/build.js +1 -204
  21. package/scripts/css-purge.js +241 -0
  22. package/scripts/css-purge.test.js +94 -0
  23. package/src/agent/checklist.md +10 -0
  24. package/src/agent/commands/verify.md +6 -4
  25. package/src/agent/guide-routing.md +21 -0
  26. package/src/agent/guide-server.md +34 -0
  27. package/src/agent/guide-spec.md +25 -0
  28. package/src/agent/guide-styles.md +3 -1
  29. package/src/agent/skills/build-page/SKILL.md +1 -1
  30. package/src/agent/skills/verify/SKILL.md +2 -2
  31. package/src/agent/workflow.md +15 -3
  32. package/src/mcp/mcp.test.js +55 -1
  33. package/src/mcp/server.js +82 -5
  34. package/src/runtime/ssr.js +30 -7
  35. package/src/server/index.js +119 -16
  36. package/src/server/server.test.js +200 -0
  37. package/src/spec/schema.js +20 -3
  38. package/src/spec/schema.test.js +33 -0
  39. package/src/ui/drop-cap.js +1 -1
  40. package/src/ui/gallery.js +5 -1
  41. package/src/ui/ui.test.js +34 -0
  42. package/types/schema.d.ts +31 -2
  43. package/types/server.d.ts +7 -0
@@ -150,6 +150,20 @@ html, body {
150
150
  color: var(--ui-text);
151
151
  }
152
152
 
153
+ /* ghost-light — use on dark section backgrounds (e.g. navy hero, section--dark).
154
+ Renders with light/white text and a subtle light border so it remains legible
155
+ regardless of whether the surrounding theme is light or dark. */
156
+ .ui-btn--ghost-light {
157
+ background: transparent;
158
+ color: rgba(255, 255, 255, 0.9);
159
+ border: 1px solid rgba(255, 255, 255, 0.35);
160
+ }
161
+ .ui-btn--ghost-light:hover:not(.ui-btn--disabled):not([disabled]) {
162
+ background: rgba(255, 255, 255, 0.1);
163
+ border-color: rgba(255, 255, 255, 0.65);
164
+ color: #ffffff;
165
+ }
166
+
153
167
  .ui-btn--danger {
154
168
  background: var(--ui-red-dim);
155
169
  color: var(--ui-red);
@@ -682,6 +696,36 @@ html, body {
682
696
  .ui-section--alt { background: var(--ui-surface, var(--surface, #111116)); }
683
697
  .ui-section--dark { background: var(--ui-surface-2, var(--surface-2, #18181f)); }
684
698
 
699
+ /* In light theme, --ui-surface-2 resolves to a light grey — not "dark".
700
+ Override so section--dark always renders a genuinely dark background that
701
+ contrasts with the page, regardless of the active theme. */
702
+ [data-theme="light"] .ui-section--dark,
703
+ .ui-theme-light .ui-section--dark {
704
+ background: var(--ui-dark-bg, #1a1a2e);
705
+ color: var(--ui-dark-text, #e2e2ea);
706
+ }
707
+ /* Ensure text children inherit the light colour in the dark section */
708
+ [data-theme="light"] .ui-section--dark .ui-section-title,
709
+ [data-theme="light"] .ui-section--dark .ui-section-eyebrow,
710
+ [data-theme="light"] .ui-section--dark .ui-section-subtitle,
711
+ .ui-theme-light .ui-section--dark .ui-section-title,
712
+ .ui-theme-light .ui-section--dark .ui-section-eyebrow,
713
+ .ui-theme-light .ui-section--dark .ui-section-subtitle {
714
+ color: inherit;
715
+ }
716
+ /* Ghost button inside a dark section on a light-theme page */
717
+ [data-theme="light"] .ui-section--dark .ui-btn--ghost,
718
+ .ui-theme-light .ui-section--dark .ui-btn--ghost {
719
+ color: var(--ui-dark-text, #e2e2ea);
720
+ border-color: rgba(226, 226, 234, 0.35);
721
+ }
722
+ [data-theme="light"] .ui-section--dark .ui-btn--ghost:hover:not(.ui-btn--disabled):not([disabled]),
723
+ .ui-theme-light .ui-section--dark .ui-btn--ghost:hover:not(.ui-btn--disabled):not([disabled]) {
724
+ background: rgba(226, 226, 234, 0.1);
725
+ border-color: rgba(226, 226, 234, 0.65);
726
+ color: var(--ui-dark-text, #e2e2ea);
727
+ }
728
+
685
729
  /* diagonal — tapered bottom edge; follow with a section that starts flush */
686
730
  .ui-section--diagonal {
687
731
  background: var(--ui-surface, var(--surface, #111116));
@@ -3987,8 +4031,14 @@ fieldset[disabled] .ui-rating-stars .ui-rating-star:hover ~ .ui-rating-star { co
3987
4031
  }
3988
4032
 
3989
4033
  .ui-drop-cap-letter {
3990
- font-weight: 700;
3991
- color: var(--ui-text);
4034
+ /* line-height ~cap height keeps the float box hugging the glyph so the cap
4035
+ top-aligns with the first text line — a relative line-height makes serif
4036
+ caps float below the opening line. Component sets font-size inline. */
4037
+ float: left;
4038
+ font-weight: 700;
4039
+ line-height: 0.82;
4040
+ margin-right: 0.1em;
4041
+ color: var(--ui-text);
3992
4042
  }
3993
4043
 
3994
4044
  /* Colophon — minimal footer for editorial pages */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invisibleloop/pulse",
3
- "version": "0.17.1",
3
+ "version": "0.17.3",
4
4
  "type": "module",
5
5
  "description": "AI-first frontend framework. The spec is the source of truth.",
6
6
  "license": "MIT",
@@ -57,7 +57,7 @@
57
57
  "scripts": {
58
58
  "dev": "node --watch src/cli/dev.js",
59
59
  "build": "node scripts/build.js",
60
- "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/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 src/mcp/mcp.test.js",
60
+ "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/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",
61
61
  "typecheck": "tsc --noEmit",
62
62
  "docs": "node --watch src/cli/dev.js --root docs --port 4000",
63
63
  "docs:start": "node docs/server.js"
@@ -1 +1 @@
1
- 0.17.1
1
+ 0.17.3
@@ -4031,8 +4031,14 @@ fieldset[disabled] .ui-rating-stars .ui-rating-star:hover ~ .ui-rating-star { co
4031
4031
  }
4032
4032
 
4033
4033
  .ui-drop-cap-letter {
4034
- font-weight: 700;
4035
- color: var(--ui-text);
4034
+ /* line-height ~cap height keeps the float box hugging the glyph so the cap
4035
+ top-aligns with the first text line — a relative line-height makes serif
4036
+ caps float below the opening line. Component sets font-size inline. */
4037
+ float: left;
4038
+ font-weight: 700;
4039
+ line-height: 0.82;
4040
+ margin-right: 0.1em;
4041
+ color: var(--ui-text);
4036
4042
  }
4037
4043
 
4038
4044
  /* Colophon — minimal footer for editorial pages */
package/scripts/build.js CHANGED
@@ -20,6 +20,7 @@ import { createHash } from 'crypto'
20
20
  import { discoverPages } from '../src/cli/discover.js'
21
21
  import { renderToString } from '../src/runtime/ssr.js'
22
22
  import { c, header, table, ok, fail, info, warn, elapsed, icon } from '../src/cli/fmt.js'
23
+ import { extractUsedClasses, purgeCss, minifyCss } from './css-purge.js'
23
24
 
24
25
  // ---------------------------------------------------------------------------
25
26
  // Server-only property stripping
@@ -783,207 +784,3 @@ async function purgeCssStep(pages, manifest, root, outDir) {
783
784
  fs.writeFileSync(path.join(outDir, 'manifest.json'), JSON.stringify(manifest, null, 2))
784
785
  return cssStats
785
786
  }
786
-
787
- // ---------------------------------------------------------------------------
788
- // CSS class extractor
789
- // ---------------------------------------------------------------------------
790
-
791
- function extractUsedClasses(htmlContents, jsContents, safelist = []) {
792
- const used = new Set()
793
-
794
- // Extract from class="..." in both rendered HTML and JS template literals
795
- for (const content of [...htmlContents, ...jsContents]) {
796
- const re = /class(?:Name)?=["']([^"'\n]+)["']/g
797
- let m
798
- while ((m = re.exec(content)) !== null) {
799
- for (const cls of m[1].trim().split(/\s+/)) {
800
- if (cls) used.add(cls)
801
- }
802
- }
803
- }
804
-
805
- // Scan JS source for quoted strings that look like CSS class names (contain hyphens)
806
- // Catches conditionally-applied classes like 'ui-btn--disabled' in ternaries
807
- for (const js of jsContents) {
808
- const re = /['"`]((?:[a-zA-Z][a-zA-Z0-9]*-[a-zA-Z0-9-]*)(?:\s+[a-zA-Z][a-zA-Z0-9-]*)*)['"`]/g
809
- let m
810
- while ((m = re.exec(js)) !== null) {
811
- for (const cls of m[1].trim().split(/\s+/)) {
812
- if (cls && cls.includes('-')) used.add(cls)
813
- }
814
- }
815
- }
816
-
817
- // Apply safelist from pulse.config.js — css.safelist accepts strings or RegExp.
818
- // String entries are added directly; RegExp entries are matched against all
819
- // class names defined in the CSS (resolved later in purgeCss via selectorUsed
820
- // because we don't have the CSS text here). To handle RegExp, we mark them as
821
- // a sentinel that selectorUsed checks separately.
822
- for (const entry of safelist) {
823
- if (typeof entry === 'string') {
824
- used.add(entry)
825
- }
826
- // RegExp entries are stored on the Set as a symbol property for selectorUsed to check
827
- }
828
- if (safelist.some(e => e instanceof RegExp)) {
829
- used._safelistPatterns = safelist.filter(e => e instanceof RegExp)
830
- }
831
-
832
- return used
833
- }
834
-
835
- // ---------------------------------------------------------------------------
836
- // CSS purger — strips unused rules, preserves @media / :root / element rules
837
- // ---------------------------------------------------------------------------
838
-
839
- /**
840
- * Parse CSS text into top-level blocks using a bracket-depth counter.
841
- * Returns [{ type, selector?, prelude?, inner?, raw }]
842
- */
843
- function parseCssBlocks(css) {
844
- const blocks = []
845
- let i = 0
846
- const len = css.length
847
-
848
- while (i < len) {
849
- // Skip whitespace
850
- while (i < len && /\s/.test(css[i])) i++
851
- if (i >= len) break
852
-
853
- // Skip block comments
854
- if (css[i] === '/' && css[i + 1] === '*') {
855
- const end = css.indexOf('*/', i + 2)
856
- i = end === -1 ? len : end + 2
857
- continue
858
- }
859
-
860
- const start = i
861
- let depth = 0
862
-
863
- while (i < len) {
864
- // Skip inline comments
865
- if (css[i] === '/' && css[i + 1] === '*') {
866
- const end = css.indexOf('*/', i + 2)
867
- i = end === -1 ? len : end + 2
868
- continue
869
- }
870
- if (css[i] === '{') { depth++; i++; continue }
871
- if (css[i] === '}') { depth--; i++; if (depth === 0) break; continue }
872
- if (css[i] === ';' && depth === 0) { i++; break }
873
- i++
874
- }
875
-
876
- const raw = css.slice(start, i).trim()
877
- if (!raw) continue
878
-
879
- if (raw.startsWith('@')) {
880
- const braceIdx = raw.indexOf('{')
881
- if (braceIdx === -1) {
882
- blocks.push({ type: 'at-simple', raw })
883
- } else {
884
- const prelude = raw.slice(0, braceIdx).trim()
885
- const inner = raw.slice(braceIdx + 1, raw.lastIndexOf('}')).trim()
886
- blocks.push({ type: 'at-block', prelude, inner, raw })
887
- }
888
- } else {
889
- const braceIdx = raw.indexOf('{')
890
- if (braceIdx !== -1) {
891
- blocks.push({ type: 'rule', selector: raw.slice(0, braceIdx).trim(), raw })
892
- }
893
- }
894
- }
895
-
896
- return blocks
897
- }
898
-
899
- /**
900
- * Return true if any selector in the rule should be kept.
901
- *
902
- * Matching strategy: check the *rightmost* class token in the selector (the
903
- * subject of the rule). A rule like `.parent .child` should survive only when
904
- * `.child` is used — keeping it whenever `.parent` is used would retain dead
905
- * rules whose actual target class was never referenced.
906
- *
907
- * Pseudo-classes/elements and attribute selectors are stripped before checking
908
- * so `.btn:hover` is treated as `.btn`, `.btn--active::before` as `.btn--active`.
909
- *
910
- * Element-only selectors (`a`, `*`, `:root`) and rules that contain no class
911
- * tokens at all are always kept.
912
- */
913
- function selectorUsed(selector, usedClasses) {
914
- const selectors = selector.split(',').map(s => s.trim())
915
-
916
- for (const sel of selectors) {
917
- // Strip pseudo-classes/elements and attribute selectors to find the base
918
- const base = sel
919
- .replace(/::?[a-z-]+(\([^)]*\))?/gi, '')
920
- .replace(/\[[^\]]*\]/g, '')
921
- .trim()
922
-
923
- // Keep element-only selectors — no class tokens present
924
- if (!base.includes('.')) return true
925
-
926
- // Extract all class tokens and check the rightmost one (the rule subject).
927
- // Using the rightmost class correctly handles descendant selectors:
928
- // .nav .link → subject is .link
929
- // .btn--active → subject is .btn--active
930
- const classes = [...base.matchAll(/\.([a-zA-Z][a-zA-Z0-9_-]*)/g)].map(m => m[1])
931
- if (classes.length === 0) return true
932
- const subject = classes[classes.length - 1]
933
- if (usedClasses.has(subject)) return true
934
-
935
- // Check RegExp safelist patterns if any were provided
936
- const patterns = usedClasses._safelistPatterns
937
- if (patterns && patterns.some(re => re.test(subject))) return true
938
- }
939
-
940
- return false
941
- }
942
-
943
- /**
944
- * Purge unused CSS rules from a CSS string.
945
- * Recursively processes @media and @supports blocks.
946
- */
947
- function minifyCss(css) {
948
- return css
949
- .replace(/\/\*[\s\S]*?\*\//g, '') // strip comments
950
- .replace(/\s+/g, ' ') // collapse whitespace
951
- // Remove spaces around safe punctuation — NOT + or - (would break calc() expressions)
952
- .replace(/\s*([{}:;,>~])\s*/g, '$1')
953
- .replace(/;}/g, '}') // remove trailing semicolons
954
- .trim()
955
- }
956
-
957
- function purgeCss(cssText, usedClasses) {
958
- const blocks = parseCssBlocks(cssText)
959
- const kept = []
960
-
961
- for (const block of blocks) {
962
- if (block.type === 'at-simple') {
963
- kept.push(block.raw)
964
- } else if (block.type === 'at-block') {
965
- const p = block.prelude.toLowerCase()
966
- if (/^@(?:keyframes|font-face|charset)/.test(p)) {
967
- kept.push(block.raw)
968
- } else if (/^@(?:media|supports)/.test(p)) {
969
- const innerPurged = purgeCss(block.inner, usedClasses)
970
- if (innerPurged.trim()) kept.push(`${block.prelude} {\n${innerPurged}\n}`)
971
- } else if (/^@layer/.test(p)) {
972
- // Always preserve empty @layer declarations — they establish cascade order
973
- // and removing them silently changes specificity even when no rules remain.
974
- const innerPurged = purgeCss(block.inner, usedClasses)
975
- if (innerPurged.trim()) {
976
- kept.push(`${block.prelude} {\n${innerPurged}\n}`)
977
- } else {
978
- kept.push(`${block.prelude} {}`)
979
- }
980
- } else {
981
- kept.push(block.raw) // unknown at-blocks — keep to be safe
982
- }
983
- } else if (block.type === 'rule') {
984
- if (selectorUsed(block.selector, usedClasses)) kept.push(block.raw)
985
- }
986
- }
987
-
988
- return kept.join('\n\n')
989
- }
@@ -0,0 +1,241 @@
1
+ /**
2
+ * CSS purge + minify — used by scripts/build.js.
3
+ *
4
+ * Extracted into its own module so the parser can be unit-tested
5
+ * (build.js runs the build at import time and cannot be imported by tests).
6
+ */
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // CSS class extractor
10
+ // ---------------------------------------------------------------------------
11
+
12
+ export function extractUsedClasses(htmlContents, jsContents, safelist = []) {
13
+ const used = new Set()
14
+
15
+ // Extract from class="..." in both rendered HTML and JS template literals
16
+ for (const content of [...htmlContents, ...jsContents]) {
17
+ const re = /class(?:Name)?=["']([^"'\n]+)["']/g
18
+ let m
19
+ while ((m = re.exec(content)) !== null) {
20
+ for (const cls of m[1].trim().split(/\s+/)) {
21
+ if (cls) used.add(cls)
22
+ }
23
+ }
24
+ }
25
+
26
+ // Scan JS source for quoted strings that look like CSS class names (contain hyphens)
27
+ // Catches conditionally-applied classes like 'ui-btn--disabled' in ternaries
28
+ for (const js of jsContents) {
29
+ const re = /['"`]((?:[a-zA-Z][a-zA-Z0-9]*-[a-zA-Z0-9-]*)(?:\s+[a-zA-Z][a-zA-Z0-9-]*)*)['"`]/g
30
+ let m
31
+ while ((m = re.exec(js)) !== null) {
32
+ for (const cls of m[1].trim().split(/\s+/)) {
33
+ if (cls && cls.includes('-')) used.add(cls)
34
+ }
35
+ }
36
+ }
37
+
38
+ // Apply safelist from pulse.config.js — css.safelist accepts strings or RegExp.
39
+ // String entries are added directly; RegExp entries are matched against all
40
+ // class names defined in the CSS (resolved later in purgeCss via selectorUsed
41
+ // because we don't have the CSS text here). To handle RegExp, we mark them as
42
+ // a sentinel that selectorUsed checks separately.
43
+ for (const entry of safelist) {
44
+ if (typeof entry === 'string') {
45
+ used.add(entry)
46
+ }
47
+ // RegExp entries are stored on the Set as a symbol property for selectorUsed to check
48
+ }
49
+ if (safelist.some(e => e instanceof RegExp)) {
50
+ used._safelistPatterns = safelist.filter(e => e instanceof RegExp)
51
+ }
52
+
53
+ return used
54
+ }
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // CSS purger — strips unused rules, preserves @media / :root / element rules
58
+ // ---------------------------------------------------------------------------
59
+
60
+ /**
61
+ * Parse CSS text into top-level blocks using a bracket-depth counter.
62
+ * Quote-aware: `;` and braces inside '...' or "..." strings are content, not
63
+ * structure — an @import URL like url('...?family=Inter:wght@400;600&display=swap')
64
+ * must not be split at the `;` inside the quotes.
65
+ * Returns [{ type, selector?, prelude?, inner?, raw }]
66
+ */
67
+ export function parseCssBlocks(css) {
68
+ const blocks = []
69
+ let i = 0
70
+ const len = css.length
71
+
72
+ // Advance past a quoted string starting at css[i] (which is ' or ").
73
+ // Handles backslash escapes. Returns the index just past the closing quote.
74
+ const skipString = (idx) => {
75
+ const quote = css[idx]
76
+ idx++
77
+ while (idx < len && css[idx] !== quote) {
78
+ if (css[idx] === '\\') idx++
79
+ idx++
80
+ }
81
+ return idx + 1 // past closing quote (or len)
82
+ }
83
+
84
+ while (i < len) {
85
+ // Skip whitespace
86
+ while (i < len && /\s/.test(css[i])) i++
87
+ if (i >= len) break
88
+
89
+ // Skip block comments
90
+ if (css[i] === '/' && css[i + 1] === '*') {
91
+ const end = css.indexOf('*/', i + 2)
92
+ i = end === -1 ? len : end + 2
93
+ continue
94
+ }
95
+
96
+ const start = i
97
+ let depth = 0
98
+
99
+ while (i < len) {
100
+ // Skip inline comments
101
+ if (css[i] === '/' && css[i + 1] === '*') {
102
+ const end = css.indexOf('*/', i + 2)
103
+ i = end === -1 ? len : end + 2
104
+ continue
105
+ }
106
+ // Skip quoted strings — ; { } inside them are content, not structure
107
+ if (css[i] === '"' || css[i] === "'") { i = skipString(i); continue }
108
+ if (css[i] === '{') { depth++; i++; continue }
109
+ if (css[i] === '}') { depth--; i++; if (depth === 0) break; continue }
110
+ if (css[i] === ';' && depth === 0) { i++; break }
111
+ i++
112
+ }
113
+
114
+ const raw = css.slice(start, i).trim()
115
+ if (!raw) continue
116
+
117
+ if (raw.startsWith('@')) {
118
+ const braceIdx = raw.indexOf('{')
119
+ if (braceIdx === -1) {
120
+ blocks.push({ type: 'at-simple', raw })
121
+ } else {
122
+ const prelude = raw.slice(0, braceIdx).trim()
123
+ const inner = raw.slice(braceIdx + 1, raw.lastIndexOf('}')).trim()
124
+ blocks.push({ type: 'at-block', prelude, inner, raw })
125
+ }
126
+ } else {
127
+ const braceIdx = raw.indexOf('{')
128
+ if (braceIdx !== -1) {
129
+ blocks.push({ type: 'rule', selector: raw.slice(0, braceIdx).trim(), raw })
130
+ }
131
+ }
132
+ }
133
+
134
+ return blocks
135
+ }
136
+
137
+ /**
138
+ * Return true if any selector in the rule should be kept.
139
+ *
140
+ * Matching strategy: check the *rightmost* class token in the selector (the
141
+ * subject of the rule). A rule like `.parent .child` should survive only when
142
+ * `.child` is used — keeping it whenever `.parent` is used would retain dead
143
+ * rules whose actual target class was never referenced.
144
+ *
145
+ * Pseudo-classes/elements and attribute selectors are stripped before checking
146
+ * so `.btn:hover` is treated as `.btn`, `.btn--active::before` as `.btn--active`.
147
+ *
148
+ * Element-only selectors (`a`, `*`, `:root`) and rules that contain no class
149
+ * tokens at all are always kept.
150
+ */
151
+ export function selectorUsed(selector, usedClasses) {
152
+ const selectors = selector.split(',').map(s => s.trim())
153
+
154
+ for (const sel of selectors) {
155
+ // Strip pseudo-classes/elements and attribute selectors to find the base
156
+ const base = sel
157
+ .replace(/::?[a-z-]+(\([^)]*\))?/gi, '')
158
+ .replace(/\[[^\]]*\]/g, '')
159
+ .trim()
160
+
161
+ // Keep element-only selectors — no class tokens present
162
+ if (!base.includes('.')) return true
163
+
164
+ // Extract all class tokens and check the rightmost one (the rule subject).
165
+ // Using the rightmost class correctly handles descendant selectors:
166
+ // .nav .link → subject is .link
167
+ // .btn--active → subject is .btn--active
168
+ const classes = [...base.matchAll(/\.([a-zA-Z][a-zA-Z0-9_-]*)/g)].map(m => m[1])
169
+ if (classes.length === 0) return true
170
+ const subject = classes[classes.length - 1]
171
+ if (usedClasses.has(subject)) return true
172
+
173
+ // Check RegExp safelist patterns if any were provided
174
+ const patterns = usedClasses._safelistPatterns
175
+ if (patterns && patterns.some(re => re.test(subject))) return true
176
+ }
177
+
178
+ return false
179
+ }
180
+
181
+ /**
182
+ * Minify a CSS string. String-aware: content inside '...' or "..." is left
183
+ * untouched so quoted URLs (e.g. Google Fonts @import with ; and : in the
184
+ * query string) survive intact.
185
+ */
186
+ export function minifyCss(css) {
187
+ // Protect quoted strings from the whitespace/punctuation passes
188
+ const strings = []
189
+ const protectedCss = css.replace(/(['"])(?:\\.|(?!\1).)*\1/g, (m) => {
190
+ strings.push(m)
191
+ return `\x00S${strings.length - 1}\x00`
192
+ })
193
+
194
+ const minified = protectedCss
195
+ .replace(/\/\*[\s\S]*?\*\//g, '') // strip comments
196
+ .replace(/\s+/g, ' ') // collapse whitespace
197
+ // Remove spaces around safe punctuation — NOT + or - (would break calc() expressions)
198
+ .replace(/\s*([{}:;,>~])\s*/g, '$1')
199
+ .replace(/;}/g, '}') // remove trailing semicolons
200
+ .trim()
201
+
202
+ return minified.replace(/\x00S(\d+)\x00/g, (_, i) => strings[+i])
203
+ }
204
+
205
+ /**
206
+ * Purge unused CSS rules from a CSS string.
207
+ * Recursively processes @media and @supports blocks.
208
+ */
209
+ export function purgeCss(cssText, usedClasses) {
210
+ const blocks = parseCssBlocks(cssText)
211
+ const kept = []
212
+
213
+ for (const block of blocks) {
214
+ if (block.type === 'at-simple') {
215
+ kept.push(block.raw)
216
+ } else if (block.type === 'at-block') {
217
+ const p = block.prelude.toLowerCase()
218
+ if (/^@(?:keyframes|font-face|charset)/.test(p)) {
219
+ kept.push(block.raw)
220
+ } else if (/^@(?:media|supports)/.test(p)) {
221
+ const innerPurged = purgeCss(block.inner, usedClasses)
222
+ if (innerPurged.trim()) kept.push(`${block.prelude} {\n${innerPurged}\n}`)
223
+ } else if (/^@layer/.test(p)) {
224
+ // Always preserve empty @layer declarations — they establish cascade order
225
+ // and removing them silently changes specificity even when no rules remain.
226
+ const innerPurged = purgeCss(block.inner, usedClasses)
227
+ if (innerPurged.trim()) {
228
+ kept.push(`${block.prelude} {\n${innerPurged}\n}`)
229
+ } else {
230
+ kept.push(`${block.prelude} {}`)
231
+ }
232
+ } else {
233
+ kept.push(block.raw) // unknown at-blocks — keep to be safe
234
+ }
235
+ } else if (block.type === 'rule') {
236
+ if (selectorUsed(block.selector, usedClasses)) kept.push(block.raw)
237
+ }
238
+ }
239
+
240
+ return kept.join('\n\n')
241
+ }
@@ -0,0 +1,94 @@
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
+ })