ReviewOS

also looking at this

stacks/ts-css

Merge @stacksjs/ts-css into this repo as packages/toolkit

#27
Merged glennmichael123 wants to merge merge/ts-css-toolkit into main
128 files +9797 -201

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

src/parse/generator.tsmodified+28-13
Changes to src/parse/generator.ts
@@ -47,12 +47,10 @@ export function generate(node: CssNode): string {
4747 let out = `[${node.name.name}`
4848 if (node.matcher && node.value) {
4949 out += node.matcher
50 if (node.value.type === 'String') {
51 out += `"${node.value.value.replace(/"/g, '\\"')}"`
52 }
53 else {
50 if (node.value.type === 'String')
51 out += `"${escapeStringContent(node.value.value)}"`
52 else
5453 out += node.value.name
55 }
5654 }
5755 if (node.flags)
5856 out += ` ${node.flags}`
@@ -84,19 +82,15 @@ export function generate(node: CssNode): string {
8482 case 'Dimension':
8583 return node.value + node.unit
8684 case 'String':
87 return `"${node.value.replace(/"/g, '\\"')}"`
85 return `"${escapeStringContent(node.value)}"`
8886 case 'Url':
8987 return /[\s"'()\\]/.test(node.value)
90 ? `url("${node.value.replace(/"/g, '\\"')}")`
88 ? `url("${escapeStringContent(node.value)}")`
9189 : `url(${node.value})`
9290 case 'Hash':
9391 return `#${node.name}`
94 case 'Operator': {
95 const v = node.value
96 if (v === ',' || v === ':')
97 return v
98 return v
99 }
92 case 'Operator':
93 return node.value
10094 case 'Function':
10195 return `${node.name}(${joinChildren(node.children, '')})`
10296 case 'Parentheses':
@@ -159,3 +153,24 @@ function joinChildren(list: { head: any, [Symbol.iterator]: any }, separator: st
159153 }
160154 return out
161155}
156
157/**
158 * Escape `"` and `\\` for CSS string output. The parser stores decoded
159 * string values in the AST, so the generator is responsible for putting
160 * the escapes back. Control chars (< 0x20 / DEL) become hex escapes.
161 */
162function escapeStringContent(s: string): string {
163 let out = ''
164 for (let i = 0; i < s.length; i++) {
165 const ch = s.charCodeAt(i)
166 if (ch === 92 /* \\ */)
167 out += '\\\\'
168 else if (ch === 34 /* " */)
169 out += '\\"'
170 else if (ch < 32 || ch === 127)
171 out += `\\${ch.toString(16)} `
172 else
173 out += s[i]
174 }
175 return out
176}
src/parse/parser/index.tsmodified+9-5
Changes to src/parse/parser/index.ts
@@ -51,7 +51,7 @@ import type {
5151} from '../types'
5252import { CssList } from '../list'
5353import type { Token } from '../tokenizer'
54import { decodeName, Tokenizer, TokenType } from '../tokenizer'
54import { decodeName, decodeString, Tokenizer, TokenType } from '../tokenizer'
5555
5656interface ParserState {
5757 source: string
@@ -618,8 +618,12 @@ function parseValueChild(s: ParserState): CssNode | null {
618618 }
619619 case TokenType.String: {
620620 consume(s)
621 const slice = tokenSlice(s, t)
622 const value = slice.length >= 2 ? slice.slice(1, slice.endsWith(slice[0]!) ? -1 : undefined) : ''
621 // Drop the surrounding quotes and decode escapes (`\"` → `"`,
622 // `\26 ` → `&`, etc.) so the AST holds the actual string value.
623 const innerStart = t.start + 1
624 const closesProperly = t.end - t.start >= 2 && s.source.charCodeAt(t.end - 1) === s.source.charCodeAt(t.start)
625 const innerEnd = closesProperly ? t.end - 1 : t.end
626 const value = decodeString(s.source, innerStart, innerEnd)
623627 const node: StringNode = { type: 'String', value, loc: loc(s, t, t) }
624628 return node
625629 }
@@ -937,8 +941,8 @@ function parseAttribute(s: ParserState): AttributeSelector | null {
937941 const v = peek(s)
938942 if (v.type === TokenType.String) {
939943 consume(s)
940 const slice = tokenSlice(s, v)
941 const text = slice.length >= 2 ? slice.slice(1, -1) : ''
944 const closesProperly = v.end - v.start >= 2 && s.source.charCodeAt(v.end - 1) === s.source.charCodeAt(v.start)
945 const text = decodeString(s.source, v.start + 1, closesProperly ? v.end - 1 : v.end)
942946 value = { type: 'String', value: text, loc: loc(s, v, v) }
943947 }
944948 else if (v.type === TokenType.Ident) {
test/fixtures.test.tsadded+52-0
Changes to test/fixtures.test.ts
@@ -0,0 +1,52 @@
1/**
2 * Real-world CSS fixtures.
3 *
4 * For each fixture we verify three properties:
5 * 1. parse + generate round-trip is stable (re-parsing the output
6 * produces the same string).
7 * 2. minify produces output strictly smaller than input.
8 * 3. minify is itself a fixed point (re-minifying doesn't change anything).
9 */
10
11import { describe, expect, it } from 'bun:test'
12import { readFileSync } from 'node:fs'
13import { join } from 'node:path'
14import { minify } from '../src/optimize'
15import { generate, parse } from '../src/parse'
16
17const FIXTURES = ['reset.css', 'utilities.css', 'component.css']
18
19describe('fixtures', () => {
20 for (const name of FIXTURES) {
21 describe(name, () => {
22 const source = readFileSync(join(import.meta.dir, 'fixtures', name), 'utf8')
23
24 it('parse + generate round-trips stably', () => {
25 const out1 = generate(parse(source))
26 const out2 = generate(parse(out1))
27 expect(out1).toBe(out2)
28 })
29
30 it('minify shrinks the source meaningfully', () => {
31 const { css } = minify(source)
32 expect(css.length).toBeLessThan(source.length)
33 // sanity floor — the minified output should keep the basic structure
34 expect(css).toContain('{')
35 expect(css).toContain('}')
36 })
37
38 it('minify is idempotent', () => {
39 const a = minify(source).css
40 const b = minify(a).css
41 expect(b).toBe(a)
42 })
43
44 it('minify keeps `/*!*/` comments by default', () => {
45 if (source.includes('/*!')) {
46 const { css } = minify(source)
47 expect(css).toContain('/*!')
48 }
49 })
50 })
51 }
52})
test/fixtures/component.cssadded+88-0
Changes to test/fixtures/component.css
@@ -0,0 +1,88 @@
1/*! design-system-style component fixture */
2
3:root {
4 --color-primary: #3b82f6;
5 --color-primary-hover: #2563eb;
6 --color-text: #111827;
7 --color-text-muted: #6b7280;
8 --color-bg: #ffffff;
9 --color-border: #e5e7eb;
10 --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
11 --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
12 --radius-sm: 0.25rem;
13 --radius-md: 0.5rem;
14 --space-1: 0.25rem;
15 --space-2: 0.5rem;
16 --space-4: 1rem;
17}
18
19@media (prefers-color-scheme: dark) {
20 :root {
21 --color-text: #f9fafb;
22 --color-text-muted: #9ca3af;
23 --color-bg: #111827;
24 --color-border: #374151;
25 }
26}
27
28.btn {
29 display: inline-flex;
30 align-items: center;
31 justify-content: center;
32 padding: 0.5rem 1rem;
33 border: 1px solid var(--color-border);
34 border-radius: var(--radius-md);
35 background-color: var(--color-bg);
36 color: var(--color-text);
37 font-weight: 500;
38 font-size: 0.875rem;
39 line-height: 1.25rem;
40 cursor: pointer;
41 transition: background-color 150ms ease-out, border-color 150ms ease-out;
42}
43.btn:hover { background-color: var(--color-primary); border-color: var(--color-primary); color: #fff; }
44.btn:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 2px; }
45.btn:disabled { opacity: 0.5; cursor: not-allowed; }
46
47.btn--primary { background-color: var(--color-primary); color: #fff; border-color: transparent; }
48.btn--primary:hover { background-color: var(--color-primary-hover); }
49.btn--small { padding: 0.25rem 0.5rem; font-size: 0.75rem; }
50.btn--icon-only { padding: 0.5rem; aspect-ratio: 1 / 1; }
51
52.card {
53 background: var(--color-bg);
54 border: 1px solid var(--color-border);
55 border-radius: var(--radius-md);
56 box-shadow: var(--shadow-sm);
57 overflow: hidden;
58}
59.card > .card__header { padding: var(--space-4); border-bottom: 1px solid var(--color-border); font-weight: 600; }
60.card > .card__body { padding: var(--space-4); }
61.card > .card__footer { padding: var(--space-4); border-top: 1px solid var(--color-border); display: flex; justify-content: flex-end; gap: var(--space-2); }
62
63@keyframes fade-in {
64 from { opacity: 0; transform: translateY(8px); }
65 to { opacity: 1; transform: translateY(0); }
66}
67.fade-in { animation: fade-in 200ms ease-out; }
68
69@supports (backdrop-filter: blur(8px)) {
70 .glass { backdrop-filter: blur(8px); background-color: rgba(255, 255, 255, 0.7); }
71}
72
73@container (min-width: 400px) {
74 .card { padding: var(--space-4); }
75}
76
77.icon { width: 1em; height: 1em; flex-shrink: 0; fill: currentColor; }
78.icon--sm { width: 0.75em; height: 0.75em; }
79.icon--lg { width: 1.5em; height: 1.5em; }
80
81.list { list-style: none; margin: 0; padding: 0; }
82.list > li + li { margin-top: var(--space-2); }
83.list a:hover { text-decoration: underline; text-decoration-thickness: 2px; }
84
85@media (max-width: 600px) {
86 .card { border-radius: 0; box-shadow: none; }
87 .btn { width: 100%; }
88}
test/fixtures/reset.cssadded+50-0
Changes to test/fixtures/reset.css
@@ -0,0 +1,50 @@
1/*! normalize-style reset (excerpt). */
2*, *::before, *::after { box-sizing: border-box; }
3html {
4 -webkit-text-size-adjust: 100%;
5 -moz-tab-size: 4;
6 tab-size: 4;
7 line-height: 1.15;
8}
9body {
10 margin: 0;
11 font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
12}
13hr { box-sizing: content-box; height: 0; overflow: visible; }
14abbr[title] { text-decoration: underline dotted; }
15b, strong { font-weight: bolder; }
16code, kbd, samp, pre {
17 font-family: ui-monospace, SFMono-Regular, "SF Mono", monospace;
18 font-size: 1em;
19}
20small { font-size: 80%; }
21sub, sup { font-size: 75%; line-height: 0; position: relative; vertical-align: baseline; }
22sub { bottom: -0.25em; }
23sup { top: -0.5em; }
24table { border-color: inherit; text-indent: 0; }
25button, input, optgroup, select, textarea {
26 font-family: inherit;
27 font-size: 100%;
28 line-height: 1.15;
29 margin: 0;
30}
31button, [type='button'], [type='reset'], [type='submit'] {
32 -webkit-appearance: button;
33}
34::-moz-focus-inner { border-style: none; padding: 0; }
35:-moz-focusring { outline: 1px dotted ButtonText; }
36:-moz-ui-invalid { box-shadow: none; }
37legend {
38 box-sizing: border-box;
39 color: inherit;
40 display: table;
41 max-width: 100%;
42 padding: 0;
43 white-space: normal;
44}
45progress { vertical-align: baseline; }
46::-webkit-inner-spin-button, ::-webkit-outer-spin-button { height: auto; }
47[type='search'] { -webkit-appearance: textfield; outline-offset: -2px; }
48::-webkit-search-decoration { -webkit-appearance: none; }
49::-webkit-file-upload-button { -webkit-appearance: button; font: inherit; }
50summary { display: list-item; }
test/fixtures/utilities.cssadded+64-0
Changes to test/fixtures/utilities.css
@@ -0,0 +1,64 @@
1/*! tailwind-style utility excerpt */
2.flex { display: flex; }
3.inline-flex { display: inline-flex; }
4.grid { display: grid; }
5.hidden { display: none; }
6
7.flex-col { flex-direction: column; }
8.flex-row { flex-direction: row; }
9.flex-wrap { flex-wrap: wrap; }
10.items-center { align-items: center; }
11.justify-center { justify-content: center; }
12.justify-between { justify-content: space-between; }
13
14.gap-1 { gap: 0.25rem; }
15.gap-2 { gap: 0.5rem; }
16.gap-4 { gap: 1rem; }
17.gap-8 { gap: 2rem; }
18
19.p-0 { padding: 0; }
20.p-1 { padding: 0.25rem; }
21.p-2 { padding: 0.5rem; }
22.p-4 { padding: 1rem; }
23.p-8 { padding: 2rem; }
24
25.text-xs { font-size: 0.75rem; line-height: 1rem; }
26.text-sm { font-size: 0.875rem; line-height: 1.25rem; }
27.text-base { font-size: 1rem; line-height: 1.5rem; }
28.text-lg { font-size: 1.125rem; line-height: 1.75rem; }
29.text-xl { font-size: 1.25rem; line-height: 1.75rem; }
30
31.bg-white { background-color: #ffffff; }
32.bg-black { background-color: #000000; }
33.bg-red-500 { background-color: #ef4444; }
34.bg-blue-500 { background-color: #3b82f6; }
35.bg-green-500 { background-color: #22c55e; }
36
37.text-white { color: #ffffff; }
38.text-black { color: #000000; }
39.text-gray-500 { color: #6b7280; }
40
41.rounded { border-radius: 0.25rem; }
42.rounded-md { border-radius: 0.375rem; }
43.rounded-lg { border-radius: 0.5rem; }
44.rounded-full { border-radius: 9999px; }
45
46.shadow { box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); }
47.shadow-md { box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); }
48.shadow-lg { box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); }
49
50.transition { transition-property: all; transition-duration: 150ms; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); }
51.duration-300 { transition-duration: 300ms; }
52
53@media (min-width: 640px) {
54 .sm\:flex { display: flex; }
55 .sm\:hidden { display: none; }
56 .sm\:p-4 { padding: 1rem; }
57}
58@media (min-width: 768px) {
59 .md\:grid { display: grid; }
60 .md\:hidden { display: none; }
61}
62@media (min-width: 1024px) {
63 .lg\:flex { display: flex; }
64}
test/parse.test.tsmodified+34-0
Changes to test/parse.test.ts
@@ -124,6 +124,40 @@ describe('parse + generate round-trip', () => {
124124 expect(list.toArray()).toEqual([2, 3])
125125 })
126126
127 it('round-trips strings with escape sequences', () => {
128 const cases = [
129 '.a{content:"with \\"quote\\" inside"}',
130 '.a{content:"line1\\a line2"}',
131 'a[data-x="\\"x\\""]{color:red}',
132 ]
133 for (const c of cases) {
134 const out1 = generate(parse(c))
135 const out2 = generate(parse(out1))
136 expect(out1).toBe(out2)
137 }
138 })
139
140 it('decodes hex escapes in string values', () => {
141 const ast = parse('.a{content:"\\26 B"}') as any
142 const decl = ast.children.first.block.children.first
143 const string = decl.value.children.first
144 expect(string.type).toBe('String')
145 expect(string.value).toBe('&B')
146 })
147
148 it('round-trips selector identifier escapes', () => {
149 const cases = [
150 '.\\26 B{color:red}',
151 '.foo\\30 bar{color:red}',
152 '.foo\\!bar{color:red}',
153 ]
154 for (const c of cases) {
155 const out1 = generate(parse(c))
156 const out2 = generate(parse(out1))
157 expect(out1).toBe(out2)
158 }
159 })
160
127161 it('survives nested forEach with mid-walk removal', () => {
128162 const list = new List<number>()
129163 list.fromArray([1, 2, 3, 4, 5])