@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.
- package/.claude/commands/build-page.md +1 -1
- package/.claude/commands/verify.md +6 -4
- package/.claude/pulse-checklist.md +10 -0
- package/CLAUDE.md +56 -2
- package/docs/.claude/pulse-checklist.md +10 -0
- package/docs/public/pulse-ui.css +8 -2
- package/docs/server.js +2 -0
- package/docs/src/lib/nav.js +2 -0
- package/docs/src/pages/actions.js +2 -0
- package/docs/src/pages/error-pages.js +98 -0
- package/docs/src/pages/forms.js +128 -0
- package/docs/src/pages/guard.js +1 -0
- package/docs/src/pages/routing.js +7 -9
- package/docs/src/pages/server-api.js +2 -1
- package/examples/.claude/pulse-checklist.md +10 -0
- package/examples/public/pulse-ui.css +52 -2
- package/package.json +2 -2
- package/public/.pulse-ui-version +1 -1
- package/public/pulse-ui.css +8 -2
- package/scripts/build.js +1 -204
- package/scripts/css-purge.js +241 -0
- package/scripts/css-purge.test.js +94 -0
- package/src/agent/checklist.md +10 -0
- package/src/agent/commands/verify.md +6 -4
- package/src/agent/guide-routing.md +21 -0
- package/src/agent/guide-server.md +34 -0
- package/src/agent/guide-spec.md +25 -0
- package/src/agent/guide-styles.md +3 -1
- package/src/agent/skills/build-page/SKILL.md +1 -1
- package/src/agent/skills/verify/SKILL.md +2 -2
- package/src/agent/workflow.md +15 -3
- package/src/mcp/mcp.test.js +55 -1
- package/src/mcp/server.js +82 -5
- package/src/runtime/ssr.js +30 -7
- package/src/server/index.js +119 -16
- package/src/server/server.test.js +200 -0
- package/src/spec/schema.js +20 -3
- package/src/spec/schema.test.js +33 -0
- package/src/ui/drop-cap.js +1 -1
- package/src/ui/gallery.js +5 -1
- package/src/ui/ui.test.js +34 -0
- package/types/schema.d.ts +31 -2
- 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
|
-
|
|
3991
|
-
|
|
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.
|
|
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"
|
package/public/.pulse-ui-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.17.
|
|
1
|
+
0.17.3
|
package/public/pulse-ui.css
CHANGED
|
@@ -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
|
-
|
|
4035
|
-
|
|
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
|
+
})
|