diff --git a/package.json b/package.json index 0ae1ed0..6517103 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "lint": "oxlint && oxfmt --check" }, "dependencies": { - "@projectwallace/css-parser": "~0.17.0" + "@projectwallace/css-parser": "~0.18.1" }, "devDependencies": { "@projectwallace/preset-oxlint": "^0.0.11", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1947e32..ea58115 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: '@projectwallace/css-parser': - specifier: ~0.17.0 - version: 0.17.0 + specifier: ~0.18.1 + version: 0.18.1 devDependencies: '@projectwallace/preset-oxlint': specifier: ^0.0.11 @@ -347,8 +347,8 @@ packages: cpu: [x64] os: [win32] - '@projectwallace/css-parser@0.17.0': - resolution: {integrity: sha512-y+PHhq9YzC8M3Rndw3XX2tucnV8zEYDLyz/fEsRfd0VCSxol0bB6MU03vvXuICkW8+FLsME6Ff0TA0DVEUfg5A==} + '@projectwallace/css-parser@0.18.1': + resolution: {integrity: sha512-XTeexH92z5NLWfNw031emlEdd7P3yxJPv9ac/ZN3tFxyPyz1l8vEdZ6afQsd+cpfclOMvFfWbDxhfZ7IRePoLg==} engines: {pnpm: '>=11.0.0'} '@projectwallace/preset-oxlint@0.0.11': @@ -1525,7 +1525,7 @@ snapshots: '@oxlint/binding-win32-x64-msvc@1.74.0': optional: true - '@projectwallace/css-parser@0.17.0': {} + '@projectwallace/css-parser@0.18.1': {} '@projectwallace/preset-oxlint@0.0.11(oxlint@1.74.0)': dependencies: diff --git a/src/lib/index.ts b/src/lib/index.ts index 67434db..80a9cda 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -20,10 +20,24 @@ 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 SupportsQuery, + type SupportsDeclaration, + type FeatureRange, + type MediaFeature, + type Function as CSSFunction, type NthSelector, type NthOfSelector, type PseudoClassSelector, @@ -410,6 +424,215 @@ 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. The feature name never appears as a child + * node, so its printed position is found by comparing its offset in the + * source against the sibling Dimension/PreludeOperator children. */ +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 }] + + let children = [...(node as unknown as Iterable)] + for (let i = 0; i < children.length; i++) { + let child = children[i]! + if (is_prelude_operator(child)) { + let text = child.text + // Upstream tokenizer bug: `=>` comes through as two separate + // single-char operator tokens ("=" then ">") instead of one, unlike + // `>=`/`<=` which are correctly kept whole — merge them back + // together when directly adjacent. + let next = children[i + 1] + if (next && is_prelude_operator(next) && next.start === child.end) { + text += next.text + i++ + } + items.push({ offset: child.start, text: optional_space + text + optional_space }) + } else { + items.push({ offset: child.start, text: child.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)`. `node.value` is a fully parsed value node (as of + * @projectwallace/css-parser 0.18.0 — function calls like `calc()`/`env()` + * used to be silently dropped from it), so it can go straight through + * `print_list` like a declaration's value would. */ +function print_media_feature(node: MediaFeature, minify: boolean): string { + if (node.value === null) { + return OPEN_PARENTHESES + node.property + CLOSE_PARENTHESES + } + + let optional_space = minify ? EMPTY_STRING : SPACE + return ( + OPEN_PARENTHESES + + node.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 { + // node.has_children means the prelude parser found a simple `prop: value` + // declaration inside the parens (never true for @import's `supports(...)`, + // which doesn't get a SupportsDeclaration child at all) — its value node is + // fully parsed (see print_media_feature), so it can be printed directly + // via format_declaration instead of re-parsing raw text. + let condition = node.has_children + ? format_declaration(node.first_child.first_child, { minify }) + : format_atrule_prelude(node.value, { minify }) + // `@import url(...) supports(display: grid)` uses the functional notation + // (keyword + parens around the condition) rather than the standalone + // `@supports (...)` form's bare parenthesized condition — node.text still + // carries the "supports(" prefix in that case. + 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 = node.name.toLowerCase() + // `@supports selector(...)` takes a selector list, not a declaration — + // deep-parsed as of @projectwallace/css-parser 0.18.1 (previously this + // whole prelude came back empty). + 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 + ) + } + // A container style() condition is deep-parsed into a SupportsDeclaration + // the same way @supports's own condition is (see print_supports_query), + // as of @projectwallace/css-parser 0.18.1. Function's declared child type + // doesn't include SupportsDeclaration (it's normally only used for + // value-position functions like calc()), 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` URL: lowercases a leading `url(` keyword but never + * touches quote style, matching how `format_atrule_prelude` already treats + * quotes inside at-rule preludes (unlike value-position `url()`, which does + * get its quotes normalized by `print_url`). */ +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: a nested + * query, a feature/condition, an operator, or (for anything the prelude + * parser doesn't specifically model — Identifier, String, LayerName from + * `@import`'s `layer()`, Raw, ...) the node's raw text verbatim. + */ +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, minify) + } + 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)) { + // Both the standalone `@layer name[, name2, ...];` statement form + // (bare "name") and @import's embedded `layer(...)` reach this branch. + // Only the latter has a "layer(" keyword to lowercase (matching + // format_atrule_prelude); the former's node.text is just the name + // itself, dots and all (@projectwallace/css-parser 0.17.0+). + return /^layer\(/i.test(node.text) ? 'layer(' + node.text.slice(6) : node.text + } + if (is_prelude_operator(node)) { + return node.text + } + return node.text +} + +/** + * Prints a `,`-or-space joined sequence of at-rule prelude components: a + * media/container query's own parts, or the top-level children of an + * AtrulePrelude/MediaQuery/ContainerQuery. + * + * Same-type siblings back to back only ever happens for comma-separated + * lists (e.g. multiple `MediaQuery`s in `screen, print`), so a comma is used + * there; every other adjacent pair (`screen and (min-width: 100px)`, or + * `url(...) layer(...) supports(...)` in `@import`) requires a real space + * regardless of `minify` — CSS syntax doesn't allow gluing them together. + */ +function print_prelude_children(node: CSSNode, optional_space: string, minify: boolean): string { + let parts: string[] = [] + for (let child of node as unknown as Iterable) { + 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 (`Atrule.prelude` when + * `parse_atrule_preludes: true`). + * + * Falls back to the existing regex-based `format_atrule_prelude` (still + * correct, just not structure-aware), applied to the whole prelude's raw + * text, when: + * - the prelude parser doesn't recognize the at-rule at all, or the prelude + * doesn't fit what it expects (`@page :first`, a quoted `@keyframes` name, + * `@starting-style`, an unsupported `@supports selector(...)`/function + * condition, ...) — it returns no children in that case, and nothing new + * is invented for at-rules with no test coverage, just today's existing + * (already tested) text-based formatting; + * - the prelude contains a comment: the prelude parser has no equivalent of + * the main parser's `on_comment` hook, so any comment gets silently + * dropped while building the structured tree. + */ +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 }) + } + // The prelude parser has no equivalent of the main parser's on_comment + // hook, so any comment inside a prelude is silently dropped while + // building the structured tree. Fall back to the regex formatter, which + // operates on the raw text and never removes anything, whenever a + // comment is present. + if (node.text.includes('/*')) { + 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 */ @@ -431,7 +654,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 }) => { @@ -590,7 +813,7 @@ export function format( function print_atrule(node: Atrule): string { let name = '@' + node.name!.toLowerCase() 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 = diff --git a/test/api.test.ts b/test/api.test.ts index 6f4e2fd..de8d9c1 100644 --- a/test/api.test.ts +++ b/test/api.test.ts @@ -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; } @@ -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) }) diff --git a/test/atrules.test.ts b/test/atrules.test.ts index 651890f..c7a162a 100644 --- a/test/atrules.test.ts +++ b/test/atrules.test.ts @@ -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) {}`, @@ -415,6 +415,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(` diff --git a/test/minify.test.ts b/test/minify.test.ts index 8d9416c..0240706 100644 --- a/test/minify.test.ts +++ b/test/minify.test.ts @@ -72,7 +72,7 @@ test('minified Vadims example', () => { let actual = minify( `@layer what{@container (width>0){@media (min-height:.001px){ul:has(:nth-child(1 of li)):hover{--is:this}}}}`, ) - let expected = `@layer what{@container (width>0){@media (min-height:.001px){ul:has(:nth-child(1 of li)):hover{--is:this}}}}` + let expected = `@layer what{@container (width>0){@media (min-height:0.001px){ul:has(:nth-child(1 of li)):hover{--is:this}}}}` expect(actual).toEqual(expected) })