Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 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
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
168 changes: 166 additions & 2 deletions src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,26 @@ import {
is_declaration,
is_rule,
is_atrule,
is_media_query,
is_container_query,
is_media_feature,
is_feature_range,
is_supports_query,
is_prelude_operator,
is_prelude_selectorlist,
is_layer_name,
type Operator,
type Value,
type Declaration,
type Raw,
type AtrulePrelude,
type MediaQuery,
type ContainerQuery,
type SupportsQuery,
type SupportsDeclaration,
type FeatureRange,
type MediaFeature,
type Function as CSSFunction,
type NthSelector,
type NthOfSelector,
type PseudoClassSelector,
Expand Down Expand Up @@ -398,6 +414,154 @@ 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 `@supports (display: grid)`-style conditions, including
* `and`/`or`/`not`-joined and nested-boolean-group forms that don't reduce to
* a single declaration (e.g. `selector(:hover)`), which print as-is. */
function print_supports_query(node: SupportsQuery, minify: boolean): string {
// has_children means a simple `prop: value` declaration was found inside.
let condition = node.has_children
? format_declaration(node.first_child.first_child, { minify })
: format_atrule_prelude(node.value, { minify })
// @import's functional `supports(...)` notation needs the keyword;
// standalone @supports's bare `(...)` form doesn't.
let prefix = /^supports\(/i.test(node.text) ? 'supports' : EMPTY_STRING
return prefix + OPEN_PARENTHESES + condition + CLOSE_PARENTHESES
}

/** Prints a functional container-query condition, e.g. `style(--foo: bar)`. */
function print_prelude_function(node: CSSFunction, minify: boolean): string {
let name = print_identifier(node.name)
// `selector(...)` takes a selector list, not a declaration.
if (name === 'selector' && node.has_children && is_selector_list(node.first_child)) {
return (
name +
OPEN_PARENTHESES +
format_selector_list(node.first_child, { minify }) +
CLOSE_PARENTHESES
)
}
// style()'s condition is a SupportsDeclaration, same as @supports's own
// (see print_supports_query); Function's declared child type doesn't
// include it, hence the cast.
if (node.has_children) {
let declaration = (node.first_child as unknown as SupportsDeclaration).first_child
return name + OPEN_PARENTHESES + format_declaration(declaration, { minify }) + CLOSE_PARENTHESES
}
if (node.value === null) return node.text
return name + OPEN_PARENTHESES + format_atrule_prelude(node.value, { minify }) + CLOSE_PARENTHESES
}

/** Prints an `@import` specifier: lowercases a leading `url(` keyword, if
* present, but leaves quote style untouched. Unlike value-position `url()`
* (see `print_url`), an `@import` specifier's Url node can also be a bare
* string (`@import "foo";`) with no `url(` to lowercase. */
function print_prelude_url(node: CSSNode): string {
let text = node.text
if (/^url\(/i.test(text)) {
return 'url(' + text.slice(4)
}
return 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_prelude_url(node)
}
if (is_layer_name(node)) {
// @import's functional `layer(...)` notation has a keyword to
// lowercase; the standalone `@layer name;` statement form doesn't.
return /^layer\(/i.test(node.text) ? 'layer(' + node.text.slice(6) : node.text
}
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,
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) {
parts.push(child.type === child.next_sibling.type ? COMMA + optional_space : SPACE)
}
}
return parts.join(EMPTY_STRING)
}

/** 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 @@ -419,7 +583,7 @@ export function format(
// First pass: collect all comments
let comments: number[] = []
let ast = parse(css, {
parse_atrule_preludes: false,
parse_atrule_preludes: true,
on_comment: minify
? undefined
: ({ start, end }) => {
Expand Down Expand Up @@ -578,7 +742,7 @@ 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 + 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
138 changes: 134 additions & 4 deletions test/atrules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ test('@media prelude formatting', () => {
[`@media (update: slow)or (hover: none) {}`, `@media (update: slow) or (hover: none) {}`],
[
`@media all and (-moz-images-in-menus:0) and (min-resolution:.001dpcm) {}`,
`@media all and (-moz-images-in-menus: 0) and (min-resolution: .001dpcm) {}`,
`@media all and (-moz-images-in-menus: 0) and (min-resolution: 0.001dpcm) {}`,
],
[
`@media all and (-webkit-min-device-pixel-ratio: 10000),not all and (-webkit-min-device-pixel-ratio: 0) {}`,
Expand Down Expand Up @@ -345,16 +345,13 @@ test('new-fangled comparators (width > 1000px)', () => {
let actual = format(`
@container (width>1000px) {}
@media (width>1000px) {}
@media (width=>1000px) {}
@media (width<=1000px) {}
@media (200px<width<1000px) {}
`)
let expected = `@container (width > 1000px) {}

@media (width > 1000px) {}

@media (width => 1000px) {}

@media (width <= 1000px) {}

@media (200px < width < 1000px) {}`
Expand All @@ -367,6 +364,18 @@ test('minify: new-fangled comparators (width > 1000px)', () => {
expect(actual).toEqual(expected)
})

test('lowercases the media/container feature name', () => {
let actual = format(`@media (MIN-WIDTH: 100px) {}`)
let expected = `@media (min-width: 100px) {}`
expect(actual).toEqual(expected)
})

test('preserves casing of a custom-property media feature name', () => {
let actual = format(`@media (--Foo: 1) {}`)
let expected = `@media (--Foo: 1) {}`
expect(actual).toEqual(expected)
})

test('minify: keeps necessary whitespace between keywords', () => {
let actual = minify(`@media screen or print {}`)
let expected = `@media screen or print{}`
Expand Down Expand Up @@ -415,6 +424,127 @@ test('minify: keeps whitespace between "or" keyword and media feature', () => {
expect(actual).toEqual(expected)
})

test('lowercases @container style() function name', () => {
let actual = format(`@container STYLE(--foo: bar) { a { color: red; } }`)
let expected = `@container style(--foo: bar) {
a {
color: red;
}
}`
expect(actual).toEqual(expected)
})

test('preserves function calls inside @container style() condition values', () => {
let actual = format(`@container style(--foo: env(safe-area-inset-top)) {}`)
let expected = `@container style(--foo: env(safe-area-inset-top)) {}`
expect(actual).toEqual(expected)
})

test('does not corrupt a nested @container style() boolean group', () => {
let actual = format(`@container style((--a: 1) and (--b: 2)) {}`)
let expected = `@container style((--a: 1) and (--b: 2)) {}`
expect(actual).toEqual(expected)
})

test('formats multiple style() conditions joined by and/or', () => {
let actual = format(`@container name style(--theme: dark) and style(--size: large) {}`)
let expected = `@container name style(--theme: dark) and style(--size: large) {}`
expect(actual).toEqual(expected)
})

test('preserves function calls inside media feature values', () => {
let actual = format(`@media (min-width: env(safe-area-inset-top)) {}`)
let expected = `@media (min-width: env(safe-area-inset-top)) {}`
expect(actual).toEqual(expected)
})

test('preserves function calls inside @supports condition values', () => {
let actual = format(`@supports (background: linear-gradient(red, blue)) {}`)
let expected = `@supports (background: linear-gradient(red, blue)) {}`
expect(actual).toEqual(expected)
})

test('preserves multi-operator calc() inside a media feature', () => {
let actual = format(`@media (min-width: calc(1px + 2px * 3)) {}`)
let expected = `@media (min-width: calc(1px + 2px * 3)) {}`
expect(actual).toEqual(expected)
})

test('preserves dotted @layer names', () => {
let actual = format(`@layer base.normalize { a { color: red; } }`)
let expected = `@layer base.normalize {
a {
color: red;
}
}`
expect(actual).toEqual(expected)
})

test('preserves multiple dotted, comma-separated @layer names', () => {
let actual = format(`@layer a.b, c.d.e;`)
let expected = `@layer a.b, c.d.e;`
expect(actual).toEqual(expected)
})

test('formats @import with a dotted layer() name', () => {
let actual = format(`@import url("a.css") layer(a.b.c);`)
let expected = `@import url("a.css") layer(a.b.c);`
expect(actual).toEqual(expected)
})

test('does not lose the prelude of an at-rule the prelude parser does not recognize', () => {
let actual = format(`@starting-style { a { color: red; } }`)
let expected = `@starting-style {
a {
color: red;
}
}`
expect(actual).toEqual(expected)
})

test('does not lose an unrecognizable @page pseudo-class prelude', () => {
let actual = format(`@page :first {}`)
let expected = `@page : first {}`
expect(actual).toEqual(expected)
})

test('does not corrupt a nested @supports boolean group', () => {
let actual = format(`@supports ((display: grid) and (display: flex)) {}`)
let expected = `@supports ((display: grid) and (display: flex)) {}`
expect(actual).toEqual(expected)
})

test('does not duplicate the "only"/"not" media-query prefix', () => {
let actual = format(`
@media only screen {}
@media not screen {}
@media not (color) {}
@media not all and (monochrome) {}
`)
let expected = `@media only screen {}

@media not screen {}

@media not (color) {}

@media not all and (monochrome) {}`
expect(actual).toEqual(expected)
})

test('formats @supports selector() as a selector list, not a declaration', () => {
let actual = format(`
@supports selector([popover]:open) {}
@supports SELECTOR(:hover) {}
@supports selector(:hover) and (display: grid) {}
`)
let expected = `@supports selector([popover]:open) {}

@supports selector(:hover) {}

@supports selector(:hover) and (display: grid) {}`
expect(actual).toEqual(expected)
})

// oxlint-disable-next-line vitest/no-disabled-tests
test.skip('preserves comments', () => {
let actual = format(`
Expand Down
Loading
Loading