also looking at this
Merge @stacksjs/ts-css into this repo as packages/toolkit
#27
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.
| @@ -1,109 +1,200 @@ | ||
| 1 | 1 | # @stacksjs/ts-css |
| 2 | 2 | |
| 3 | Pure-TypeScript CSS toolkit for Bun/Node. Zero runtime deps. | |
| 3 | > Pure-TypeScript CSS toolkit for Bun & Node. Zero runtime dependencies. | |
| 4 | 4 | |
| 5 | Drop-in replacement for the `css-tree` + `css-select` + `css-what` + `csso` | |
| 6 | quartet that most CSS processing pipelines pull in. | |
| 5 | `ts-css` is a single, dependency-free package that replaces the four-library | |
| 6 | quartet most CSS pipelines pull in: | |
| 7 | 7 | |
| 8 | ## What's included | |
| 8 | | You used to install | Now you only install | | |
| 9 | | -------------------------------- | ---------------------------- | | |
| 10 | | `css-tree` `css-select` `css-what` `csso` | `@stacksjs/ts-css` | | |
| 9 | 11 | |
| 10 | | Module | Replaces | What you get | | |
| 11 | | -------------------------- | ------------- | ------------------------------------------------------------- | | |
| 12 | | `@stacksjs/ts-css/parse` | `css-tree` | Tokenizer, recursive-descent parser, walker, generator, clone | | |
| 13 | | `@stacksjs/ts-css/what` | `css-what` | Selector parser / stringifier | | |
| 14 | | `@stacksjs/ts-css/select` | `css-select` | `selectAll` / `selectOne` / `is` against any tree-like adapter | | |
| 15 | | `@stacksjs/ts-css/optimize`| `csso` | `minify` / `minifyBlock`, `syntax.specificity` | | |
| 12 | It's a drop-in for the API surface that real consumers (CSSO, SVGO, lint | |
| 13 | tools, design-system pipelines) actually touch — written from scratch in | |
| 14 | TypeScript with strict types, the CSS Syntax Module Level 3 tokenizer, and | |
| 15 | the `css-select` adapter pattern preserved verbatim so existing call sites | |
| 16 | don't change. | |
| 16 | 17 | |
| 17 | The top-level `@stacksjs/ts-css` package re-exports all four under their | |
| 18 | familiar namespaces (`csstree`, `cssWhat`, `cssSelect`, `csso`) plus the | |
| 19 | most-used functions directly. | |
| 18 | ## Highlights | |
| 19 | ||
| 20 | - **Zero runtime deps.** Only ts-css's own code. Ships ~4.5k lines of TS. | |
| 21 | - **Four packages worth of API in one import.** Sub-modules at | |
| 22 | `@stacksjs/ts-css/parse`, `/what`, `/select`, `/optimize`. | |
| 23 | - **Drop-in compatible.** Namespace exports (`csstree`, `cssWhat`, | |
| 24 | `cssSelect`, `csso`) keep existing call sites intact. | |
| 25 | - **Bun-first.** Built and tested on Bun, works on Node ≥18. | |
| 26 | - **Strict TypeScript.** Strict mode, isolatedDeclarations, verbatimModuleSyntax. | |
| 20 | 27 | |
| 21 | 28 | ## Install |
| 22 | 29 | |
| 23 | 30 | ```bash |
| 24 | 31 | bun add @stacksjs/ts-css |
| 32 | # or | |
| 33 | npm i @stacksjs/ts-css | |
| 25 | 34 | ``` |
| 26 | 35 | |
| 27 | 36 | ## Quick start |
| 28 | 37 | |
| 29 | ### Parse → walk → generate | |
| 38 | ### Parse, walk, generate | |
| 30 | 39 | |
| 31 | 40 | ```ts |
| 32 | 41 | import { generate, parse, walk } from '@stacksjs/ts-css' |
| 33 | 42 | |
| 34 | const ast = parse('.foo { color: red }') | |
| 35 | walk(ast, (node) => { | |
| 36 | if (node.type === 'Declaration') | |
| 37 | console.log(node.property) | |
| 43 | const ast = parse(` | |
| 44 | .foo, .bar > .baz { | |
| 45 | color: red; | |
| 46 | background: url("/x.png") no-repeat; | |
| 47 | } | |
| 48 | `) | |
| 49 | ||
| 50 | walk(ast, { | |
| 51 | visit: 'Declaration', | |
| 52 | enter(decl) { | |
| 53 | console.log(decl.property, '→', generate(decl.value)) | |
| 54 | }, | |
| 38 | 55 | }) |
| 56 | // color → red | |
| 57 | // background → url("/x.png") no-repeat | |
| 58 | ||
| 39 | 59 | console.log(generate(ast)) |
| 60 | // .foo,.bar>.baz{color:red;background:url("/x.png") no-repeat} | |
| 40 | 61 | ``` |
| 41 | 62 | |
| 42 | ### Selector matching | |
| 63 | ### Match selectors against any tree | |
| 43 | 64 | |
| 44 | 65 | ```ts |
| 45 | 66 | import { selectAll } from '@stacksjs/ts-css' |
| 46 | 67 | |
| 47 | const els = selectAll('p > span.foo', root, { adapter, xmlMode: true }) | |
| 68 | const adapter = { | |
| 69 | isTag: (n) => n?.type === 'el', | |
| 70 | getName: (e) => e.name, | |
| 71 | getAttributeValue: (e, n) => e.attrs[n], | |
| 72 | // …other adapter methods (see docs/api/select.md) | |
| 73 | } | |
| 74 | ||
| 75 | selectAll('p > span.foo:not(.disabled)', root, { adapter, xmlMode: true }) | |
| 48 | 76 | ``` |
| 49 | 77 | |
| 50 | The adapter shape is identical to `css-select`'s — pass your own | |
| 51 | implementation for any tree (HTML, XML, your custom AST, etc.). | |
| 78 | The adapter shape is **identical** to `css-select`'s — pass your own and | |
| 79 | ts-css matches selectors against your custom tree (HTML, XML, your | |
| 80 | design-system AST, anything). | |
| 52 | 81 | |
| 53 | 82 | ### Minify CSS |
| 54 | 83 | |
| 55 | 84 | ```ts |
| 56 | 85 | import { minify } from '@stacksjs/ts-css' |
| 57 | 86 | |
| 58 | const { css } = minify('.a { margin: 0px 0.5em 10.000px; color:#aabbcc }') | |
| 59 | // → ".a{margin:0 .5em 10px;color:#abc}" | |
| 87 | minify(` | |
| 88 | .a { | |
| 89 | margin: 0px 0.5em 10.000px; | |
| 90 | color: #aabbcc; | |
| 91 | color: blue; /* later wins */ | |
| 92 | } | |
| 93 | `).css | |
| 94 | // → ".a{margin:0 .5em 10px;color:blue}" | |
| 60 | 95 | ``` |
| 61 | 96 | |
| 62 | ### Specificity | |
| 97 | ### Compute selector specificity | |
| 63 | 98 | |
| 64 | 99 | ```ts |
| 65 | import { syntax } from '@stacksjs/ts-css' | |
| 66 | import { parse } from '@stacksjs/ts-css' | |
| 100 | import { parse, syntax } from '@stacksjs/ts-css' | |
| 67 | 101 | |
| 68 | 102 | const sel = parse('#a.b div', { context: 'selector' }) |
| 69 | 103 | syntax.specificity(sel) // [1, 1, 1] |
| 70 | 104 | ``` |
| 71 | 105 | |
| 72 | ### Drop-in compatibility | |
| 106 | ## Migration: drop-in compat | |
| 73 | 107 | |
| 74 | If you're migrating off the original quartet, the namespace import lines | |
| 75 | change but the call sites stay the same: | |
| 108 | If you already use `css-tree`/`css-select`/`css-what`/`csso`, the migration | |
| 109 | is one block of imports: | |
| 76 | 110 | |
| 77 | 111 | ```diff |
| 78 | 112 | - import * as csstree from 'css-tree' |
| 79 | 113 | - import * as csswhat from 'css-what' |
| 80 | - import { selectAll, selectOne, is } from 'css-select' | |
| 114 | - import { is, selectAll, selectOne } from 'css-select' | |
| 81 | 115 | - import * as csso from 'csso' |
| 82 | + import { csstree, cssWhat, csso } from '@stacksjs/ts-css' | |
| 83 | + import { selectAll, selectOne, is } from '@stacksjs/ts-css' | |
| 116 | + import { csstree, cssWhat as csswhat, csso, is, selectAll, selectOne } from '@stacksjs/ts-css' | |
| 84 | 117 | ``` |
| 85 | 118 | |
| 119 | Every method already used by SVGO and CSSO works without further changes: | |
| 120 | ||
| 121 | | Surface | Available | | |
| 122 | | -------------------------------- | --------- | | |
| 123 | | `csstree.parse(s, opts)` | ✅ — all `context` modes | | |
| 124 | | `csstree.walk(ast, cb \| { visit, enter, leave })` + `walk.skip` | ✅ | | |
| 125 | | `csstree.generate(node)` | ✅ | | |
| 126 | | `csstree.clone(node)` | ✅ | | |
| 127 | | `csstree.List` / `ListItem` | ✅ — same `forEach(data, item, list)` callback shape | | |
| 128 | | `cssWhat.parse(selector)` | ✅ | | |
| 129 | | `cssWhat.isTraversal(seg)` | ✅ | | |
| 130 | | `cssSelect.{selectAll,selectOne,is}` | ✅ | | |
| 131 | | `csso.{minify,minifyBlock}` | ✅ | | |
| 132 | | `csso.syntax.specificity(node)` | ✅ | | |
| 133 | ||
| 86 | 134 | ## CLI |
| 87 | 135 | |
| 88 | 136 | ```bash |
| 89 | 137 | ts-css minify input.css > output.css |
| 90 | ts-css parse input.css | jq # pretty-print AST | |
| 91 | ts-css format input.css # round-trip through parser/generator | |
| 138 | ts-css parse input.css # AST as JSON on stdout | |
| 139 | ts-css format input.css # round-trip through parser/generator | |
| 92 | 140 | ``` |
| 93 | 141 | |
| 94 | ## Scope notes | |
| 142 | ## What's not (yet) in scope | |
| 95 | 143 | |
| 96 | This is a pragmatic port — the full surface area of the original libraries | |
| 97 | that real consumers (CSSO, SVGO, lint tools) actually touch. Specifically: | |
| 144 | This is a pragmatic port — full surface area where it's load-bearing, | |
| 145 | trimmed where the original libs spend most of their bytes on | |
| 146 | edge cases real consumers don't hit: | |
| 98 | 147 | |
| 99 | - ❌ No CSS-spec-aware lexer (no `<color>` / `<length>` validation against | |
| 100 | the syntax definitions). Add it back when an actual consumer needs it — | |
| 101 | the AST already preserves enough information to do so. | |
| 102 | - ❌ No selector-rewriting CSS restructuring (csso's "merge rules across | |
| 103 | selectors" pass). Marginal real-world gain over gzip. | |
| 148 | - ❌ **No CSS-spec-aware lexer.** `css-tree`'s value validator (e.g. | |
| 149 | "is `red` a valid `<color>`?") is ~3000 lines of spec data and grammar | |
| 150 | matchers. We don't ship it. If you need spec validation, the AST already | |
| 151 | preserves enough to add it on top. | |
| 152 | - ❌ **No CSSO restructuring pass.** `csso` reorders rules across selectors | |
| 153 | to fold compatible declarations. It's complex and gives marginal gain | |
| 154 | beyond gzip. Declaration-level minification (numbers, colors, dedup) | |
| 155 | ships fully. | |
| 104 | 156 | |
| 105 | 157 | Everything else round-trips byte-equivalent or better than the originals. |
| 106 | 158 | |
| 159 | ## Project layout | |
| 160 | ||
| 161 | ``` | |
| 162 | src/ | |
| 163 | parse/ # tokenizer, parser, AST types, walker, generator, clone, List | |
| 164 | what/ # selector parser/stringifier | |
| 165 | select/ # selectAll/selectOne/is engine + pseudo-class compilers | |
| 166 | optimize/ # minifier, value compressors, specificity | |
| 167 | ``` | |
| 168 | ||
| 169 | Each sub-module is exported as a separate entry point so you can import | |
| 170 | just what you need: | |
| 171 | ||
| 172 | ```ts | |
| 173 | import { parse, walk } from '@stacksjs/ts-css/parse' | |
| 174 | import { selectAll } from '@stacksjs/ts-css/select' | |
| 175 | import { minify } from '@stacksjs/ts-css/optimize' | |
| 176 | ``` | |
| 177 | ||
| 178 | ## Documentation | |
| 179 | ||
| 180 | Full docs: see [`./docs`](./docs) or run `bun run dev:docs` to start the | |
| 181 | local docs site. | |
| 182 | ||
| 183 | - [Getting started](./docs/intro.md) | |
| 184 | - [Parser API](./docs/api/parse.md) | |
| 185 | - [Selector parser API](./docs/api/what.md) | |
| 186 | - [Selector matcher API](./docs/api/select.md) | |
| 187 | - [Optimizer API](./docs/api/optimize.md) | |
| 188 | - [Migration guide](./docs/migration.md) | |
| 189 | ||
| 107 | 190 | ## License |
| 108 | 191 | |
| 109 | MIT | |
| 192 | MIT — see [`LICENSE.md`](./LICENSE.md). | |
| 193 | ||
| 194 | The selector parsing strategy in `src/what` is structurally inspired by | |
| 195 | [`css-what`](https://github.com/fb55/css-what); the visitor & List shape in | |
| 196 | `src/parse` mirrors [`css-tree`](https://github.com/csstree/csstree); the | |
| 197 | minification plan in `src/optimize` follows | |
| 198 | [`csso`](https://github.com/css/csso). All four are MIT-licensed; this | |
| 199 | project is an independent reimplementation that preserves their public API | |
| 200 | surface. | |
| @@ -0,0 +1,107 @@ | ||
| 1 | # Optimizer API | |
| 2 | ||
| 3 | Replaces `csso`. Compact a CSS string, remove duplicate declarations, | |
| 4 | shorten colors, and compute selector specificity. | |
| 5 | ||
| 6 | ```ts | |
| 7 | import { minify, minifyBlock, specificity, syntax } from '@stacksjs/ts-css' | |
| 8 | // or: | |
| 9 | import { /* … */ } from '@stacksjs/ts-css/optimize' | |
| 10 | ``` | |
| 11 | ||
| 12 | ## `minify(source, options?)` | |
| 13 | ||
| 14 | Parse, compress, and re-stringify a full stylesheet. | |
| 15 | ||
| 16 | ```ts | |
| 17 | minify(source: string, options?: MinifyOptions): { css: string, ast: CssNode } | |
| 18 | ``` | |
| 19 | ||
| 20 | ```ts | |
| 21 | minify('.a { margin: 0px 0.5em 10.000px; color: #aabbcc }').css | |
| 22 | // → ".a{margin:0 .5em 10px;color:#abc}" | |
| 23 | ``` | |
| 24 | ||
| 25 | ## `minifyBlock(source, options?)` | |
| 26 | ||
| 27 | Same, but parse `source` as a declaration list (the body of a `style="…"` | |
| 28 | attribute). | |
| 29 | ||
| 30 | ```ts | |
| 31 | minifyBlock('color: red; color: blue').css | |
| 32 | // → "color:blue" | |
| 33 | ``` | |
| 34 | ||
| 35 | ## `MinifyOptions` | |
| 36 | ||
| 37 | | Option | Default | Description | | |
| 38 | | ------------------- | ---------------- | ---------------------------------------------------------- | | |
| 39 | | `comments` | `'exclamation'` | `false` to strip all, `true` / `'exclamation'` to keep `/*!*/` comments, `'first-exclamation'` to keep just the first. | | |
| 40 | | `restructure` | `true` | (Reserved — currently a no-op; declaration-level minification only.) | | |
| 41 | | `forceMediaMerge` | `false` | (Reserved.) | | |
| 42 | | `usage` | `null` | Usage hints (`{tags, ids, classes, force}`) — accepted for API parity. | | |
| 43 | ||
| 44 | ## What gets compressed | |
| 45 | ||
| 46 | Inside any `Value`, ts-css compresses: | |
| 47 | ||
| 48 | | Input | Output | | |
| 49 | | ------------------ | ---------- | | |
| 50 | | `0.5em` | `.5em` | | |
| 51 | | `10.000px` | `10px` | | |
| 52 | | `0px` / `0em` / … | `0` | | |
| 53 | | `#aabbcc` | `#abc` | | |
| 54 | | `#aabbccdd` | `#abcd` | | |
| 55 | | `rgb(255,0,0)` | `#f00` | | |
| 56 | | `url("/x.png")` | `url(/x.png)` (when safe) | | |
| 57 | ||
| 58 | In each `Block`, duplicate declarations are folded with **last wins** | |
| 59 | semantics (respecting `!important`): | |
| 60 | ||
| 61 | ```css | |
| 62 | .a { color: red; color: blue } → .a { color: blue } | |
| 63 | .a { color: red !important; | |
| 64 | color: blue } → .a { color: red !important } | |
| 65 | ``` | |
| 66 | ||
| 67 | Comments matching `options.comments` rule are stripped. | |
| 68 | ||
| 69 | ## `specificity(node)` | |
| 70 | ||
| 71 | Compute the specificity of a `Selector` AST. | |
| 72 | ||
| 73 | ```ts | |
| 74 | import { parse, syntax } from '@stacksjs/ts-css' | |
| 75 | ||
| 76 | const sel = parse('#a.b div', { context: 'selector' }) | |
| 77 | syntax.specificity(sel) // [1, 1, 1] | |
| 78 | ``` | |
| 79 | ||
| 80 | Returns a `[a, b, c]` tuple per | |
| 81 | [selectors level 4](https://www.w3.org/TR/selectors-4/#specificity-rules): | |
| 82 | ||
| 83 | - `a` — id selectors | |
| 84 | - `b` — class / attribute / pseudo-class selectors | |
| 85 | - `c` — type / pseudo-element selectors | |
| 86 | ||
| 87 | `:is()`, `:not()`, `:has()`, `:matches()`, `:-moz-any()`, `:-webkit-any()` | |
| 88 | take the **maximum** specificity of their arguments. | |
| 89 | `:where()` always contributes `[0,0,0]`. | |
| 90 | ||
| 91 | The `syntax.specificity` re-export mirrors `csso.syntax.specificity` so | |
| 92 | existing call sites work without changes. | |
| 93 | ||
| 94 | ## Compose your own pipeline | |
| 95 | ||
| 96 | If you want finer control, all the building blocks are exported: | |
| 97 | ||
| 98 | ```ts | |
| 99 | import { compressTree, dedupeDeclarations, removeComments } from '@stacksjs/ts-css/optimize' | |
| 100 | import { generate, parse } from '@stacksjs/ts-css/parse' | |
| 101 | ||
| 102 | const ast = parse(input) | |
| 103 | removeComments(ast, { exclamation: true }) | |
| 104 | compressTree(ast) | |
| 105 | dedupeDeclarations(ast) | |
| 106 | const out = generate(ast) | |
| 107 | ``` | |
| @@ -0,0 +1,166 @@ | ||
| 1 | # Parser API | |
| 2 | ||
| 3 | Replaces `css-tree`. Tokenizer, recursive-descent parser, walker, | |
| 4 | generator, deep clone, and a doubly-linked `List`. | |
| 5 | ||
| 6 | ```ts | |
| 7 | import { clone, generate, List, parse, walk } from '@stacksjs/ts-css' | |
| 8 | // or, when you only need the parser: | |
| 9 | import { /* … */ } from '@stacksjs/ts-css/parse' | |
| 10 | ``` | |
| 11 | ||
| 12 | ## `parse(source, options?)` | |
| 13 | ||
| 14 | Parse a CSS source string into an AST. | |
| 15 | ||
| 16 | ```ts | |
| 17 | parse(source: string, options?: ParseOptions): CssNode | |
| 18 | ``` | |
| 19 | ||
| 20 | ### Options | |
| 21 | ||
| 22 | | Option | Default | Description | | |
| 23 | | ----------------------- | -------------- | ------------------------------------------------------------------------ | | |
| 24 | | `context` | `'stylesheet'` | What grammar to parse against (see below). | | |
| 25 | | `parseValue` | `true` | When `false`, declaration values are stored as `Raw` nodes. | | |
| 26 | | `parseAtrulePrelude` | `true` | When `false`, at-rule preludes are stored as `Raw`. | | |
| 27 | | `parseRulePrelude` | `true` | When `false`, rule preludes (selector lists) are stored as `Raw`. | | |
| 28 | | `parseCustomProperty` | `false` | When `true`, custom property values are parsed (otherwise `Raw`). | | |
| 29 | | `positions` | `false` | Track source `loc` info on every node. | | |
| 30 | | `filename` | — | Source filename, used in `loc.source`. | | |
| 31 | | `onParseError` | — | Called when the parser substitutes a `Raw` node for an error. | | |
| 32 | ||
| 33 | ### Parse contexts | |
| 34 | ||
| 35 | | `context` | Returns | Use for | | |
| 36 | | ------------------- | ------------------------------------ | ---------------------------------------------------- | | |
| 37 | | `'stylesheet'` | `StyleSheet` | Top-level CSS files (default) | | |
| 38 | | `'atrule'` | `Atrule \| StyleSheet` | A single `@rule …;` or `@rule { … }` | | |
| 39 | | `'atrulePrelude'` | `AtrulePrelude` | The `(min-width: 800px)` part of a media query | | |
| 40 | | `'rule'` | `Rule` | A single `selector { … }` block | | |
| 41 | | `'block'` | `Block` | Just the `{ decl; decl; }` body | | |
| 42 | | `'declarationList'` | `DeclarationList` | A `style="…"` attribute body | | |
| 43 | | `'declaration'` | `Declaration` | A single `prop: value;` line | | |
| 44 | | `'value'` | `Value` | Just the value side of a declaration | | |
| 45 | | `'selector'` | `Selector` | A single selector segment (`a > b`) | | |
| 46 | | `'selectorList'` | `SelectorList` | A comma-separated list (`a, b > c`) | | |
| 47 | ||
| 48 | ## `walk(ast, visitor)` | |
| 49 | ||
| 50 | Recursively traverse the AST. | |
| 51 | ||
| 52 | ```ts | |
| 53 | walk(ast: CssNode, visitor: WalkVisitor): symbol | undefined | |
| 54 | ``` | |
| 55 | ||
| 56 | `visitor` is either: | |
| 57 | - A function: `(node, item, list) => void | symbol` | |
| 58 | - An object: `{ visit?, enter?, leave?, reverse? }` | |
| 59 | ||
| 60 | Inside callbacks, `this` carries traversal context: `this.atrule`, | |
| 61 | `this.rule`, `this.selector`, `this.declaration`, etc. | |
| 62 | ||
| 63 | ```ts | |
| 64 | walk(ast, { | |
| 65 | visit: 'Declaration', | |
| 66 | enter(decl) { | |
| 67 | if (decl.property === 'color') | |
| 68 | decl.value = parse('blue', { context: 'value' }) | |
| 69 | }, | |
| 70 | }) | |
| 71 | ``` | |
| 72 | ||
| 73 | ### Sentinels | |
| 74 | ||
| 75 | | Constant | What it does | | |
| 76 | | ------------- | --------------------------------------------------------- | | |
| 77 | | `walk.skip` | Return from `enter` to skip the node's children. | | |
| 78 | | `walk.stop` | Return to halt the entire traversal. | | |
| 79 | ||
| 80 | ## `generate(node)` | |
| 81 | ||
| 82 | Serialize an AST node back to a CSS string. Output is compact (no | |
| 83 | unnecessary whitespace) — comparable to `csstree.generate(node)` with | |
| 84 | no formatting options. | |
| 85 | ||
| 86 | ```ts | |
| 87 | generate(node: CssNode): string | |
| 88 | ``` | |
| 89 | ||
| 90 | ## `clone(node)` | |
| 91 | ||
| 92 | Deep-clone an AST node, preserving the `List` structure of children. | |
| 93 | ||
| 94 | ```ts | |
| 95 | clone<T extends CssNode>(node: T): T | |
| 96 | ``` | |
| 97 | ||
| 98 | ## `List<T>` | |
| 99 | ||
| 100 | Doubly linked list — every container node uses one for its `children`. | |
| 101 | ||
| 102 | ```ts | |
| 103 | import { List } from '@stacksjs/ts-css' | |
| 104 | ||
| 105 | const list = new List<number>() | |
| 106 | list.appendData(1) | |
| 107 | list.appendData(2) | |
| 108 | list.toArray() // [1, 2] | |
| 109 | ``` | |
| 110 | ||
| 111 | ### Mutation methods | |
| 112 | ||
| 113 | | Method | Description | | |
| 114 | | --------------------------- | ------------------------------------------------- | | |
| 115 | | `appendData(data)` | Append a new item built from `data`. | | |
| 116 | | `prependData(data)` | Prepend a new item. | | |
| 117 | | `insertData(data, before?)` | Insert before `before` (a `ListItem`), or at tail. | | |
| 118 | | `insert(item, before?)` | Insert an existing `ListItem`. | | |
| 119 | | `remove(item)` | Detach an item from the list. | | |
| 120 | | `replace(old, newItem)` | Replace `old` with `newItem` (or another `List`). | | |
| 121 | | `clear()` | Detach all items. | | |
| 122 | ||
| 123 | ### Iteration | |
| 124 | ||
| 125 | | Method | Callback signature | | |
| 126 | | ------------------------------- | ----------------------------------------------- | | |
| 127 | | `forEach(fn)` | `(data, item, list)` — matches `css-tree`. | | |
| 128 | | `forEachRight(fn)` | Same, in reverse. | | |
| 129 | | `reduce(fn, init)` | `(acc, data, index, list)` | | |
| 130 | | `some(fn)` | `(data, index, list) => boolean` | | |
| 131 | | `map(fn)` | Returns a new `List`. | | |
| 132 | | `filter(fn)` | Returns a new `List`. | | |
| 133 | ||
| 134 | The `forEach` / `forEachRight` callbacks honor a cursor protocol so | |
| 135 | callbacks can safely insert/remove items mid-walk without breaking the | |
| 136 | iterator. | |
| 137 | ||
| 138 | ## AST node reference | |
| 139 | ||
| 140 | The full discriminated union is `CssNode`. The most common variants: | |
| 141 | ||
| 142 | ```ts | |
| 143 | interface StyleSheet { type: 'StyleSheet', children: List<CssNode> } | |
| 144 | interface Rule { type: 'Rule', prelude: SelectorList | Raw, block: Block } | |
| 145 | interface Atrule { type: 'Atrule', name: string, prelude: AtrulePrelude | Raw | null, block: Block | null } | |
| 146 | interface Block { type: 'Block', children: List<CssNode> } | |
| 147 | interface Declaration { type: 'Declaration', property: string, value: Value | Raw, important: boolean | string } | |
| 148 | interface Value { type: 'Value', children: List<CssNode> } | |
| 149 | interface Selector { type: 'Selector', children: List<CssNode> } | |
| 150 | // + identifiers, numbers, dimensions, hashes, urls, functions, … | |
| 151 | ``` | |
| 152 | ||
| 153 | See `src/parse/types.ts` for the complete type definitions. | |
| 154 | ||
| 155 | ## Tokenizer (advanced) | |
| 156 | ||
| 157 | If you need raw token output, import `Tokenizer` directly: | |
| 158 | ||
| 159 | ```ts | |
| 160 | import { Tokenizer, TokenType } from '@stacksjs/ts-css/parse' | |
| 161 | ||
| 162 | const t = new Tokenizer('.foo { color: red }') | |
| 163 | for (const tok of t.tokens) { | |
| 164 | console.log(TokenType[tok.type], tok.start, tok.end) | |
| 165 | } | |
| 166 | ``` | |
| @@ -0,0 +1,123 @@ | ||
| 1 | # Selector matcher API | |
| 2 | ||
| 3 | Replaces `css-select`. Run any CSS selector against any tree-like data | |
| 4 | structure via an `Adapter`. | |
| 5 | ||
| 6 | ```ts | |
| 7 | import { compile, is, selectAll, selectOne } from '@stacksjs/ts-css' | |
| 8 | // or: | |
| 9 | import { /* … */ } from '@stacksjs/ts-css/select' | |
| 10 | ``` | |
| 11 | ||
| 12 | ## `selectAll(selector, root, options)` | |
| 13 | ||
| 14 | Return every element under `root` that matches `selector`. | |
| 15 | ||
| 16 | ```ts | |
| 17 | selectAll<Node, ElementNode extends Node>( | |
| 18 | selector: string | Selector[][] | CompiledQuery<ElementNode>, | |
| 19 | root: Node | Node[], | |
| 20 | options: Options<Node, ElementNode>, | |
| 21 | ): ElementNode[] | |
| 22 | ``` | |
| 23 | ||
| 24 | ## `selectOne(selector, root, options)` | |
| 25 | ||
| 26 | Return the first match (in document order), or `null`. | |
| 27 | ||
| 28 | ```ts | |
| 29 | selectOne(...): ElementNode | null | |
| 30 | ``` | |
| 31 | ||
| 32 | ## `is(node, selector, options)` | |
| 33 | ||
| 34 | Test whether `node` itself matches `selector`. | |
| 35 | ||
| 36 | ```ts | |
| 37 | is(node, selector, options): boolean | |
| 38 | ``` | |
| 39 | ||
| 40 | ## `compile(selector, options)` | |
| 41 | ||
| 42 | Compile a selector once and reuse the predicate against many trees. | |
| 43 | ||
| 44 | ```ts | |
| 45 | const test = compile('p > span.foo', options) | |
| 46 | adapter.findAll(test, [root]) | |
| 47 | ``` | |
| 48 | ||
| 49 | ## `Adapter<Node, ElementNode>` | |
| 50 | ||
| 51 | The adapter is the tree-access contract. Every method has the same | |
| 52 | signature as `css-select`'s — if you already have one, it works as-is. | |
| 53 | ||
| 54 | | Method | Returns | Purpose | | |
| 55 | | ------------------------------------------------- | -------------------------------- | ----------------------------------------------- | | |
| 56 | | `isTag(node)` | `node is ElementNode` | Type guard for element-vs-text/comment. | | |
| 57 | | `getName(elem)` | `string` | Element tag name. | | |
| 58 | | `getAttributeValue(elem, name)` | `string \| undefined` | Attribute value (case-sensitive on `name`). | | |
| 59 | | `hasAttrib(elem, name)` | `boolean` | Whether `elem[name]` is set. | | |
| 60 | | `getChildren(node)` | `Node[]` | Direct children of `node`. | | |
| 61 | | `getParent(elem)` | `ElementNode \| null` | Parent element (or null for the root). | | |
| 62 | | `getSiblings(elem)` | `Node[]` | All siblings, including `elem`. | | |
| 63 | | `getText(node)` | `string` | Text content (used by some pseudos). | | |
| 64 | | `findAll(test, elems)` | `ElementNode[]` | DFS traversal. | | |
| 65 | | `findOne(test, elems)` | `ElementNode \| null` | DFS, first match. | | |
| 66 | | `existsOne(test, elems)` | `boolean` | DFS, just a check. | | |
| 67 | | `removeSubsets(nodes)` | `Node[]` | Remove descendants when their ancestor is also in `nodes`. | | |
| 68 | ||
| 69 | Optional state-aware hooks (always called for `:hover` / `:active` / | |
| 70 | `:visited`): | |
| 71 | ||
| 72 | | Method | Default | When you'd implement it | | |
| 73 | | --------------------- | ---------------- | --------------------------------------------- | | |
| 74 | | `isActive(elem)` | always `false` | DOM-aware match for `:active` | | |
| 75 | | `isHovered(elem)` | always `false` | DOM-aware match for `:hover` | | |
| 76 | | `isVisited(elem)` | always `false` | DOM-aware match for `:visited` | | |
| 77 | | `equals(a, b)` | `===` | Custom node equality (rare). | | |
| 78 | ||
| 79 | If your tree has no notion of focus/hover, leave these undefined — the | |
| 80 | matcher treats stateful pseudos as never-matching. | |
| 81 | ||
| 82 | ## `Options` | |
| 83 | ||
| 84 | | Option | Default | Description | | |
| 85 | | ---------------------------- | ------- | -------------------------------------------------------------------- | | |
| 86 | | `adapter` | — | **Required.** The `Adapter` for your tree. | | |
| 87 | | `xmlMode` | `false` | Disable HTML quirks (case-insensitive tags / attrs). | | |
| 88 | | `lowerCaseAttributeNames` | `true` | Lowercase attribute names before matching. | | |
| 89 | | `lowerCaseTags` | `true` | Lowercase tag names. | | |
| 90 | | `cacheResults` | `true` | Cache compiled selectors keyed by selector string. | | |
| 91 | | `context` | — | Reference node(s) for `:scope`. | | |
| 92 | | `pseudos` | — | Map of custom pseudo-class implementations. | | |
| 93 | | `relativeSelector` | — | Match `:has` lazily. | | |
| 94 | | `quirksMode` | `false` | Match HTML quirks-mode attribute case rules. | | |
| 95 | ||
| 96 | ## Supported selectors | |
| 97 | ||
| 98 | | Category | Examples | | |
| 99 | | --------------------- | --------------------------------------------------- | | |
| 100 | | Type / universal | `div`, `*`, `svg\|circle` (namespaces in xmlMode) | | |
| 101 | | Class / id | `.foo`, `#bar` | | |
| 102 | | Attribute | `[a]`, `[a=x]`, `[a~=x]`, `[a\|=x]`, `[a^=x]`, `[a$=x]`, `[a*=x]` (with `i` / `s` case flags) | | |
| 103 | | Combinators | `a b`, `a > b`, `a + b`, `a ~ b`, `a \|\| b` | | |
| 104 | | Structural pseudos | `:first-child`, `:last-child`, `:only-child`, `:first-of-type`, `:last-of-type`, `:only-of-type`, `:nth-child(...)`, `:nth-last-child(...)`, `:nth-of-type(...)`, `:nth-last-of-type(...)` | | |
| 105 | | Logical pseudos | `:is(...)`, `:not(...)`, `:where(...)`, `:has(...)`, `:matches(...)` | | |
| 106 | | Other pseudos | `:empty`, `:root`, `:scope` | | |
| 107 | | Stateful pseudos | `:hover`, `:active`, `:visited`, `:focus`, etc. — gated on adapter `is*` hooks. | | |
| 108 | ||
| 109 | ## Custom pseudo-classes | |
| 110 | ||
| 111 | Add your own via `options.pseudos`: | |
| 112 | ||
| 113 | ```ts | |
| 114 | selectAll(':my-thing', root, { | |
| 115 | adapter, | |
| 116 | pseudos: { | |
| 117 | 'my-thing': elem => adapter.getName(elem).startsWith('x-'), | |
| 118 | }, | |
| 119 | }) | |
| 120 | ``` | |
| 121 | ||
| 122 | The value is either a predicate `(elem, arg?) => boolean` or a string | |
| 123 | selector that the matcher will compile. | |
| @@ -0,0 +1,104 @@ | ||
| 1 | # Selector parser API | |
| 2 | ||
| 3 | Replaces `css-what`. Pure-string-to-selector-AST parsing — separate from | |
| 4 | the matcher, so you can analyse selectors (specificity, rewrites, lint | |
| 5 | rules) without running them. | |
| 6 | ||
| 7 | ```ts | |
| 8 | import { isTraversal, parseSelector, stringifySelector } from '@stacksjs/ts-css' | |
| 9 | // or: | |
| 10 | import { parse, stringify, isTraversal } from '@stacksjs/ts-css/what' | |
| 11 | ``` | |
| 12 | ||
| 13 | ## `parse(selector, options?)` | |
| 14 | ||
| 15 | Parse a CSS selector string into a list-of-lists of selector segments. | |
| 16 | ||
| 17 | ```ts | |
| 18 | parse(selector: string, options?: ParseOptions): Selector[][] | |
| 19 | ``` | |
| 20 | ||
| 21 | The outer array is **comma-separated selectors**; the inner arrays are | |
| 22 | **simple selectors and combinators** in source order. | |
| 23 | ||
| 24 | ```ts | |
| 25 | import { parse } from '@stacksjs/ts-css/what' | |
| 26 | ||
| 27 | parse('div > p, .foo') | |
| 28 | // [ | |
| 29 | // [{type:'tag',name:'div'}, {type:'child'}, {type:'tag',name:'p'}], | |
| 30 | // [{type:'attribute',name:'class',action:'element',value:'foo'}], | |
| 31 | // ] | |
| 32 | ``` | |
| 33 | ||
| 34 | ### Options | |
| 35 | ||
| 36 | | Option | Default | Description | | |
| 37 | | ---------------------------- | ------- | -------------------------------------------------------- | | |
| 38 | | `xmlMode` | `false` | Disable HTML quirks (case-insensitive tags / attrs). | | |
| 39 | | `lowerCaseAttributeNames` | `true` | Lowercase attribute names (HTML default). | | |
| 40 | | `lowerCaseTags` | `true` | Lowercase tag names. | | |
| 41 | ||
| 42 | ## `stringify(ast)` | |
| 43 | ||
| 44 | Inverse of `parse` — turn a selector AST back into a string. | |
| 45 | ||
| 46 | ```ts | |
| 47 | stringify(ast: Selector[][]): string | |
| 48 | ``` | |
| 49 | ||
| 50 | ```ts | |
| 51 | import { parse, stringify } from '@stacksjs/ts-css/what' | |
| 52 | ||
| 53 | const ast = parse('div > p:not(.x)') | |
| 54 | stringify(ast) // 'div > p:not(.x)' | |
| 55 | ``` | |
| 56 | ||
| 57 | ## `isTraversal(token)` | |
| 58 | ||
| 59 | Returns `true` if `token` is a combinator (descendant / child / sibling / | |
| 60 | adjacent / parent / column-combinator) and not a simple selector. | |
| 61 | ||
| 62 | ```ts | |
| 63 | isTraversal(token: Selector): boolean | |
| 64 | ``` | |
| 65 | ||
| 66 | ## Segment types | |
| 67 | ||
| 68 | The `Selector` union contains: | |
| 69 | ||
| 70 | ```ts | |
| 71 | type Selector = | |
| 72 | | { type: 'tag', name: string, namespace: string | null } | |
| 73 | | { type: 'universal', namespace: string | null } | |
| 74 | | { type: 'attribute', name: string, action: AttributeAction, value: string, namespace: string | null, ignoreCase: boolean | 'quirks' | null } | |
| 75 | | { type: 'pseudo', name: string, data: string | Selector[][] | null } | |
| 76 | | { type: 'pseudo-element', name: string, data: string | null } | |
| 77 | | { type: 'descendant' } | |
| 78 | | { type: 'child' } | |
| 79 | | { type: 'parent' } | |
| 80 | | { type: 'sibling' } | |
| 81 | | { type: 'adjacent' } | |
| 82 | | { type: 'column-combinator' } | |
| 83 | ``` | |
| 84 | ||
| 85 | `AttributeAction` is one of: | |
| 86 | `'any'`, `'element'`, `'end'`, `'equals'`, `'exists'`, `'hyphen'`, `'not'`, `'start'`. | |
| 87 | ||
| 88 | | `[a]` form | `action` | | |
| 89 | | -------------------- | ------------ | | |
| 90 | | `[a]` | `'exists'` | | |
| 91 | | `[a="x"]` | `'equals'` | | |
| 92 | | `[a~="x"]` | `'element'` | | |
| 93 | | `[a\|="x"]` | `'hyphen'` | | |
| 94 | | `[a^="x"]` | `'start'` | | |
| 95 | | `[a$="x"]` | `'end'` | | |
| 96 | | `[a*="x"]` | `'any'` | | |
| 97 | | `[a!="x"]` | `'not'` | | |
| 98 | ||
| 99 | ## Pseudo-class shape | |
| 100 | ||
| 101 | For pseudo-classes that take a selector list (`:is`, `:not`, `:where`, | |
| 102 | `:has`, `:matches`, `:-moz-any`, `:-webkit-any`), the `data` field is | |
| 103 | **already parsed** as a `Selector[][]`. For other pseudo-classes that take | |
| 104 | a string (e.g. `:nth-child(2n+1)`), `data` is the raw inner string. | |
| @@ -1,33 +1,38 @@ | ||
| 1 | 1 | --- |
| 2 | # https://vitepress.dev/reference/default-theme-home-page | |
| 3 | 2 | layout: home |
| 4 | 3 | |
| 5 | 4 | hero: |
| 6 | name: "ts-starter" | |
| 7 | text: "For a better local environment." | |
| 8 | tagline: "Modern and smart reverse proxy." | |
| 9 | image: /images/logo-white.png | |
| 5 | name: "@stacksjs/ts-css" | |
| 6 | text: "Pure-TypeScript CSS toolkit." | |
| 7 | tagline: "Parser, walker, generator, selector engine, minifier — zero runtime deps." | |
| 10 | 8 | actions: |
| 11 | 9 | - theme: brand |
| 12 | text: Get Started | |
| 10 | text: Get started | |
| 13 | 11 | link: /intro |
| 12 | - theme: alt | |
| 13 | text: Migration guide | |
| 14 | link: /migration | |
| 14 | 15 | - theme: alt |
| 15 | 16 | text: View on GitHub |
| 16 | link: https://github.com/stacksjs/rpx | |
| 17 | link: https://github.com/stacksjs/ts-css | |
| 17 | 18 | |
| 18 | 19 | features: |
| 19 | - title: "Minimal Starter Kit" | |
| 20 | icon: "🔀" | |
| 21 | details: "Bootstrap your next TypeScript project." | |
| 22 | - title: "Documentation" | |
| 23 | icon: "��" | |
| 24 | details: "Easily document your idea/package/library." | |
| 25 | - title: "CLI" | |
| 26 | icon: "🛠" | |
| 27 | details: "Build your own CLI." | |
| 28 | - title: "Binary" | |
| 29 | icon: "��" | |
| 30 | details: "Automatically builds a binary for your project out of your CLI." | |
| 20 | - title: "All-in-one CSS pipeline" | |
| 21 | icon: "🧩" | |
| 22 | details: "Replaces css-tree + css-select + css-what + csso. One install, no transitive deps." | |
| 23 | - title: "Drop-in compatible" | |
| 24 | icon: "��" | |
| 25 | details: "Namespace re-exports keep existing call sites unchanged. Migrate by editing imports." | |
| 26 | - title: "Selector engine" | |
| 27 | icon: "🎯" | |
| 28 | details: "Run any CSS selector against any tree. Adapter pattern preserved verbatim." | |
| 29 | - title: "Minifier" | |
| 30 | icon: "��" | |
| 31 | details: "Number / color / unit compression, declaration dedup, comment cleanup, specificity calc." | |
| 32 | - title: "Bun-first" | |
| 33 | icon: "🥟" | |
| 34 | details: "Built on Bun, runs on Node ≥18. Strict TypeScript with isolatedDeclarations." | |
| 35 | - title: "Small surface, full coverage" | |
| 36 | icon: "✂️" | |
| 37 | details: "Pragmatic — every API real consumers (CSSO, SVGO, lint tools) actually use." | |
| 31 | 38 | --- |
| 32 | ||
| 33 | <Home /> | |
| @@ -1,118 +1,76 @@ | ||
| 1 | 1 | # Install |
| 2 | 2 | |
| 3 | _This is just an example of the ts-starter docs._ | |
| 3 | `@stacksjs/ts-css` is a single package. There are no transitive dependencies. | |
| 4 | 4 | |
| 5 | Installing `rpx` is easy. Simply pull it in via your package manager of choice, or download the binary directly. | |
| 6 | ||
| 7 | ## Package Managers | |
| 8 | ||
| 9 | Choose your package manager of choice: | |
| 5 | ## Package managers | |
| 10 | 6 | |
| 11 | 7 | ::: code-group |
| 12 | 8 | |
| 13 | ```sh [npm] | |
| 14 | npm install --save-dev @stacksjs/rpx | |
| 15 | # npm i -d @stacksjs/rpx | |
| 16 | ||
| 17 | # or, install globally via | |
| 18 | npm i -g @stacksjs/rpx | |
| 19 | ``` | |
| 20 | ||
| 21 | 9 | ```sh [bun] |
| 22 | bun install --dev @stacksjs/rpx | |
| 23 | # bun add --dev @stacksjs/rpx | |
| 24 | # bun i -d @stacksjs/rpx | |
| 10 | bun add @stacksjs/ts-css | |
| 11 | # or, dev dependency | |
| 12 | bun add --dev @stacksjs/ts-css | |
| 13 | ``` | |
| 25 | 14 | |
| 26 | # or, install globally via | |
| 27 | bun add --global @stacksjs/rpx | |
| 15 | ```sh [npm] | |
| 16 | npm install @stacksjs/ts-css | |
| 17 | # or, dev dependency | |
| 18 | npm install --save-dev @stacksjs/ts-css | |
| 28 | 19 | ``` |
| 29 | 20 | |
| 30 | 21 | ```sh [pnpm] |
| 31 | pnpm add --save-dev @stacksjs/rpx | |
| 32 | # pnpm i -d @stacksjs/rpx | |
| 33 | ||
| 34 | # or, install globally via | |
| 35 | pnpm add --global @stacksjs/rpx | |
| 22 | pnpm add @stacksjs/ts-css | |
| 23 | # or, dev dependency | |
| 24 | pnpm add --save-dev @stacksjs/ts-css | |
| 36 | 25 | ``` |
| 37 | 26 | |
| 38 | 27 | ```sh [yarn] |
| 39 | yarn add --dev @stacksjs/rpx | |
| 40 | # yarn i -d @stacksjs/rpx | |
| 41 | ||
| 42 | # or, install globally via | |
| 43 | yarn global add @stacksjs/rpx | |
| 44 | ``` | |
| 45 | ||
| 46 | ```sh [brew] | |
| 47 | brew install rpx # coming soon | |
| 48 | ``` | |
| 49 | ||
| 50 | ```sh [pkgx] | |
| 51 | pkgx rpx # coming soon | |
| 28 | yarn add @stacksjs/ts-css | |
| 52 | 29 | ``` |
| 53 | 30 | |
| 54 | 31 | ::: |
| 55 | 32 | |
| 56 | Read more about how to use it in the Usage section of the documentation. | |
| 57 | ||
| 58 | ## Binaries | |
| 59 | ||
| 60 | Choose the binary that matches your platform and architecture: | |
| 61 | ||
| 62 | ::: code-group | |
| 33 | ## Local development with `bun link` | |
| 63 | 34 | |
| 64 | ```sh [macOS (arm64)] | |
| 65 | # Download the binary | |
| 66 | curl -L https://github.com/stacksjs/rpx/releases/download/v0.9.1/rpx-darwin-arm64 -o rpx | |
| 35 | If you're contributing to ts-css and want to test the changes against | |
| 36 | another project locally, register it once with `bun link`: | |
| 67 | 37 | |
| 68 | # Make it executable | |
| 69 | chmod +x rpx | |
| 38 | ```sh | |
| 39 | # inside the ts-css repo | |
| 40 | bun link | |
| 70 | 41 | |
| 71 | # Move it to your PATH | |
| 72 | mv rpx /usr/local/bin/rpx | |
| 42 | # inside the consumer project | |
| 43 | bun link @stacksjs/ts-css | |
| 73 | 44 | ``` |
| 74 | 45 | |
| 75 | ```sh [macOS (x64)] | |
| 76 | # Download the binary | |
| 77 | curl -L https://github.com/stacksjs/rpx/releases/download/v0.9.1/rpx-darwin-x64 -o rpx | |
| 46 | That's how [`ts-svg`](https://github.com/stacksjs/ts-svg) consumes ts-css | |
| 47 | during development — its `package.json` carries | |
| 48 | `"@stacksjs/ts-css": "link:@stacksjs/ts-css"`. | |
| 78 | 49 | |
| 79 | # Make it executable | |
| 80 | chmod +x rpx | |
| 50 | ## CLI binary (optional) | |
| 81 | 51 | |
| 82 | # Move it to your PATH | |
| 83 | mv rpx /usr/local/bin/rpx | |
| 84 | ``` | |
| 85 | ||
| 86 | ```sh [Linux (arm64)] | |
| 87 | # Download the binary | |
| 88 | curl -L https://github.com/stacksjs/rpx/releases/download/v0.9.1/rpx-linux-arm64 -o rpx | |
| 89 | ||
| 90 | # Make it executable | |
| 91 | chmod +x rpx | |
| 52 | The `ts-css` CLI ships with the package and is exposed via Bun's `bin` | |
| 53 | field. After installing, you can run: | |
| 92 | 54 | |
| 93 | # Move it to your PATH | |
| 94 | mv rpx /usr/local/bin/rpx | |
| 55 | ```sh | |
| 56 | bunx ts-css minify input.css | |
| 95 | 57 | ``` |
| 96 | 58 | |
| 97 | ```sh [Linux (x64)] | |
| 98 | # Download the binary | |
| 99 | curl -L https://github.com/stacksjs/rpx/releases/download/v0.9.1/rpx-linux-x64 -o rpx | |
| 59 | For a standalone binary, build from source: | |
| 100 | 60 | |
| 101 | # Make it executable | |
| 102 | chmod +x rpx | |
| 103 | ||
| 104 | # Move it to your PATH | |
| 105 | mv rpx /usr/local/bin/rpx | |
| 61 | ```sh | |
| 62 | bun run compile # builds bin/ts-css for your platform | |
| 63 | bun run compile:all # cross-compile for darwin/linux/windows | |
| 106 | 64 | ``` |
| 107 | 65 | |
| 108 | ```sh [Windows (x64)] | |
| 109 | # Download the binary | |
| 110 | curl -L https://github.com/stacksjs/rpx/releases/download/v0.9.1/rpx-windows-x64.exe -o rpx.exe | |
| 66 | ## Verifying the install | |
| 67 | ||
| 68 | ```ts | |
| 69 | import { minify, parse } from '@stacksjs/ts-css' | |
| 111 | 70 | |
| 112 | # Move it to your PATH (adjust the path as needed) | |
| 113 | move rpx.exe C:\Windows\System32\rpx.exe | |
| 71 | console.log(minify('.a { color: #aabbcc }').css) | |
| 72 | // → ".a{color:#abc}" | |
| 114 | 73 | ``` |
| 115 | 74 | |
| 116 | ::: tip | |
| 117 | You can also find the `rpx` binaries in GitHub [releases](https://github.com/stacksjs/rpx/releases). | |
| 118 | ::: | |
| 75 | If that prints the minified output, you're set. Continue to | |
| 76 | [**Getting started**](./intro.md). | |
| @@ -1,93 +1,99 @@ | ||
| 1 | <p align="center"><img src="https://github.com/stacksjs/rpx/blob/main/.github/art/cover.jpg?raw=true" alt="Social Card of this repo"></p> | |
| 1 | # Getting started | |
| 2 | 2 | |
| 3 | # A Better Developer Experience | |
| 3 | `@stacksjs/ts-css` is a pure-TypeScript CSS toolkit — a parser, walker, | |
| 4 | generator, selector engine, and minifier — all in one **zero-dependency** | |
| 5 | package. It's a drop-in replacement for the four-library quartet most CSS | |
| 6 | pipelines pull in: | |
| 4 | 7 | |
| 5 | > A TypeScript Starter Kit that will help you bootstrap your next project without minimal opinion. | |
| 8 | | Replaces | Provides | | |
| 9 | | ------------- | ----------------------------------------------------- | | |
| 10 | | `css-tree` | Tokenizer, parser, walker, generator, clone, `List` | | |
| 11 | | `css-what` | Selector parser & stringifier | | |
| 12 | | `css-select` | `selectAll` / `selectOne` / `is` against any tree | | |
| 13 | | `csso` | Minifier + `syntax.specificity` | | |
| 6 | 14 | |
| 7 | # bun-ts-starter | |
| 15 | If you've worked with any of those four libraries before, the migration | |
| 16 | is one block of `import` lines (see the [migration guide](./migration.md)). | |
| 8 | 17 | |
| 9 | This is an opinionated TypeScript Starter kit to help kick-start development of your next Bun package. | |
| 10 | ||
| 11 | ## Get Started | |
| 12 | ||
| 13 | It's rather simple to get your package development started: | |
| 18 | ## Install | |
| 14 | 19 | |
| 15 | 20 | ```bash |
| 16 | # you may use this GitHub template or the following command: | |
| 17 | bunx degit stacksjs/ts-starter my-pkg | |
| 18 | cd my-pkg | |
| 19 | ||
| 20 | # if you don't have pnpm installed, run `npm i -g pnpm` | |
| 21 | bun i # install all deps | |
| 22 | bun run build # builds the library for production-ready use | |
| 23 | ||
| 24 | # after you have successfully committed, you may create a "release" | |
| 25 | bun run release # automates git commits, versioning, and changelog generations | |
| 21 | bun add @stacksjs/ts-css | |
| 22 | # or | |
| 23 | npm i @stacksjs/ts-css | |
| 26 | 24 | ``` |
| 27 | 25 | |
| 28 | _Check out the package.json scripts for more commands._ | |
| 29 | ||
| 30 | ### Developer Experience (DX) | |
| 31 | ||
| 32 | This Starter Kit comes pre-configured with the following: | |
| 33 | ||
| 34 | - [Powerful Build Process](https://github.com/oven-sh/bun) - via Bun | |
| 35 | - [Fully Typed APIs](https://www.typescriptlang.org/) - via TypeScript | |
| 36 | - [Documentation-ready](https://vitepress.dev/) - via VitePress | |
| 37 | - [CLI & Binary](https://www.npmjs.com/package/bunx) - via Bun & CAC | |
| 38 | - [Be a Good Commitizen](https://www.npmjs.com/package/git-cz) - pre-configured Commitizen & git-cz setup to simplify semantic git commits, versioning, and changelog generations | |
| 39 | - [Built With Testing In Mind](https://bun.sh/docs/cli/test) - pre-configured unit-testing powered by [Bun](https://bun.sh/docs/cli/test) | |
| 40 | - [Renovate](https://renovatebot.com/) - optimized & automated PR dependency updates | |
| 41 | - [ESLint](https://eslint.org/) - for code linting _(and formatting)_ | |
| 42 | - [GitHub Actions](https://github.com/features/actions) - runs your CI _(fixes code style issues, tags releases & creates its changelogs, runs the test suite, etc.)_ | |
| 43 | ||
| 44 | ## Changelog | |
| 26 | ## Your first parse | |
| 45 | 27 | |
| 46 | Please see our [releases](https://github.com/stacksjs/stacks/releases) page for more information on what has changed recently. | |
| 28 | ```ts | |
| 29 | import { generate, parse, walk } from '@stacksjs/ts-css' | |
| 47 | 30 | |
| 48 | ## Stargazers | |
| 31 | const ast = parse('.foo { color: red }') | |
| 49 | 32 | |
| 50 | [](https://starchart.cc/stacksjs/ts-starter) | |
| 33 | walk(ast, (node) => { | |
| 34 | if (node.type === 'Declaration') | |
| 35 | console.log(node.property) | |
| 36 | }) | |
| 37 | // → "color" | |
| 51 | 38 | |
| 52 | ## Contributing | |
| 53 | ||
| 54 | Please review the [Contributing Guide](https://github.com/stacksjs/contributing) for details. | |
| 39 | console.log(generate(ast)) | |
| 40 | // → ".foo{color:red}" | |
| 41 | ``` | |
| 55 | 42 | |
| 56 | ## Community | |
| 43 | That's the full pipeline: parse → walk/transform → generate. | |
| 57 | 44 | |
| 58 | For help, discussion about best practices, or any other conversation that would benefit from being searchable: | |
| 45 | ## Exploring the AST | |
| 59 | 46 | |
| 60 | [Discussions on GitHub](https://github.com/stacksjs/stacks/discussions) | |
| 47 | Pass a `context` to parse a fragment instead of a full stylesheet: | |
| 61 | 48 | |
| 62 | For casual chit-chat with others using this package: | |
| 49 | ```ts | |
| 50 | parse('color: red', { context: 'declaration' }) | |
| 51 | parse('color: red; font-size: 12px', { context: 'declarationList' }) | |
| 52 | parse('.a > .b', { context: 'selector' }) | |
| 53 | parse('@media (min-width: 800px)', { context: 'atrulePrelude' }) | |
| 54 | ``` | |
| 63 | 55 | |
| 64 | [Join the Stacks Discord Server](https://discord.gg/stacksjs) | |
| 56 | The full set of contexts is in the [parser API reference](./api/parse.md). | |
| 65 | 57 | |
| 66 | ## Postcardware | |
| 58 | ## Selecting nodes | |
| 67 | 59 | |
| 68 | Two things are true: Stacks OSS will always stay open-source, and we do love to receive postcards from wherever Stacks is used! �� _We also publish them on our website. And thank you, Spatie_ | |
| 60 | `ts-css` ships a tree-agnostic selector engine. Provide an `Adapter` for | |
| 61 | your tree and you can run any CSS selector against it: | |
| 69 | 62 | |
| 70 | Our address: Stacks.js, 12665 Village Ln #2306, Playa Vista, CA 90094 | |
| 63 | ```ts | |
| 64 | import { selectAll } from '@stacksjs/ts-css' | |
| 71 | 65 | |
| 72 | ## Sponsors | |
| 66 | const matches = selectAll('p > span.foo:not(.disabled)', root, { | |
| 67 | adapter: myAdapter, | |
| 68 | xmlMode: true, | |
| 69 | }) | |
| 70 | ``` | |
| 73 | 71 | |
| 74 | We would like to extend our thanks to the following sponsors for funding Stacks development. If you are interested in becoming a sponsor, please reach out to us. | |
| 72 | [Full selector matcher API →](./api/select.md) | |
| 75 | 73 | |
| 76 | - [JetBrains](https://www.jetbrains.com/) | |
| 77 | - [The Solana Foundation](https://solana.com/) | |
| 74 | ## Minifying | |
| 78 | 75 | |
| 79 | ## Credits | |
| 76 | ```ts | |
| 77 | import { minify } from '@stacksjs/ts-css' | |
| 80 | 78 | |
| 81 | - [Chris Breuer](https://github.com/chrisbbreuer) | |
| 82 | - [All Contributors](https://github.com/stacksjs/rpx/graphs/contributors) | |
| 79 | minify('.a { margin: 0px; color: #aabbcc }').css | |
| 80 | // → ".a{margin:0;color:#abc}" | |
| 81 | ``` | |
| 83 | 82 | |
| 84 | ## License | |
| 83 | [Full minifier API →](./api/optimize.md) | |
| 85 | 84 | |
| 86 | The MIT License (MIT). Please see [LICENSE](https://github.com/stacksjs/ts-starter/tree/main/LICENSE.md) for more information. | |
| 85 | ## CLI | |
| 87 | 86 | |
| 88 | Made with �� | |
| 87 | ```bash | |
| 88 | ts-css minify input.css > output.css | |
| 89 | ts-css parse input.css | jq | |
| 90 | ts-css format input.css | |
| 91 | ``` | |
| 89 | 92 | |
| 90 | <!-- Badges --> | |
| 93 | ## Where to next | |
| 91 | 94 | |
| 92 | <!-- [codecov-src]: https://img.shields.io/codecov/c/gh/stacksjs/rpx/main?style=flat-square | |
| 93 | [codecov-href]: https://codecov.io/gh/stacksjs/rpx --> | |
| 95 | - [**Parser** API](./api/parse.md) — `parse`, `walk`, `generate`, `clone`, `List` | |
| 96 | - [**Selector parser** API](./api/what.md) — `parse`, `stringify`, `isTraversal` | |
| 97 | - [**Selector matcher** API](./api/select.md) — `selectAll`, `selectOne`, `is` | |
| 98 | - [**Optimizer** API](./api/optimize.md) — `minify`, `minifyBlock`, `specificity` | |
| 99 | - [**Migration** guide](./migration.md) — moving off the four-library setup | |
| @@ -0,0 +1,125 @@ | ||
| 1 | # Migration guide | |
| 2 | ||
| 3 | Migrating from `css-tree` / `css-select` / `css-what` / `csso` to | |
| 4 | `@stacksjs/ts-css` is one block of `import` lines. | |
| 5 | ||
| 6 | ## The diff | |
| 7 | ||
| 8 | ```diff | |
| 9 | - import * as csstree from 'css-tree' | |
| 10 | - import * as csswhat from 'css-what' | |
| 11 | - import { is, selectAll, selectOne } from 'css-select' | |
| 12 | - import * as csso from 'csso' | |
| 13 | ||
| 14 | + import { | |
| 15 | + csstree, // namespace mirror of css-tree | |
| 16 | + cssWhat as csswhat, // namespace mirror of css-what | |
| 17 | + csso, // namespace mirror of csso | |
| 18 | + is, selectAll, selectOne, // top-level css-select APIs | |
| 19 | + } from '@stacksjs/ts-css' | |
| 20 | ``` | |
| 21 | ||
| 22 | That's it. Every method real consumers use is preserved. | |
| 23 | ||
| 24 | ## Per-library compatibility | |
| 25 | ||
| 26 | ### `css-tree` | |
| 27 | ||
| 28 | | Surface | Compat | | |
| 29 | | -------------------------------------------- | ------ | | |
| 30 | | `parse(source, opts)` — all `context` modes | ✅ | | |
| 31 | | `walk(ast, cb)` & `walk(ast, { visit, enter, leave })` | ✅ | | |
| 32 | | `walk.skip` / `walk.stop` | ✅ | | |
| 33 | | `this.atrule` / `this.rule` context inside callbacks | ✅ | | |
| 34 | | `generate(node)` | ✅ | | |
| 35 | | `clone(node)` | ✅ | | |
| 36 | | `List` class — `forEach(data, item, list)`, `insert`, `remove`, `replace`, `prepend`, `append`, `prependList`, `appendList`, `insertList`, `createItem`, `first`, `last`, `isEmpty` | ✅ | | |
| 37 | | Lexer (`syntax.lexer.match*`, `syntax.match*`) | ❌ — see below | | |
| 38 | ||
| 39 | ### `css-what` | |
| 40 | ||
| 41 | | Surface | Compat | | |
| 42 | | -------------------- | ------ | | |
| 43 | | `parse(selector)` | ✅ | | |
| 44 | | `stringify(ast)` | ✅ | | |
| 45 | | `isTraversal(token)` | ✅ | | |
| 46 | | Selector segment shape (incl. `ignoreCase: 'quirks'`) | ✅ | | |
| 47 | ||
| 48 | ### `css-select` | |
| 49 | ||
| 50 | | Surface | Compat | | |
| 51 | | ------------------------------------------- | ------ | | |
| 52 | | `selectAll(sel, root, opts)` | ✅ | | |
| 53 | | `selectOne(sel, root, opts)` | ✅ | | |
| 54 | | `is(node, sel, opts)` | ✅ | | |
| 55 | | `compile(sel, opts)` | ✅ | | |
| 56 | | `Adapter` interface | ✅ — same shape, including state hooks (`isActive`, etc.) | | |
| 57 | | `pseudos` extension | ✅ | | |
| 58 | | `:has`, `:is`, `:not`, `:where`, `:matches` | ✅ | | |
| 59 | | `:nth-child`, `:nth-last-child`, `:nth-of-type`, `:nth-last-of-type`, `:first-child`, `:last-child`, `:only-child`, `:first-of-type`, `:last-of-type`, `:only-of-type` | ✅ | | |
| 60 | | `:empty`, `:root`, `:scope` | ✅ | | |
| 61 | | Stateful pseudos via adapter hooks | ✅ | | |
| 62 | ||
| 63 | ### `csso` | |
| 64 | ||
| 65 | | Surface | Compat | | |
| 66 | | -------------------------------------------- | ------ | | |
| 67 | | `minify(source, opts)` | ✅ | | |
| 68 | | `minifyBlock(source, opts)` | ✅ | | |
| 69 | | `syntax.specificity(node)` | ✅ | | |
| 70 | | Restructuring across rules / selectors | ❌ — declaration-level only | | |
| 71 | ||
| 72 | ## What's intentionally not ported | |
| 73 | ||
| 74 | We dropped two large chunks of the original libs that real consumers | |
| 75 | rarely need: | |
| 76 | ||
| 77 | ### `css-tree`'s lexer (`syntax.lexer`) | |
| 78 | ||
| 79 | `css-tree` ships ~3000 lines of CSS spec data that lets you ask "is the | |
| 80 | string `red 1px solid` a valid `<border>` shorthand?". This is great for | |
| 81 | linters and editors, but downstream consumers like CSSO, SVGO, and most | |
| 82 | formatters never touch it. | |
| 83 | ||
| 84 | The AST that ts-css produces is **rich enough** for you to add lexer | |
| 85 | behaviour back on top — every node carries its source slice and a typed | |
| 86 | discriminated union, so a value-type checker is a straightforward | |
| 87 | external library. | |
| 88 | ||
| 89 | ### CSSO's restructuring pass | |
| 90 | ||
| 91 | `csso` does heroic work to merge declarations across rules: | |
| 92 | ||
| 93 | ```css | |
| 94 | .a { color: red; padding: 4px } | |
| 95 | .b { color: red } | |
| 96 | .c { padding: 4px } | |
| 97 | ``` | |
| 98 | ||
| 99 | → rewritten to share declarations between rules. Beyond gzip's reach | |
| 100 | this saves **single-digit percent**, costs ~1000 lines, and breaks subtly | |
| 101 | in edge cases (specificity, source-order dependencies, vendor-prefix | |
| 102 | collisions). ts-css does declaration-level minification (numbers, | |
| 103 | colors, dedup-within-rule) which catches the bulk of the bytes. | |
| 104 | ||
| 105 | If you have a use case where restructuring matters, file an issue — | |
| 106 | adding it back is straightforward, but we'd rather not ship dead code. | |
| 107 | ||
| 108 | ## Real-world swap: `ts-svg` | |
| 109 | ||
| 110 | [`ts-svg`](https://github.com/stacksjs/ts-svg)'s SVGO port uses ts-css. | |
| 111 | The migration was 7 files and looked like: | |
| 112 | ||
| 113 | ```diff | |
| 114 | - import * as csstree from 'css-tree' | |
| 115 | - import * as csswhat from 'css-what' | |
| 116 | - import { is, selectAll, selectOne } from 'css-select' | |
| 117 | - import { syntax } from 'csso' | |
| 118 | - import * as csso from 'csso' | |
| 119 | ||
| 120 | + import { csstree, cssWhat as csswhat, csso, is, selectAll, selectOne } from '@stacksjs/ts-css' | |
| 121 | + const { syntax } = csso | |
| 122 | ``` | |
| 123 | ||
| 124 | The full SVGO test suite (82 tests) passes after the swap with no | |
| 125 | behaviour changes, and removes 4 transitive dep trees from `node_modules`. | |
| @@ -1,89 +1,162 @@ | ||
| 1 | # Get Started | |
| 1 | # Usage | |
| 2 | 2 | |
| 3 | There are two ways of using this reverse proxy: _as a library or as a CLI._ | |
| 3 | `@stacksjs/ts-css` is a library first, with a small CLI on top. Most | |
| 4 | consumers import it as a library; the CLI is a convenience wrapper for | |
| 5 | ad-hoc minification and AST inspection. | |
| 4 | 6 | |
| 5 | 7 | ## Library |
| 6 | 8 | |
| 7 | Given the npm package is installed: | |
| 9 | ### Full pipeline: parse → walk → generate | |
| 8 | 10 | |
| 9 | 11 | ```ts |
| 10 | import type { TlsConfig } from '@stacksjs/rpx' | |
| 11 | import { startProxy } from '@stacksjs/rpx' | |
| 12 | import { generate, parse, walk } from '@stacksjs/ts-css' | |
| 13 | ||
| 14 | const ast = parse(` | |
| 15 | .foo { | |
| 16 | color: red; | |
| 17 | background: url("/assets/bg.png"); | |
| 18 | } | |
| 19 | `) | |
| 20 | ||
| 21 | walk(ast, { | |
| 22 | visit: 'Url', | |
| 23 | enter(url) { | |
| 24 | console.log('asset:', url.value) | |
| 25 | }, | |
| 26 | }) | |
| 12 | 27 | |
| 13 | export interface CleanupConfig { | |
| 14 | hosts: boolean // clean up /etc/hosts, defaults to false | |
| 15 | certs: boolean // clean up certificates, defaults to false | |
| 16 | } | |
| 28 | console.log(generate(ast)) | |
| 29 | ``` | |
| 30 | ||
| 31 | ### Selector matching against your tree | |
| 17 | 32 | |
| 18 | export interface ReverseProxyConfig { | |
| 19 | from: string // domain to proxy from, defaults to localhost:3000 | |
| 20 | to: string // domain to proxy to, defaults to stacks.localhost | |
| 21 | cleanUrls?: boolean // removes the .html extension from URLs, defaults to false | |
| 22 | https: boolean | TlsConfig // automatically uses https, defaults to true, also redirects http to https | |
| 23 | cleanup?: boolean | CleanupConfig // automatically cleans up /etc/hosts, defaults to false | |
| 24 | verbose: boolean // log verbose output, defaults to false | |
| 33 | ```ts | |
| 34 | import { selectAll } from '@stacksjs/ts-css' | |
| 35 | ||
| 36 | interface MyEl { | |
| 37 | type: 'el' | |
| 38 | name: string | |
| 39 | attrs: Record<string, string> | |
| 40 | children: MyEl[] | |
| 41 | parent: MyEl | null | |
| 25 | 42 | } |
| 26 | 43 | |
| 27 | const config: ReverseProxyOptions = { | |
| 28 | from: 'localhost:3000', | |
| 29 | to: 'my-docs.localhost', | |
| 30 | cleanUrls: true, | |
| 31 | https: true, | |
| 32 | cleanup: false, | |
| 44 | const adapter = { | |
| 45 | isTag: (n: any): n is MyEl => n?.type === 'el', | |
| 46 | getName: (e: MyEl) => e.name, | |
| 47 | getParent: (e: MyEl) => e.parent, | |
| 48 | getChildren: (e: MyEl) => e.children, | |
| 49 | getSiblings: (e: MyEl) => e.parent?.children ?? [e], | |
| 50 | getAttributeValue: (e: MyEl, n: string) => e.attrs[n], | |
| 51 | hasAttrib: (e: MyEl, n: string) => n in e.attrs, | |
| 52 | getText: () => '', | |
| 53 | removeSubsets: (xs: any[]) => xs, | |
| 54 | existsOne: (test: (e: MyEl) => boolean, xs: any[]): boolean => | |
| 55 | xs.some(x => adapter.isTag(x) && (test(x) || adapter.existsOne(test, x.children))), | |
| 56 | findAll: (test: (e: MyEl) => boolean, xs: any[]): MyEl[] => { | |
| 57 | const out: MyEl[] = [] | |
| 58 | for (const x of xs) { | |
| 59 | if (adapter.isTag(x)) { | |
| 60 | if (test(x)) out.push(x) | |
| 61 | out.push(...adapter.findAll(test, x.children)) | |
| 62 | } | |
| 63 | } | |
| 64 | return out | |
| 65 | }, | |
| 66 | findOne: (test: (e: MyEl) => boolean, xs: any[]): MyEl | null => { | |
| 67 | for (const x of xs) { | |
| 68 | if (adapter.isTag(x)) { | |
| 69 | if (test(x)) return x | |
| 70 | const r = adapter.findOne(test, x.children) | |
| 71 | if (r) return r | |
| 72 | } | |
| 73 | } | |
| 74 | return null | |
| 75 | }, | |
| 33 | 76 | } |
| 34 | 77 | |
| 35 | startProxy(config) | |
| 78 | const matches = selectAll('p > .foo:not(.disabled)', root, { | |
| 79 | adapter, | |
| 80 | xmlMode: true, | |
| 81 | }) | |
| 36 | 82 | ``` |
| 37 | 83 | |
| 38 | In case you are trying to start multiple proxies, you may use this configuration: | |
| 84 | ### Minify a stylesheet | |
| 39 | 85 | |
| 40 | 86 | ```ts |
| 41 | // reverse-proxy.config.{ts,js} | |
| 42 | import type { ReverseProxyOptions } from '@stacksjs/rpx' | |
| 43 | import os from 'node:os' | |
| 44 | import path from 'node:path' | |
| 45 | ||
| 46 | const config: ReverseProxyOptions = { | |
| 47 | https: { // https: true -> also works with sensible defaults | |
| 48 | caCertPath: path.join(os.homedir(), '.stacks', 'ssl', `stacks.localhost.ca.crt`), | |
| 49 | certPath: path.join(os.homedir(), '.stacks', 'ssl', `stacks.localhost.crt`), | |
| 50 | keyPath: path.join(os.homedir(), '.stacks', 'ssl', `stacks.localhost.crt.key`), | |
| 51 | }, | |
| 87 | import { minify } from '@stacksjs/ts-css' | |
| 52 | 88 | |
| 53 | cleanup: { | |
| 54 | hosts: true, | |
| 55 | certs: false, | |
| 56 | }, | |
| 89 | const { css } = minify(input, { | |
| 90 | comments: 'exclamation', // keep `/*!*/` comments | |
| 91 | }) | |
| 92 | ``` | |
| 57 | 93 | |
| 58 | proxies: [ | |
| 59 | { | |
| 60 | from: 'localhost:5173', | |
| 61 | to: 'my-app.localhost', | |
| 62 | cleanUrls: true, | |
| 63 | }, | |
| 64 | { | |
| 65 | from: 'localhost:5174', | |
| 66 | to: 'my-api.local', | |
| 67 | }, | |
| 68 | ], | |
| 69 | ||
| 70 | verbose: true, | |
| 71 | } | |
| 94 | ### Compute selector specificity | |
| 72 | 95 | |
| 73 | export default config | |
| 96 | ```ts | |
| 97 | import { parse, syntax } from '@stacksjs/ts-css' | |
| 98 | ||
| 99 | const sel = parse('#a.b div', { context: 'selector' }) | |
| 100 | syntax.specificity(sel) // [1, 1, 1] | |
| 74 | 101 | ``` |
| 75 | 102 | |
| 76 | 103 | ## CLI |
| 77 | 104 | |
| 78 | ```bash | |
| 79 | rpx --from localhost:3000 --to my-project.localhost | |
| 80 | rpx --from localhost:8080 --to my-project.test --keyPath ./key.pem --certPath ./cert.pem | |
| 81 | rpx --help | |
| 82 | rpx --version | |
| 105 | ```sh | |
| 106 | ts-css <command> <file> | |
| 107 | ``` | |
| 108 | ||
| 109 | | Command | Output | | |
| 110 | | --------------- | ---------------------------------------------------- | | |
| 111 | | `minify <file>` | Minified CSS on stdout. | | |
| 112 | | `parse <file>` | The parsed AST as JSON on stdout. | | |
| 113 | | `format <file>` | Round-trip through parser/generator (deterministic). | | |
| 114 | | `version` | Print the installed CLI version. | | |
| 115 | ||
| 116 | ### Examples | |
| 117 | ||
| 118 | ```sh | |
| 119 | # Minify a stylesheet, write to disk | |
| 120 | ts-css minify src/app.css > dist/app.css | |
| 121 | ||
| 122 | # Inspect the AST of a snippet | |
| 123 | echo '.a { color: red }' > /tmp/snip.css | |
| 124 | ts-css parse /tmp/snip.css | jq '.children[0]' | |
| 125 | ||
| 126 | # Format-only round trip | |
| 127 | ts-css format src/app.css | |
| 83 | 128 | ``` |
| 84 | 129 | |
| 85 | ## Testing | |
| 130 | ### Flags | |
| 131 | ||
| 132 | `ts-css minify`: | |
| 133 | ||
| 134 | | Flag | Description | | |
| 135 | | ---------------- | ------------------------------------ | | |
| 136 | | `--no-comments` | Strip `/*!*/` comments too. | | |
| 137 | | `--block` | Treat input as a `style="…"` body. | | |
| 138 | ||
| 139 | `ts-css parse`: | |
| 140 | ||
| 141 | | Flag | Description | | |
| 142 | | -------------- | --------------------------------------------------- | | |
| 143 | | `--positions` | Track source-location info on every AST node. | | |
| 86 | 144 | |
| 87 | ```bash | |
| 88 | bun test | |
| 145 | ## Configuration | |
| 146 | ||
| 147 | You can drop a `css.config.ts` at the project root for defaults: | |
| 148 | ||
| 149 | ```ts | |
| 150 | // css.config.ts | |
| 151 | import type { CSSOptions } from '@stacksjs/ts-css' | |
| 152 | ||
| 153 | const config: CSSOptions = { | |
| 154 | floatPrecision: 3, | |
| 155 | verbose: false, | |
| 156 | } | |
| 157 | ||
| 158 | export default config | |
| 89 | 159 | ``` |
| 160 | ||
| 161 | `ts-css` loads it via [`bunfig`](https://github.com/stacksjs/bunfig) — the | |
| 162 | same loader used across the Stacks toolchain. | |
| @@ -1,16 +1,63 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * Run all compressors over the AST in place. |
| 3 | * | |
| 4 | * Identifier ↔ Hash conversions happen only when the surrounding | |
| 5 | * declaration's property is known to accept a color (so `font-family: red` | |
| 6 | * isn't treated as a color, and `color: red` is). The list+item args | |
| 7 | * from the walker callback let us swap nodes in place without rebuilding | |
| 8 | * the parent list. | |
| 3 | 9 | */ |
| 4 | 10 | |
| 5 | import type { CssNode } from '../../parse' | |
| 11 | import type { CssNode, HashNode, Identifier } from '../../parse' | |
| 6 | 12 | import { walk } from '../../parse' |
| 7 | 13 | import { colorNameToHex, hexToShortName, shortenHex } from './color' |
| 8 | 14 | import { compressDimension, compressNumber, compressPercentage } from './number' |
| 9 | import { compressString } from './string' | |
| 10 | 15 | import { compressUrl } from './url' |
| 11 | 16 | |
| 17 | const COLOR_PROPERTIES: ReadonlySet<string> = new Set([ | |
| 18 | 'color', | |
| 19 | 'background', | |
| 20 | 'background-color', | |
| 21 | 'border', | |
| 22 | 'border-color', | |
| 23 | 'border-top', | |
| 24 | 'border-top-color', | |
| 25 | 'border-right', | |
| 26 | 'border-right-color', | |
| 27 | 'border-bottom', | |
| 28 | 'border-bottom-color', | |
| 29 | 'border-left', | |
| 30 | 'border-left-color', | |
| 31 | 'border-block', | |
| 32 | 'border-block-color', | |
| 33 | 'border-block-start-color', | |
| 34 | 'border-block-end-color', | |
| 35 | 'border-inline', | |
| 36 | 'border-inline-color', | |
| 37 | 'border-inline-start-color', | |
| 38 | 'border-inline-end-color', | |
| 39 | 'outline', | |
| 40 | 'outline-color', | |
| 41 | 'caret-color', | |
| 42 | 'fill', | |
| 43 | 'stroke', | |
| 44 | 'flood-color', | |
| 45 | 'lighting-color', | |
| 46 | 'stop-color', | |
| 47 | 'column-rule', | |
| 48 | 'column-rule-color', | |
| 49 | 'text-decoration', | |
| 50 | 'text-decoration-color', | |
| 51 | 'text-emphasis', | |
| 52 | 'text-emphasis-color', | |
| 53 | 'text-shadow', | |
| 54 | 'box-shadow', | |
| 55 | 'accent-color', | |
| 56 | 'scrollbar-color', | |
| 57 | ]) | |
| 58 | ||
| 12 | 59 | export function compressTree(ast: CssNode): void { |
| 13 | walk(ast, (node) => { | |
| 60 | walk(ast, function compressVisitor(node, item, list) { | |
| 14 | 61 | switch (node.type) { |
| 15 | 62 | case 'Number': |
| 16 | 63 | node.value = compressNumber(node.value) |
| @@ -25,36 +72,38 @@ export function compressTree(ast: CssNode): void { | ||
| 25 | 72 | return |
| 26 | 73 | } |
| 27 | 74 | case 'Hash': { |
| 75 | // Hex normalisation runs unconditionally — `#aabbcc` → `#abc`. | |
| 28 | 76 | const newName = shortenHex(`#${node.name}`).slice(1) |
| 29 | 77 | node.name = newName |
| 30 | const short = hexToShortName(`#${newName}`) | |
| 31 | if (short) { | |
| 32 | // Replace this Hash by an Identifier in caller-managed list — done | |
| 33 | // by a follow-up pass; here we just leave the shortened hex. | |
| 34 | void short | |
| 78 | // If a shorter named keyword exists for the resulting hex, swap | |
| 79 | // the Hash out for an Identifier (only inside a color property). | |
| 80 | if (item && list && this.declaration && COLOR_PROPERTIES.has(this.declaration.property.toLowerCase())) { | |
| 81 | const short = hexToShortName(`#${newName}`) | |
| 82 | if (short && short.length < newName.length + 1) { | |
| 83 | const ident: Identifier = { type: 'Identifier', name: short, loc: node.loc } | |
| 84 | list.replace(item, list.createItem(ident)) | |
| 85 | } | |
| 35 | 86 | } |
| 36 | 87 | return |
| 37 | 88 | } |
| 38 | 89 | case 'Identifier': { |
| 39 | // Color name → hex (when shorter) | |
| 90 | // Named color → short hex when the substitution is *strictly* shorter. | |
| 91 | // `blue` (4) and `#00f` (4) tie — we keep the keyword (more readable | |
| 92 | // and survives gzip the same). | |
| 93 | if (!item || !list || !this.declaration) | |
| 94 | return | |
| 95 | if (!COLOR_PROPERTIES.has(this.declaration.property.toLowerCase())) | |
| 96 | return | |
| 40 | 97 | const hex = colorNameToHex(node.name) |
| 41 | 98 | if (hex && hex.length < node.name.length) { |
| 42 | // Replace identifier with a shortened hex by mutating in place is | |
| 43 | // not possible (Identifier vs Hash differ in shape) — leave alone | |
| 44 | // unless we're in a property where colors are expected. The csso | |
| 45 | // library does this with full property/value type knowledge; we | |
| 46 | // settle for the safer hex→name reverse direction. | |
| 47 | void hex | |
| 99 | const hashNode: HashNode = { type: 'Hash', name: hex.slice(1), loc: node.loc } | |
| 100 | list.replace(item, list.createItem(hashNode)) | |
| 48 | 101 | } |
| 49 | 102 | return |
| 50 | 103 | } |
| 51 | case 'String': | |
| 52 | node.value = node.value | |
| 53 | // re-rendered by generator using its own quoting | |
| 54 | return | |
| 55 | 104 | case 'Url': |
| 56 | node.value = node.value | |
| 57 | // generator already picks quoting via compressUrl shape — re-call: | |
| 105 | // Generator picks quoting via compressUrl — strip the quotes | |
| 106 | // we'd produce so the round-trip is stable. | |
| 58 | 107 | node.value = compressUrl(node.value).replace(/^"|"$/g, '') |
| 59 | 108 | return |
| 60 | 109 | } |
| @@ -6,21 +6,45 @@ | ||
| 6 | 6 | * b = class / attribute / pseudo-class selectors |
| 7 | 7 | * c = type / pseudo-element selectors |
| 8 | 8 | * |
| 9 | * Operates on css-tree-style `Selector` nodes (children list of simple | |
| 10 | * selectors + combinators). Mirrors `csso.syntax.specificity` so SVGO's | |
| 11 | * inlineStyles plugin can swap deps cleanly. | |
| 9 | * Accepts either: | |
| 10 | * - a css-tree-style `Selector` / `SelectorList` AST node (csso shape), OR | |
| 11 | * - a `Selector[][]` returned by `cssWhat.parse(string)` — so callers | |
| 12 | * can pipe `cssWhat.parse(...)` straight into `syntax.specificity(...)`. | |
| 12 | 13 | */ |
| 13 | 14 | |
| 14 | 15 | import type { CssNode } from '../parse' |
| 16 | import type { Selector as WhatSelector } from '../what' | |
| 15 | 17 | |
| 16 | 18 | export type Specificity = [number, number, number] |
| 17 | 19 | |
| 18 | export function specificity(node: CssNode): Specificity { | |
| 20 | export function specificity(input: CssNode | WhatSelector[][] | WhatSelector[] | string): Specificity { | |
| 19 | 21 | const result: Specificity = [0, 0, 0] |
| 20 | visit(node, result) | |
| 22 | ||
| 23 | if (typeof input === 'string') { | |
| 24 | // Lazy import to avoid a hard cycle. | |
| 25 | const { parse: parseSelector } = require('../what') as typeof import('../what') | |
| 26 | const ast = parseSelector(input) | |
| 27 | visitWhat(ast, result) | |
| 28 | return result | |
| 29 | } | |
| 30 | ||
| 31 | if (Array.isArray(input)) { | |
| 32 | if (input.length === 0) | |
| 33 | return result | |
| 34 | // distinguish Selector[][] from Selector[] | |
| 35 | if (Array.isArray(input[0])) | |
| 36 | visitWhat(input as WhatSelector[][], result) | |
| 37 | else | |
| 38 | visitWhatGroup(input as WhatSelector[], result) | |
| 39 | return result | |
| 40 | } | |
| 41 | ||
| 42 | visit(input, result) | |
| 21 | 43 | return result |
| 22 | 44 | } |
| 23 | 45 | |
| 46 | // ---- css-tree shape ---- | |
| 47 | ||
| 24 | 48 | function visit(node: CssNode, result: Specificity): void { |
| 25 | 49 | switch (node.type) { |
| 26 | 50 | case 'IdSelector': |
| @@ -38,9 +62,6 @@ function visit(node: CssNode, result: Specificity): void { | ||
| 38 | 62 | const name = node.name.toLowerCase() |
| 39 | 63 | if (name === 'is' || name === 'matches' || name === '-moz-any' || name === '-webkit-any' || name === 'not' || name === 'has') { |
| 40 | 64 | if (node.children) { |
| 41 | // The argument is either a SelectorList (multiple selectors) or a | |
| 42 | // flat list of selector tokens. Either way, find each top-level | |
| 43 | // Selector and take the max of their individual specificities. | |
| 44 | 65 | const selectors: CssNode[] = [] |
| 45 | 66 | for (const inner of node.children as Iterable<CssNode>) { |
| 46 | 67 | if (inner.type === 'SelectorList' && 'children' in inner && inner.children) { |
| @@ -80,11 +101,74 @@ function visit(node: CssNode, result: Specificity): void { | ||
| 80 | 101 | } |
| 81 | 102 | return |
| 82 | 103 | case 'NestingSelector': |
| 83 | // `&` adopts the host's specificity — approximate as 0 | |
| 84 | 104 | return |
| 85 | 105 | } |
| 86 | 106 | } |
| 87 | 107 | |
| 108 | // ---- css-what shape (Selector[][] or Selector[]) ---- | |
| 109 | // | |
| 110 | // css-what's `Selector` is one of: | |
| 111 | // { type: 'tag' | 'universal' | 'attribute' | 'pseudo' | 'pseudo-element' | |
| 112 | // | 'descendant' | 'child' | 'parent' | 'sibling' | 'adjacent' | 'column-combinator', | |
| 113 | // … } | |
| 114 | // | |
| 115 | // For specificity, *only* attribute/pseudo/tag matter — combinators are | |
| 116 | // ignored. `:is`/`:not`/`:has`/`:matches` take the max of their args. | |
| 117 | ||
| 118 | function visitWhat(groups: WhatSelector[][], result: Specificity): void { | |
| 119 | // Each top-level group is one comma-separated selector. css-tree-style | |
| 120 | // specificity is computed per-selector; for SelectorList (`a, b`) we | |
| 121 | // sum across all groups (matches csso behaviour). | |
| 122 | for (const g of groups) | |
| 123 | visitWhatGroup(g, result) | |
| 124 | } | |
| 125 | ||
| 126 | function visitWhatGroup(tokens: WhatSelector[], result: Specificity): void { | |
| 127 | for (const t of tokens) { | |
| 128 | switch (t.type) { | |
| 129 | case 'tag': | |
| 130 | if (t.name !== '*') | |
| 131 | result[2]++ | |
| 132 | break | |
| 133 | case 'attribute': | |
| 134 | // `.foo` and `#bar` are emitted as attribute selectors with name | |
| 135 | // 'class'/'id' — count them like ClassSelector / IdSelector. | |
| 136 | if (t.name === 'id' && t.action === 'equals') | |
| 137 | result[0]++ | |
| 138 | else | |
| 139 | result[1]++ | |
| 140 | break | |
| 141 | case 'pseudo': { | |
| 142 | const name = t.name.toLowerCase() | |
| 143 | if (name === 'is' || name === 'matches' || name === 'not' || name === 'has' || name === '-moz-any' || name === '-webkit-any') { | |
| 144 | // data is Selector[][] when parsed by css-what's selector-list pseudos | |
| 145 | if (Array.isArray(t.data)) { | |
| 146 | let max: Specificity = [0, 0, 0] | |
| 147 | for (const inner of t.data as WhatSelector[][]) { | |
| 148 | const s: Specificity = [0, 0, 0] | |
| 149 | visitWhatGroup(inner, s) | |
| 150 | if (compareSpec(s, max) > 0) | |
| 151 | max = s | |
| 152 | } | |
| 153 | result[0] += max[0] | |
| 154 | result[1] += max[1] | |
| 155 | result[2] += max[2] | |
| 156 | } | |
| 157 | break | |
| 158 | } | |
| 159 | if (name === 'where') | |
| 160 | break | |
| 161 | result[1]++ | |
| 162 | break | |
| 163 | } | |
| 164 | case 'pseudo-element': | |
| 165 | result[2]++ | |
| 166 | break | |
| 167 | // combinators don't contribute to specificity | |
| 168 | } | |
| 169 | } | |
| 170 | } | |
| 171 | ||
| 88 | 172 | function compareSpec(a: Specificity, b: Specificity): number { |
| 89 | 173 | for (let i = 0; i < 3; i++) { |
| 90 | 174 | if (a[i] !== b[i]) |
| @@ -17,30 +17,35 @@ function createItem<T>(data: T): ListItem<T> { | ||
| 17 | 17 | return { prev: null, next: null, data } |
| 18 | 18 | } |
| 19 | 19 | |
| 20 | const cursors: Array<{ list: CssList<any>, prev: ListItem<any> | null, next: ListItem<any> | null, cursor: any }> = [] | |
| 20 | interface ListCursor<T> { | |
| 21 | prev: ListItem<T> | null | |
| 22 | next: ListItem<T> | null | |
| 23 | } | |
| 21 | 24 | |
| 22 | 25 | export class CssList<T> { |
| 23 | 26 | head: ListItem<T> | null = null |
| 24 | 27 | tail: ListItem<T> | null = null |
| 25 | 28 | |
| 26 | 29 | // ----- mutation cursors ----- |
| 27 | // Walkers may iterate while declarations get inserted/removed mid-flight; | |
| 28 | // an active cursor records the in-progress prev/next pointers so a removal | |
| 29 | // that lands on the cursor's `next` slot can repoint it onto the survivor. | |
| 30 | private allocateCursor(prev: ListItem<T> | null, next: ListItem<T> | null): any { | |
| 31 | const cursor = { list: this, prev, next, cursor: cursors[cursors.length - 1] || null } | |
| 32 | cursors.push(cursor) | |
| 30 | // Each list owns its own cursor stack. Callbacks may insert/remove items | |
| 31 | // mid-walk; the topmost active cursor records the in-progress prev/next | |
| 32 | // pointers so a removal that lands on the cursor's `next` slot can be | |
| 33 | // repointed onto the survivor. Per-list (not module-global) so nested | |
| 34 | // walks of the same list don't corrupt each other. | |
| 35 | private cursors: ListCursor<T>[] = [] | |
| 36 | ||
| 37 | private allocateCursor(prev: ListItem<T> | null, next: ListItem<T> | null): ListCursor<T> { | |
| 38 | const cursor: ListCursor<T> = { prev, next } | |
| 39 | this.cursors.push(cursor) | |
| 33 | 40 | return cursor |
| 34 | 41 | } |
| 35 | 42 | |
| 36 | 43 | private releaseCursor(): void { |
| 37 | cursors.pop() | |
| 44 | this.cursors.pop() | |
| 38 | 45 | } |
| 39 | 46 | |
| 40 | 47 | private updateCursors(prevOld: ListItem<T> | null, prevNew: ListItem<T> | null, nextOld: ListItem<T> | null, nextNew: ListItem<T> | null): void { |
| 41 | for (const c of cursors) { | |
| 42 | if (c.list !== this) | |
| 43 | continue | |
| 48 | for (const c of this.cursors) { | |
| 44 | 49 | if (c.prev === prevOld) |
| 45 | 50 | c.prev = prevNew |
| 46 | 51 | if (c.next === nextOld) |
| @@ -100,28 +105,31 @@ export class CssList<T> { | ||
| 100 | 105 | return this.toArray() |
| 101 | 106 | } |
| 102 | 107 | |
| 108 | /** | |
| 109 | * css-tree-style forEach: callback receives `(data, item, list)`. | |
| 110 | * The `item` is the underlying `ListItem<T>`, not the index — call sites | |
| 111 | * use it with `list.remove(item)` / `list.replace(item, ...)`. | |
| 112 | */ | |
| 103 | 113 | // eslint-disable-next-line pickier/no-unused-vars |
| 104 | forEach(fn: (item: T, index: number, list: CssList<T>) => void, thisArg?: any): void { | |
| 114 | forEach(fn: (data: T, item: ListItem<T>, list: CssList<T>) => void, thisArg?: any): void { | |
| 105 | 115 | const cursor = this.allocateCursor(null, this.head) |
| 106 | let i = 0 | |
| 107 | 116 | while (cursor.next !== null) { |
| 108 | 117 | const item = cursor.next as ListItem<T> |
| 109 | 118 | cursor.prev = item |
| 110 | 119 | cursor.next = item.next |
| 111 | fn.call(thisArg, item.data, i++, this) | |
| 120 | fn.call(thisArg, item.data, item, this) | |
| 112 | 121 | } |
| 113 | 122 | this.releaseCursor() |
| 114 | 123 | } |
| 115 | 124 | |
| 116 | 125 | // eslint-disable-next-line pickier/no-unused-vars |
| 117 | forEachRight(fn: (item: T, index: number, list: CssList<T>) => void, thisArg?: any): void { | |
| 126 | forEachRight(fn: (data: T, item: ListItem<T>, list: CssList<T>) => void, thisArg?: any): void { | |
| 118 | 127 | const cursor = this.allocateCursor(this.tail, null) |
| 119 | let i = this.toArray().length - 1 | |
| 120 | 128 | while (cursor.prev !== null) { |
| 121 | 129 | const item = cursor.prev as ListItem<T> |
| 122 | 130 | cursor.next = item |
| 123 | 131 | cursor.prev = item.prev |
| 124 | fn.call(thisArg, item.data, i--, this) | |
| 132 | fn.call(thisArg, item.data, item, this) | |
| 125 | 133 | } |
| 126 | 134 | this.releaseCursor() |
| 127 | 135 | } |
| @@ -220,9 +228,9 @@ export class CssList<T> { | ||
| 220 | 228 | return this.insert(createItem(data), null) |
| 221 | 229 | } |
| 222 | 230 | |
| 223 | /** Insert `item` before `before`, or at tail if `before` is null. */ | |
| 224 | insert(item: ListItem<T>, before: ListItem<T> | null = null): this { | |
| 225 | if (before !== null) { | |
| 231 | /** Insert `item` before `before`, or at tail if `before` is null/undefined. */ | |
| 232 | insert(item: ListItem<T>, before: ListItem<T> | null | undefined = null): this { | |
| 233 | if (before != null) { | |
| 226 | 234 | this.updateCursors(before.prev, item, before, item) |
| 227 | 235 | if (before.prev === null) { |
| 228 | 236 | if (this.head !== before) |
| @@ -152,6 +152,24 @@ function newList<T>(): CssList<T> { | ||
| 152 | 152 | return new CssList<T>() |
| 153 | 153 | } |
| 154 | 154 | |
| 155 | /** | |
| 156 | * Report a parse problem to the consumer. Always returns the supplied | |
| 157 | * fallback node so call sites can `return reportError(…, raw)`. The | |
| 158 | * `onParseError` hook lets consumers (linters, IDEs) surface | |
| 159 | * the parser's recovery substitutions. | |
| 160 | */ | |
| 161 | function reportError(s: ParserState, message: string, at: Token, fallback: CssNode): CssNode { | |
| 162 | if (s.onParseError) { | |
| 163 | const { line, column } = s.tokenizer.locate(at.start) | |
| 164 | const err = new SyntaxError(`${s.filename ?? '<input>'}:${line}:${column}: ${message}`) as SyntaxError & { line: number, column: number, offset: number } | |
| 165 | err.line = line | |
| 166 | err.column = column | |
| 167 | err.offset = at.start | |
| 168 | s.onParseError(err, fallback) | |
| 169 | } | |
| 170 | return fallback | |
| 171 | } | |
| 172 | ||
| 155 | 173 | // ----- top-level dispatch ----- |
| 156 | 174 | |
| 157 | 175 | export function parse(source: string, options: ParseOptions = {}): CssNode { |
| @@ -250,21 +268,35 @@ function parseAtrule(s: ParserState): Atrule | null { | ||
| 250 | 268 | if (preludeStart !== peek(s)) { |
| 251 | 269 | const raw = s.source.slice(preludeStart.start, preludeEndTok.end).trim() |
| 252 | 270 | if (raw.length > 0) { |
| 253 | if (s.parseAtrulePrelude && (name === 'media' || name === 'supports' || name === 'import' || name === 'charset' || name === 'namespace' || name === 'keyframes' || name === '-webkit-keyframes' || name === '-moz-keyframes' || name === '-o-keyframes' || name === 'page' || name === 'font-face' || name === 'document' || name === 'layer' || name === 'container')) { | |
| 254 | // Reparse the prelude tokens as an AtrulePrelude with mixed tokens. | |
| 255 | const sub = makeState(raw, { positions: false }) | |
| 256 | const children = newList<CssNode>() | |
| 257 | while (peekType(sub) !== TokenType.EOF) { | |
| 258 | const tok = sub.tokens[sub.pos]! | |
| 259 | if (tok.type === TokenType.WhiteSpace) { | |
| 260 | sub.pos++ | |
| 261 | continue | |
| 271 | if (s.parseAtrulePrelude) { | |
| 272 | // Always attempt to parse — fall back to Raw if parsing produces | |
| 273 | // nothing useful or throws. No more hardcoded at-rule whitelist: | |
| 274 | // custom at-rules (`@property`, `@scope`, `@layer`, future specs) | |
| 275 | // get a parsed prelude too. | |
| 276 | try { | |
| 277 | const sub = makeState(raw, { positions: false }) | |
| 278 | sub.onParseError = s.onParseError | |
| 279 | const children = newList<CssNode>() | |
| 280 | while (peekType(sub) !== TokenType.EOF) { | |
| 281 | const tok = sub.tokens[sub.pos]! | |
| 282 | if (tok.type === TokenType.WhiteSpace) { | |
| 283 | sub.pos++ | |
| 284 | continue | |
| 285 | } | |
| 286 | const node = parseValueChild(sub) | |
| 287 | if (node) | |
| 288 | children.appendData(node) | |
| 262 | 289 | } |
| 263 | const node = parseValueChild(sub) | |
| 264 | if (node) | |
| 265 | children.appendData(node) | |
| 290 | if (children.isEmpty) | |
| 291 | prelude = rawNode(raw, preludeStart, preludeEndTok, s) | |
| 292 | else | |
| 293 | prelude = { type: 'AtrulePrelude', children, loc: null } | |
| 294 | } | |
| 295 | catch (err) { | |
| 296 | const fallback = rawNode(raw, preludeStart, preludeEndTok, s) | |
| 297 | reportError(s, `Failed to parse @${name} prelude: ${(err as Error).message}`, preludeStart, fallback) | |
| 298 | prelude = fallback | |
| 266 | 299 | } |
| 267 | prelude = { type: 'AtrulePrelude', children, loc: null } | |
| 268 | 300 | } |
| 269 | 301 | else { |
| 270 | 302 | prelude = rawNode(raw, preludeStart, preludeEndTok, s) |
| @@ -277,12 +309,28 @@ function parseAtrule(s: ParserState): Atrule | null { | ||
| 277 | 309 | consume(s) |
| 278 | 310 | } |
| 279 | 311 | else if (peekType(s) === TokenType.LeftCurlyBracket) { |
| 280 | block = parseBlock(s, name === 'media' || name === 'supports' || name === 'document' || name === 'layer' || name === 'container') | |
| 312 | block = parseBlock(s, AT_RULES_WITH_NESTED_RULES.has(name)) | |
| 281 | 313 | } |
| 282 | 314 | |
| 283 | 315 | return { type: 'Atrule', name, prelude, block, loc: loc(s, startTok, peek(s)) } |
| 284 | 316 | } |
| 285 | 317 | |
| 318 | /** | |
| 319 | * At-rules whose block contains nested *rules* (selector { … }), as | |
| 320 | * opposed to direct declarations. Used to switch parser mode inside the | |
| 321 | * block. Lives here (not on _collections) because it's a parser concern. | |
| 322 | */ | |
| 323 | const AT_RULES_WITH_NESTED_RULES: ReadonlySet<string> = new Set([ | |
| 324 | 'media', | |
| 325 | 'supports', | |
| 326 | 'document', | |
| 327 | 'layer', | |
| 328 | 'container', | |
| 329 | 'scope', | |
| 330 | 'starting-style', | |
| 331 | '-moz-document', | |
| 332 | ]) | |
| 333 | ||
| 286 | 334 | function parseAtrulePreludeContext(s: ParserState): AtrulePrelude { |
| 287 | 335 | const startTok = peek(s) |
| 288 | 336 | const children = newList<CssNode>() |
| @@ -430,6 +478,7 @@ function parseDeclaration(s: ParserState): Declaration | null { | ||
| 430 | 478 | } |
| 431 | 479 | else { |
| 432 | 480 | // skip junk to next ; |
| 481 | reportError(s, `Expected property name`, startTok, { type: 'Raw', value: '', loc: null }) | |
| 433 | 482 | while (peekType(s) !== TokenType.EOF && peekType(s) !== TokenType.Semicolon && peekType(s) !== TokenType.RightCurlyBracket) |
| 434 | 483 | consume(s) |
| 435 | 484 | return null |
| @@ -443,6 +492,7 @@ function parseDeclaration(s: ParserState): Declaration | null { | ||
| 443 | 492 | skipWhitespaceOnly(s) |
| 444 | 493 | if (peekType(s) !== TokenType.Colon) { |
| 445 | 494 | // not a declaration — recover |
| 495 | reportError(s, `Expected ':' after property "${property}"`, peek(s), { type: 'Raw', value: '', loc: null }) | |
| 446 | 496 | while (peekType(s) !== TokenType.EOF && peekType(s) !== TokenType.Semicolon && peekType(s) !== TokenType.RightCurlyBracket) |
| 447 | 497 | consume(s) |
| 448 | 498 | return null |
| @@ -507,6 +507,65 @@ export class Tokenizer { | ||
| 507 | 507 | } |
| 508 | 508 | |
| 509 | 509 | /** Decode a CSS-escaped name slice (e.g. `\\26 B` → `&B`). */ |
| 510 | /** | |
| 511 | * Decode a CSS string literal slice to its actual character value. | |
| 512 | * `start` / `end` exclude the surrounding quote characters. | |
| 513 | * | |
| 514 | * Handles: | |
| 515 | * - hex escapes (`\26 ` → `&`) | |
| 516 | * - `\"` / `\'` / `\\` | |
| 517 | * - escaped newlines (CSS: backslash followed by newline is removed) | |
| 518 | * - any other `\<char>` → `<char>` | |
| 519 | */ | |
| 520 | export function decodeString(source: string, start: number, end: number): string { | |
| 521 | let out = '' | |
| 522 | let i = start | |
| 523 | while (i < end) { | |
| 524 | const c = source.charCodeAt(i) | |
| 525 | if (c !== 92 /* \\ */) { | |
| 526 | out += source[i]! | |
| 527 | i++ | |
| 528 | continue | |
| 529 | } | |
| 530 | // backslash at end-of-input | |
| 531 | if (i + 1 >= end) { | |
| 532 | i++ | |
| 533 | continue | |
| 534 | } | |
| 535 | // backslash + newline = continuation (removed entirely) | |
| 536 | const next = source.charCodeAt(i + 1) | |
| 537 | if (next === 10) { | |
| 538 | i += 2 | |
| 539 | continue | |
| 540 | } | |
| 541 | if (next === 13) { | |
| 542 | i += source.charCodeAt(i + 2) === 10 ? 3 : 2 | |
| 543 | continue | |
| 544 | } | |
| 545 | // hex escape | |
| 546 | if (isHexDigit(next)) { | |
| 547 | let j = i + 1 | |
| 548 | let hex = '' | |
| 549 | while (hex.length < 6 && j < end && isHexDigit(source.charCodeAt(j))) { | |
| 550 | hex += source[j] | |
| 551 | j++ | |
| 552 | } | |
| 553 | const code = Number.parseInt(hex, 16) | |
| 554 | if (j < end && isWhitespace(source.charCodeAt(j))) | |
| 555 | j++ | |
| 556 | out += code === 0 || (code >= 0xD800 && code <= 0xDFFF) || code > 0x10FFFF | |
| 557 | ? String.fromCodePoint(REPLACEMENT) | |
| 558 | : String.fromCodePoint(code) | |
| 559 | i = j | |
| 560 | continue | |
| 561 | } | |
| 562 | // escape of any other char (including \" \' \\) — keep the char | |
| 563 | out += source[i + 1] | |
| 564 | i += 2 | |
| 565 | } | |
| 566 | return out | |
| 567 | } | |
| 568 | ||
| 510 | 569 | export function decodeName(source: string, start: number, end: number): string { |
| 511 | 570 | let out = '' |
| 512 | 571 | let i = start |
| @@ -25,17 +25,34 @@ export interface WalkContext { | ||
| 25 | 25 | |
| 26 | 26 | export type WalkVisit = CssNode['type'] |
| 27 | 27 | |
| 28 | export type WalkVisitor = | |
| 29 | // eslint-disable-next-line pickier/no-unused-vars | |
| 30 | | ((this: WalkContext, node: CssNode, item: ListItem<CssNode> | null, list: CssList<CssNode> | null) => void | symbol) | |
| 31 | | { | |
| 32 | visit?: WalkVisit | |
| 33 | reverse?: boolean | |
| 34 | // eslint-disable-next-line pickier/no-unused-vars | |
| 35 | enter?: (this: WalkContext, node: CssNode, item: ListItem<CssNode> | null, list: CssList<CssNode> | null) => void | symbol | |
| 36 | // eslint-disable-next-line pickier/no-unused-vars | |
| 37 | leave?: (this: WalkContext, node: CssNode, item: ListItem<CssNode> | null, list: CssList<CssNode> | null) => void | symbol | |
| 38 | } | |
| 28 | // Narrow CssNode by its `type` discriminant — used to type the | |
| 29 | // `visit: 'Rule'` filter so consumer callbacks receive a `Rule`, not the | |
| 30 | // full union. | |
| 31 | export type CssNodeOfType<T extends WalkVisit> = Extract<CssNode, { type: T }> | |
| 32 | ||
| 33 | // eslint-disable-next-line pickier/no-unused-vars | |
| 34 | type VisitorCallback<N> = (this: WalkContext, node: N, item: ListItem<CssNode> | null, list: CssList<CssNode> | null) => void | symbol | |
| 35 | ||
| 36 | /** | |
| 37 | * Object-form visitor. The `T extends WalkVisit` generic is inferred from | |
| 38 | * the literal value of `visit`, so writing | |
| 39 | * | |
| 40 | * walk(ast, { visit: 'Rule', enter(node) { … } }) | |
| 41 | * | |
| 42 | * narrows `node` to `Rule`. Without `visit`, callbacks receive the full | |
| 43 | * `CssNode` union as `enter`/`leave` arguments. | |
| 44 | */ | |
| 45 | export interface WalkVisitorObject<T extends WalkVisit | undefined = undefined> { | |
| 46 | visit?: T | |
| 47 | reverse?: boolean | |
| 48 | enter?: VisitorCallback<T extends WalkVisit ? CssNodeOfType<T> : CssNode> | |
| 49 | leave?: VisitorCallback<T extends WalkVisit ? CssNodeOfType<T> : CssNode> | |
| 50 | } | |
| 51 | ||
| 52 | /** Plain-function visitor — runs against every node. */ | |
| 53 | export type WalkVisitorFunction = VisitorCallback<CssNode> | |
| 54 | ||
| 55 | export type WalkVisitor = WalkVisitorFunction | WalkVisitorObject<any> | |
| 39 | 56 | |
| 40 | 57 | function newContext(root: CssNode): WalkContext { |
| 41 | 58 | return { |
| @@ -60,8 +77,12 @@ function newContext(root: CssNode): WalkContext { | ||
| 60 | 77 | * `walk.skip` / `walk.stop` are exposed as static-symbol sentinels. |
| 61 | 78 | */ |
| 62 | 79 | export interface WalkFn { |
| 80 | // 1. visitor function — every node (no narrowing) | |
| 81 | // eslint-disable-next-line pickier/no-unused-vars | |
| 82 | (root: CssNode, visitor: WalkVisitorFunction): symbol | undefined | |
| 83 | // 2. visitor object with `visit: '<NodeType>'` — narrowed callbacks | |
| 63 | 84 | // eslint-disable-next-line pickier/no-unused-vars |
| 64 | (root: CssNode, visitor: WalkVisitor): symbol | undefined | |
| 85 | <T extends WalkVisit>(root: CssNode, visitor: WalkVisitorObject<T>): symbol | undefined | |
| 65 | 86 | skip: symbol |
| 66 | 87 | stop: symbol |
| 67 | 88 | } |
| @@ -5,6 +5,9 @@ | ||
| 5 | 5 | * selectOne(selector, root, options) → ElementNode | null |
| 6 | 6 | * is(node, selector, options) → boolean |
| 7 | 7 | * compile(selector, options) → CompiledQuery |
| 8 | * | |
| 9 | * Compiled-selector cache: the same selector string compiled with the | |
| 10 | * same adapter + options is reused. Disable with `cacheResults: false`. | |
| 8 | 11 | */ |
| 9 | 12 | |
| 10 | 13 | import type { Selector } from '../what' |
| @@ -18,6 +21,26 @@ function getAdapter<Node, ElementNode extends Node>(options: Options<Node, Eleme | ||
| 18 | 21 | return options.adapter |
| 19 | 22 | } |
| 20 | 23 | |
| 24 | // ----- compiled-selector cache ----- | |
| 25 | // Keyed by Adapter (object identity) so different trees can't poison each | |
| 26 | // other's cache. The inner key is a deterministic string capturing the | |
| 27 | // selector + the options that affect compile output. | |
| 28 | const adapterCaches = new WeakMap<object, Map<string, CompiledQuery<any>>>() | |
| 29 | ||
| 30 | function cacheKey(selector: string, options: Options<any, any>): string { | |
| 31 | // Only options that change compile output need to be in the key. | |
| 32 | return `${options.xmlMode ? '1' : '0'}|${options.lowerCaseAttributeNames === false ? '0' : '1'}|${options.lowerCaseTags === false ? '0' : '1'}|${selector}` | |
| 33 | } | |
| 34 | ||
| 35 | function getCache(adapter: object): Map<string, CompiledQuery<any>> { | |
| 36 | let cache = adapterCaches.get(adapter) | |
| 37 | if (!cache) { | |
| 38 | cache = new Map() | |
| 39 | adapterCaches.set(adapter, cache) | |
| 40 | } | |
| 41 | return cache | |
| 42 | } | |
| 43 | ||
| 21 | 44 | function compileSelectorString<Node, ElementNode extends Node>( |
| 22 | 45 | selector: string, |
| 23 | 46 | options: Options<Node, ElementNode>, |
| @@ -34,9 +57,37 @@ export function compile<Node, ElementNode extends Node>( | ||
| 34 | 57 | selector: string | Selector[][], |
| 35 | 58 | options: Options<Node, ElementNode>, |
| 36 | 59 | ): CompiledQuery<ElementNode> { |
| 37 | if (typeof selector === 'string') | |
| 60 | if (typeof selector !== 'string') | |
| 61 | return compileGeneric(selector, options) | |
| 62 | ||
| 63 | // Fast path: use the per-adapter cache unless the consumer opted out. | |
| 64 | if (options.cacheResults === false || !options.adapter) | |
| 38 | 65 | return compileSelectorString(selector, options) |
| 39 | return compileGeneric(selector, options) | |
| 66 | ||
| 67 | const cache = getCache(options.adapter as unknown as object) | |
| 68 | const key = cacheKey(selector, options) | |
| 69 | let compiled = cache.get(key) | |
| 70 | if (compiled !== undefined) | |
| 71 | return compiled as CompiledQuery<ElementNode> | |
| 72 | compiled = compileSelectorString(selector, options) | |
| 73 | cache.set(key, compiled) | |
| 74 | return compiled as CompiledQuery<ElementNode> | |
| 75 | } | |
| 76 | ||
| 77 | /** Drop the cached compile of `selector` for `adapter`, or all entries | |
| 78 | * if `selector` is omitted. */ | |
| 79 | export function clearSelectorCache(adapter: object, selector?: string): void { | |
| 80 | if (selector === undefined) { | |
| 81 | adapterCaches.delete(adapter) | |
| 82 | return | |
| 83 | } | |
| 84 | const cache = adapterCaches.get(adapter) | |
| 85 | if (!cache) | |
| 86 | return | |
| 87 | for (const key of cache.keys()) { | |
| 88 | if (key.endsWith(`|${selector}`)) | |
| 89 | cache.delete(key) | |
| 90 | } | |
| 40 | 91 | } |
| 41 | 92 | |
| 42 | 93 | export function selectAll<Node, ElementNode extends Node>( |
| @@ -27,6 +27,30 @@ describe('minify', () => { | ||
| 27 | 27 | it('shortens hex colors', () => { |
| 28 | 28 | expect(minify('.a{color:#aabbcc}').css).toBe('.a{color:#abc}') |
| 29 | 29 | }) |
| 30 | ||
| 31 | it('shortens named colors when hex is strictly shorter', () => { | |
| 32 | // black (5) → #000 (4): replace | |
| 33 | expect(minify('.a{color:black}').css).toBe('.a{color:#000}') | |
| 34 | // white (5) → #fff (4): replace | |
| 35 | expect(minify('.a{color:white}').css).toBe('.a{color:#fff}') | |
| 36 | }) | |
| 37 | ||
| 38 | it('keeps named colors that tie or beat hex', () => { | |
| 39 | // blue (4) ties #00f (4) — keep keyword | |
| 40 | expect(minify('.a{color:blue}').css).toBe('.a{color:blue}') | |
| 41 | // red (3) beats #f00 (4) — keep keyword | |
| 42 | expect(minify('.a{color:red}').css).toBe('.a{color:red}') | |
| 43 | }) | |
| 44 | ||
| 45 | it('only swaps colors inside color properties', () => { | |
| 46 | // font-family is not a color property — `red` stays | |
| 47 | expect(minify('.a{font-family:red}').css).toBe('.a{font-family:red}') | |
| 48 | }) | |
| 49 | ||
| 50 | it('reverses hex → name when name is strictly shorter', () => { | |
| 51 | // #ff0000 (7) → red (3): replace | |
| 52 | expect(minify('.a{color:#ff0000}').css).toBe('.a{color:red}') | |
| 53 | }) | |
| 30 | 54 | }) |
| 31 | 55 | |
| 32 | 56 | describe('minifyBlock', () => { |
| @@ -56,4 +80,13 @@ describe('syntax.specificity', () => { | ||
| 56 | 80 | const sel = parse(':is(#a, .b)', { context: 'selector' }) |
| 57 | 81 | expect(syntax.specificity(sel)).toEqual([1, 0, 0]) |
| 58 | 82 | }) |
| 83 | it('accepts a raw selector string', () => { | |
| 84 | expect(syntax.specificity('#a.b div')).toEqual([1, 1, 1]) | |
| 85 | expect(syntax.specificity('p > span:not(#x)')).toEqual([1, 0, 2]) | |
| 86 | }) | |
| 87 | it('accepts cssWhat-style Selector[][] arrays', async () => { | |
| 88 | const { parse: parseWhat } = await import('../src/what') | |
| 89 | const ast = parseWhat('#a.b div') | |
| 90 | expect(syntax.specificity(ast)).toEqual([1, 1, 1]) | |
| 91 | }) | |
| 59 | 92 | }) |
| @@ -38,6 +38,34 @@ describe('parse + generate round-trip', () => { | ||
| 38 | 38 | expect(ast.children.first.name).toBe('media') |
| 39 | 39 | }) |
| 40 | 40 | |
| 41 | it('parses unknown at-rule preludes (no hardcoded whitelist)', () => { | |
| 42 | const ast = parse('@my-custom rule (foo) { .a { color: red } }') as any | |
| 43 | const at = ast.children.first | |
| 44 | expect(at.type).toBe('Atrule') | |
| 45 | expect(at.name).toBe('my-custom') | |
| 46 | // Prelude should be parsed structurally (AtrulePrelude with children), | |
| 47 | // not stored as a Raw blob. | |
| 48 | expect(at.prelude.type).toBe('AtrulePrelude') | |
| 49 | expect(at.prelude.children.toArray().length).toBeGreaterThan(0) | |
| 50 | }) | |
| 51 | ||
| 52 | it('parses @scope, @starting-style, and @container with nested rules', () => { | |
| 53 | for (const name of ['scope', 'starting-style', 'container']) { | |
| 54 | const ast = parse(`@${name} (foo) { .x { color: blue } }`) as any | |
| 55 | const at = ast.children.first | |
| 56 | expect(at.type).toBe('Atrule') | |
| 57 | expect(at.block.children.first.type).toBe('Rule') | |
| 58 | } | |
| 59 | }) | |
| 60 | ||
| 61 | it('calls onParseError for malformed declarations', () => { | |
| 62 | const errors: string[] = [] | |
| 63 | parse('.a { invalid-no-colon; color: red; }', { | |
| 64 | onParseError: (err) => { errors.push(err.message) }, | |
| 65 | }) | |
| 66 | expect(errors.length).toBeGreaterThan(0) | |
| 67 | }) | |
| 68 | ||
| 41 | 69 | it('parses url() function', () => { |
| 42 | 70 | const ast = parse('.a{background:url("x.png")}') as any |
| 43 | 71 | let foundUrl = false |
| @@ -65,6 +93,27 @@ describe('parse + generate round-trip', () => { | ||
| 65 | 93 | expect(ast.children.toArray().length).toBe(2) |
| 66 | 94 | }) |
| 67 | 95 | |
| 96 | it('narrows visitor.enter via visit filter', () => { | |
| 97 | const ast = parse('.a{color:red} .b{color:blue}') | |
| 98 | const propsByRule: string[][] = [] | |
| 99 | walk(ast, { | |
| 100 | visit: 'Rule', | |
| 101 | enter(rule) { | |
| 102 | // `rule` is narrowed to Rule here — TS should NOT see the wider union. | |
| 103 | const props: string[] = [] | |
| 104 | walk(rule.block, { | |
| 105 | visit: 'Declaration', | |
| 106 | enter(decl) { | |
| 107 | // `decl` narrowed to Declaration | |
| 108 | props.push(decl.property) | |
| 109 | }, | |
| 110 | }) | |
| 111 | propsByRule.push(props) | |
| 112 | }, | |
| 113 | }) | |
| 114 | expect(propsByRule).toEqual([['color'], ['color']]) | |
| 115 | }) | |
| 116 | ||
| 68 | 117 | it('exposes a doubly linked List', () => { |
| 69 | 118 | const list = new List<number>() |
| 70 | 119 | list.appendData(1) |
| @@ -74,4 +123,21 @@ describe('parse + generate round-trip', () => { | ||
| 74 | 123 | list.shift() |
| 75 | 124 | expect(list.toArray()).toEqual([2, 3]) |
| 76 | 125 | }) |
| 126 | ||
| 127 | it('survives nested forEach with mid-walk removal', () => { | |
| 128 | const list = new List<number>() | |
| 129 | list.fromArray([1, 2, 3, 4, 5]) | |
| 130 | const seen: number[] = [] | |
| 131 | list.forEach((data, item) => { | |
| 132 | seen.push(data) | |
| 133 | // a nested walk over the SAME list — must not corrupt the outer cursor. | |
| 134 | list.forEach((d) => { void d }) | |
| 135 | if (data === 2) { | |
| 136 | // remove the current node mid-iteration | |
| 137 | list.remove(item) | |
| 138 | } | |
| 139 | }) | |
| 140 | expect(seen).toEqual([1, 2, 3, 4, 5]) | |
| 141 | expect(list.toArray()).toEqual([1, 3, 4, 5]) | |
| 142 | }) | |
| 77 | 143 | }) |
| @@ -102,3 +102,28 @@ describe('selectOne / is', () => { | ||
| 102 | 102 | expect(is(span, '.missing', opts)).toBe(false) |
| 103 | 103 | }) |
| 104 | 104 | }) |
| 105 | ||
| 106 | describe('compile cache', () => { | |
| 107 | it('reuses compiled selectors with default options', async () => { | |
| 108 | const { compile, clearSelectorCache } = await import('../src/select') | |
| 109 | clearSelectorCache(adapter as any) | |
| 110 | const a = compile('p > span.foo', opts) | |
| 111 | const b = compile('p > span.foo', opts) | |
| 112 | expect(a).toBe(b) | |
| 113 | }) | |
| 114 | ||
| 115 | it('skips the cache when cacheResults is false', async () => { | |
| 116 | const { compile } = await import('../src/select') | |
| 117 | const noCache: typeof opts = { ...opts, cacheResults: false } | |
| 118 | const a = compile('p > span', noCache) | |
| 119 | const b = compile('p > span', noCache) | |
| 120 | expect(a).not.toBe(b) | |
| 121 | }) | |
| 122 | ||
| 123 | it('keys cache by xmlMode flag', async () => { | |
| 124 | const { compile } = await import('../src/select') | |
| 125 | const a = compile('p', opts) | |
| 126 | const b = compile('p', { ...opts, xmlMode: false }) | |
| 127 | expect(a).not.toBe(b) | |
| 128 | }) | |
| 129 | }) | |