Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
946c536
fix: format at-rule preludes with structured parsing instead of regexes
claude Jul 11, 2026
e14571c
Merge branch 'main' into claude/atrule-prelude-structured-parsing
bartveneman Jul 19, 2026
8cf6e94
fix: drop the dotted @layer name workaround, fixed upstream in css-pa…
claude Jul 19, 2026
858a88f
docs: remove fixed issues from PARSER_ISSUES.md instead of marking th…
claude Jul 19, 2026
1df87e1
fix: simplify atrule prelude printing now that css-parser 0.18.0 deep…
claude Jul 19, 2026
2ef6c57
fix: drop only/not-prefix and @supports selector() workarounds, fixed…
claude Jul 19, 2026
7470a1a
docs: remove PARSER_ISSUES.md, remaining issues marked won't-fix
claude Jul 19, 2026
a9448ac
fix: remove balance_parens/has_top_level_colon/print_condition, no lo…
claude Jul 19, 2026
e868556
fix: address atrule-prelude review feedback
claude Jul 19, 2026
0764987
refactor: split print_identifier() and print_url() out to separate PRs
claude Jul 20, 2026
b066f8d
Merge remote-tracking branch 'origin/main' into claude/atrule-prelude…
claude Jul 20, 2026
f19ca53
refactor: use the now-merged print_identifier()/print_url() helpers
claude Jul 20, 2026
8e2efe3
chore(deps): bump @projectwallace/css-parser to ~0.18.1
claude Jul 20, 2026
1c699b0
Merge remote-tracking branch 'origin/claude/bump-css-parser-0.18.1' i…
claude Jul 20, 2026
5fdc73d
Merge branch 'main' into claude/atrule-prelude-structured-parsing
bartveneman Jul 20, 2026
a87dd52
refactor: remove unnecessary type casts and an unused minify param
claude Jul 20, 2026
189bbcc
Merge branch 'main' into claude/atrule-prelude-structured-parsing
bartveneman Jul 28, 2026
f8d964c
test: add extensive unit tests for all at-rules
claude Oct 21, 2026
2b50d5d
fix: keep strings, urls and non-condition atrule preludes intact
claude Oct 21, 2026
06db29c
revert: drop regex-based prelude fix in favour of structured prelude …
claude Oct 21, 2026
7610e9f
fix: print non-condition atrule preludes as plain text
claude Oct 21, 2026
1d75a10
test: assert dropped prelude comments and make at-rule tests prove fo…
claude Oct 21, 2026
0197bd6
refactor: share print_url, drop default parse option, tidy prelude pr…
claude Oct 21, 2026
c7b5ecd
chore: bump @projectwallace/css-parser to 0.18.12
claude Oct 21, 2026
3e41259
Merge branch 'main' into claude/atrule-prelude-structured-parsing
claude Oct 21, 2026
1110e70
feat: adapt prelude printer to Function(layer|supports) structure
claude Oct 21, 2026
1974cab
feat: print nested @supports/style() conditions from the AST
claude Oct 21, 2026
cec3c5b
chore: use css-parser preview 60af53b
claude Oct 21, 2026
65f3b8c
refactor: narrow print_url parameter to Url
claude Oct 21, 2026
ea604ca
refactor: use str_starts_with in print_url
claude Oct 21, 2026
d8e48d5
chore: use css-parser preview 2aa30b9 and adapt to new string helper …
claude Oct 21, 2026
94ea762
chore: bump @projectwallace/css-parser to 0.19.0
claude Oct 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
"knip": "knip"
},
"dependencies": {
"@projectwallace/css-parser": "^0.18.12"
"@projectwallace/css-parser": "^0.19.0"
},
"devDependencies": {
"@projectwallace/preset-oxlint": "^0.0.11",
Expand Down
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

214 changes: 207 additions & 7 deletions src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
parse,
str_equals,
str_index_of,
str_starts_with,
is_custom,
is_function,
is_dimension,
Expand All @@ -25,13 +26,29 @@ import {
is_atrule,
is_if_branch,
is_if_condition,
is_media_query,
is_container_query,
is_media_feature,
is_feature_range,
is_supports_query,
is_prelude_operator,
is_supports_declaration,
is_prelude_selectorlist,
type Operator,
type Value,
type Declaration,
type IfBranch,
type IfCondition,
type Function,
type Raw,
type Url,
type AtrulePrelude,
type MediaQuery,
type ContainerQuery,
type SupportsQuery,
type FeatureRange,
type MediaFeature,
type Function as CSSFunction,
type NthSelector,
type NthOfSelector,
type PseudoClassSelector,
Expand Down Expand Up @@ -78,6 +95,20 @@ const ATRULE_WHITESPACE_RE = /\s+/g
const ATRULE_COLON_COMMA_SPACE_RE = /([:,]) /g
const ATRULE_CALC_RE = /calc\(\s*([^()+\-*/]+)\s*([*/+-])\s*([^()+\-*/]+)\s*\)/g
const ATRULE_FN_NAME_RE = /selector|url|supports|layer\(/gi
// Quoted strings and unquoted url() contents must keep their exact text
const ATRULE_PROTECTED_RE = /"[^"]*"|'[^']*'|(?<=url\()[^\s"')][^)]*(?=\))/gi
const ATRULE_COMMA_RE = /\s*,\s*/g
/** Atrules whose prelude is not a media/supports-style condition, so it is only
* whitespace- and comma-normalized instead of being interpreted. */
const OPAQUE_PRELUDE_ATRULES = new Set([
'page',
'namespace',
'document',
'-moz-document',
'custom-selector',
'function',
'font-feature-values',
])

export function unquote(str: string): string {
return str.replaceAll(UNQUOTE_RE, EMPTY_STRING)
Expand All @@ -95,6 +126,17 @@ function print_string(str: string): string {
return quote + inner + quote
}

/** Lowercases a leading `url(` keyword but leaves quote style untouched. In value position a Url
* node's text always starts with `url(` (any casing), but an `@import` specifier can also be a
* bare string (`@import "foo";`) with no `url(` to lowercase. */
function print_url(node: Url): string {
let text = node.text
if (str_starts_with('url(', text)) {
return 'url(' + text.slice(4)
}
return text
}

function print_operator(node: Operator, optional_space = SPACE): string {
// https://developer.mozilla.org/en-US/docs/Web/CSS/calc#notes
// The + and - operators must be surrounded by whitespace
Expand Down Expand Up @@ -210,10 +252,8 @@ function print_list(
print_list(node.children, optional_space, depth, minify, tab_size),
CLOSE_PARENTHESES,
)
} else if (is_url(node) && node.value) {
// Lowercase the `url(` keyword but leave quote style untouched. A Url node's text
// always starts with `url(` (any casing), that's how the parser identifies it.
parts.push('url(' + node.text.slice(4))
} else if (is_url(node)) {
parts.push(print_url(node))
} else {
parts.push(node.text)
}
Expand Down Expand Up @@ -474,7 +514,7 @@ export function format_atrule_prelude(
): string {
let optional_space = minify ? EMPTY_STRING : SPACE
return prelude
.replaceAll(ATRULE_COLON_COMMA_RE, str_index_of(prelude, 'selector(') === -1 ? '$1 ' : '$1') // force whitespace after colon or comma, except inside `selector()`
.replaceAll(ATRULE_COLON_COMMA_RE, str_index_of('selector(', prelude) === -1 ? '$1 ' : '$1') // force whitespace after colon or comma, except inside `selector()`
.replaceAll(ATRULE_PAREN_TEXT_RE, ') $1') // force whitespace between closing parenthesis and following text (usually and|or)
.replaceAll(ATRULE_KEYWORD_PAREN_RE, '$1 (') // force whitespace between media/supports keywords and opening parenthesis
.replaceAll(ATRULE_ARROW_COMPARE_RE, `${optional_space}$1${optional_space}`) // add optional spacing around =>, >= and <=
Expand All @@ -490,6 +530,163 @@ export function format_atrule_prelude(
.replaceAll(ATRULE_FN_NAME_RE, (match) => match.toLowerCase()) // lowercase function names
}

/** Prints a two-sided (`200px < width < 1000px`) or one-sided (`width >
* 1000px`) media-feature range, reordering by source offset since the
* feature name isn't a child node. */
function print_feature_range(node: FeatureRange, optional_space: string): string {
let name_offset = node.start + node.text.indexOf(node.name, 1)
let items: { offset: number; text: string }[] = [{ offset: name_offset, text: node.name }]

for (let child of node) {
let text = is_prelude_operator(child)
? optional_space + child.text + optional_space
: child.text
items.push({ offset: child.start, text })
}

items.sort((a, b) => a.offset - b.offset)
return OPEN_PARENTHESES + items.map((item) => item.text).join(EMPTY_STRING) + CLOSE_PARENTHESES
}

/** Prints a single media/container feature, e.g. `(min-width: 768px)` or the
* boolean form `(hover)`. */
function print_media_feature(node: MediaFeature, optional_space: string): string {
let property = print_identifier(node.property)
if (node.value === null) {
return OPEN_PARENTHESES + property + CLOSE_PARENTHESES
}

return (
OPEN_PARENTHESES +
property +
COLON +
optional_space +
print_list([node.value], optional_space) +
CLOSE_PARENTHESES
)
}

/** Prints the condition inside a `SupportsQuery` or a `style()`/`supports()` Function: either a
* single declaration (`display: grid`) or `and`/`or`/`not`-joined nested conditions. */
function print_condition(node: SupportsQuery | CSSFunction, minify: boolean): string {
let first = node.first_child
if (first === null) {
return node.value ?? EMPTY_STRING
}
if (is_supports_declaration(first)) {
return format_declaration(first.first_child, { minify })
}
return print_prelude_children(node, minify ? EMPTY_STRING : SPACE, minify)
}

/** Prints a `@supports` condition group, e.g. `(display: grid)` or `((a: b) and (c: d))`. */
function print_supports_query(node: SupportsQuery, minify: boolean): string {
let condition = print_condition(node, minify)
// Inside `supports(...)` (@import) the query has no parentheses of its own
return node.text.startsWith(OPEN_PARENTHESES)
? OPEN_PARENTHESES + condition + CLOSE_PARENTHESES
: condition
}

/** Prints a functional prelude condition, e.g. `style(--foo: bar)`, `selector(:hover)`,
* or @import's `layer(...)` and `supports(...)`. */
function print_prelude_function(node: CSSFunction, minify: boolean): string {
let name = print_identifier(node.name)
// `selector(...)` takes a selector list, not a declaration.
if (str_equals('selector', name) && node.has_children && is_selector_list(node.first_child)) {
return (
name +
OPEN_PARENTHESES +
format_selector_list(node.first_child, { minify }) +
CLOSE_PARENTHESES
)
}
if (str_equals('layer', name)) {
return (
name +
OPEN_PARENTHESES +
print_prelude_children(node, EMPTY_STRING, minify) +
CLOSE_PARENTHESES
)
}
if (str_equals('style', name) || str_equals('supports', name)) {
return name + OPEN_PARENTHESES + print_condition(node, minify) + CLOSE_PARENTHESES
}
return node.text
}

/** Prints one child of an AtrulePrelude/MediaQuery/ContainerQuery. Falls back
* to the node's raw text for anything the prelude parser doesn't specifically
* model (Identifier, String, Raw, ...). */
function print_prelude_component(node: CSSNode, optional_space: string, minify: boolean): string {
if (is_media_query(node) || is_container_query(node)) {
return print_prelude_children(node, optional_space, minify)
}
if (is_media_feature(node)) {
return print_media_feature(node, optional_space)
}
if (is_feature_range(node)) {
return print_feature_range(node, optional_space)
}
if (is_supports_query(node)) {
return print_supports_query(node, minify)
}
if (is_prelude_selectorlist(node)) {
return node.text
}
if (is_function(node)) {
return print_prelude_function(node, minify)
}
if (is_url(node)) {
return print_url(node)
}
return node.text
}

/** Prints a `,`-or-space joined sequence of at-rule prelude components.
* Same-type siblings (e.g. multiple `MediaQuery`s in `screen, print`) are
* comma-separated; everything else gets a real space, always, since CSS
* doesn't allow gluing them together. */
function print_prelude_children(
node: AtrulePrelude | MediaQuery | ContainerQuery | CSSFunction | SupportsQuery,
optional_space: string,
minify: boolean,
): string {
let parts: string[] = []
for (let child of node) {
parts.push(print_prelude_component(child, optional_space, minify))
if (child.has_next) {
// adjacent functions (`layer(a) supports(b)` in @import) are not a comma-separated list
let is_list = child.type === child.next_sibling.type && !is_function(child)
parts.push(is_list ? COMMA + optional_space : SPACE)
}
}
return parts.join(EMPTY_STRING)
}

/** Prints a prelude as plain text: collapses whitespace and normalizes commas, but never
* touches colons, `<`/`>`, strings or url() contents (`@page :first`, `@function --f(--a <length>)`). */
function print_opaque_atrule_prelude(text: string, minify: boolean): string {
let strings: string[] = []
let masked = text.replaceAll(ATRULE_PROTECTED_RE, (match) => `\0${strings.push(match) - 1}\0`)
return masked
.replaceAll(ATRULE_WHITESPACE_RE, SPACE)
.replaceAll(ATRULE_COMMA_RE, minify ? COMMA : COMMA + SPACE)
.trim()
.replaceAll(/\0(\d+)\0/g, (_, i) => strings[Number(i)]!)
}

/** Prints a structured at-rule prelude node. Falls back to the regex-based
* `format_atrule_prelude` when the prelude parser doesn't recognize the
* at-rule or its shape (`@page :first`, `@starting-style`, ...). */
function print_atrule_prelude_node(node: AtrulePrelude | Raw, minify: boolean): string {
if (is_raw(node) || !node.has_children) {
return format_atrule_prelude(node.text, { minify })
}
let optional_space = minify ? EMPTY_STRING : SPACE
return print_prelude_children(node, optional_space, minify)
}

/**
* Format a string of CSS using some simple rules
*/
Expand All @@ -511,7 +708,6 @@ export function format(
// First pass: collect all comments
let comments: number[] = []
let ast = parse(css, {
parse_atrule_preludes: false,
on_comment: minify
? undefined
: ({ start, end }) => {
Expand Down Expand Up @@ -659,7 +855,11 @@ export function format(
function print_atrule(node: Atrule): string {
let name = '@' + print_identifier(node.name!)
if (node.prelude) {
name += SPACE + format_atrule_prelude(node.prelude.text, { minify })
name +=
SPACE +
(OPAQUE_PRELUDE_ATRULES.has(node.name!.toLowerCase())
? print_opaque_atrule_prelude(node.prelude.text, minify)
: print_atrule_prelude_node(node.prelude, minify))
}

let block_has_content =
Expand Down
4 changes: 2 additions & 2 deletions test/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ test('format minified Vadims example', () => {

let expected = `@layer what {
@container (width > 0) {
@media (min-height: .001px) {
@media (min-height: 0.001px) {
ul:has(:nth-child(1 of li)):hover {
--is: this;
}
Expand All @@ -74,7 +74,7 @@ test('format minified Vadims example', () => {
})

test('minify keeps already-minified CSS unchanged', () => {
let input = `@layer what{@container (width>0){@media (min-height:.001px){ul:has(:nth-child(1 of li)):hover{--is:this}}}}`
let input = `@layer what{@container (width>0){@media (min-height:0.001px){ul:has(:nth-child(1 of li)):hover{--is:this}}}}`
let actual = minify(input)
expect(actual).toEqual(input)
})
Expand Down
Loading
Loading