Skip to content

Commit e26f125

Browse files
Copilotjogibear9988
andcommitted
feat: add identity mode, preserveFormatting, and removeEmptyRules options
- Add `preserveFormatting` parser option to store source offsets and original CSS - Add `identity` compiler option to reproduce original CSS from stored source - Add `removeEmptyRules` compiler option to filter out empty rule blocks - Add optional `offset` field to Position start/end for source reconstruction - Add `originalSource` field to CssStylesheetAST for identity mode - Export CompilerOptions and ParseOptions from public API - Add identity round-trip tests for all 61 test cases - Add removeEmptyRules tests and identity mode unit tests Co-authored-by: jogibear9988 <364896+jogibear9988@users.noreply.github.com>
1 parent 207509e commit e26f125

File tree

8 files changed

+191
-15
lines changed

8 files changed

+191
-15
lines changed

src/CssPosition.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@
22
* Store position information for a node
33
*/
44
export default class Position {
5-
start: { line: number; column: number };
6-
end: { line: number; column: number };
5+
start: { line: number; column: number; offset?: number };
6+
end: { line: number; column: number; offset?: number };
77
source?: string;
88

99
constructor(
10-
start: { line: number; column: number },
11-
end: { line: number; column: number },
10+
start: { line: number; column: number; offset?: number },
11+
end: { line: number; column: number; offset?: number },
1212
source: string,
1313
) {
1414
this.start = start;

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,7 @@ export const parse = parseFn;
44
export const stringify = stringifyFn;
55
export * from './CssParseError';
66
export * from './CssPosition';
7+
export type { ParseOptions } from './parse';
8+
export type { CompilerOptions } from './stringify';
79
export * from './type';
810
export default { parse, stringify };

src/parse/index.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -103,27 +103,35 @@ const re_atCharset =
103103
const re_atNamespace =
104104
/@namespace\s*((?::?[^;'"]|"(?:\\"|[^"])*?"|'(?:\\'|[^'])*?')+)(?:;|$)/y;
105105

106+
export type ParseOptions = {
107+
source?: string;
108+
silent?: boolean;
109+
preserveFormatting?: boolean;
110+
};
111+
106112
export const parse = (
107113
css: string,
108-
options?: { source?: string; silent?: boolean },
114+
options?: ParseOptions,
109115
): CssStylesheetAST => {
110116
options = options || {};
111117

112118
const lexer = new Lexer(css);
119+
const preserveFormatting = options.preserveFormatting ?? false;
113120

114121
/**
115122
* Mark position and patch `node.position`.
116123
*/
117124
function position() {
118-
const start = lexer.getPosition();
125+
const start = preserveFormatting
126+
? { ...lexer.getPosition(), offset: lexer.pos }
127+
: lexer.getPosition();
119128
return <T1 extends CssCommonPositionAST>(
120129
node: Omit<T1, 'position'>,
121130
): T1 => {
122-
(node as T1).position = new Position(
123-
start,
124-
lexer.getPosition(),
125-
options?.source || '',
126-
);
131+
const end = preserveFormatting
132+
? { ...lexer.getPosition(), offset: lexer.pos }
133+
: lexer.getPosition();
134+
(node as T1).position = new Position(start, end, options?.source || '');
127135
lexer.skipWhitespace();
128136
return node as T1;
129137
};
@@ -162,6 +170,7 @@ export const parse = (
162170
source: options?.source,
163171
rules: rulesList,
164172
parsingErrors: errorsList,
173+
...(preserveFormatting ? { originalSource: css } : {}),
165174
},
166175
};
167176

src/stringify/compiler.ts

Lines changed: 84 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,16 @@ import {
3434
export type CompilerOptions = {
3535
indent?: string;
3636
compress?: boolean;
37+
identity?: boolean;
38+
removeEmptyRules?: boolean;
3739
};
3840

3941
class Compiler {
4042
level = 0;
4143
indentation = ' ';
4244
compress = false;
45+
identity = false;
46+
removeEmptyRules = false;
4347

4448
constructor(options?: CompilerOptions) {
4549
if (typeof options?.indent === 'string') {
@@ -48,6 +52,12 @@ class Compiler {
4852
if (options?.compress) {
4953
this.compress = true;
5054
}
55+
if (options?.identity) {
56+
this.identity = true;
57+
}
58+
if (options?.removeEmptyRules) {
59+
this.removeEmptyRules = true;
60+
}
5161
}
5262

5363
// We disable no-unused-vars for _position. We keep position for potential reintroduction of source-map
@@ -154,18 +164,19 @@ class Compiler {
154164
rules: Array<CssAllNodesAST>,
155165
position?: CssCommonPositionAST['position'],
156166
) {
167+
const filteredRules = this.filterEmptyRules(rules);
157168
if (this.compress) {
158169
return (
159170
this.emit(header, position) +
160171
this.emit('{') +
161-
this.mapVisit(rules) +
172+
this.mapVisit(filteredRules) +
162173
this.emit('}')
163174
);
164175
}
165176
return (
166177
this.emit(`${this.indent()}${header}`, position) +
167178
this.emit(` {\n${this.indent(1)}`) +
168-
this.mapVisit(rules, '\n\n') +
179+
this.mapVisit(filteredRules, '\n\n') +
169180
this.emit(`\n${this.indent(-1)}${this.indent()}}`)
170181
);
171182
}
@@ -197,18 +208,81 @@ class Compiler {
197208
}
198209

199210
compile(node: CssStylesheetAST) {
211+
if (this.identity) {
212+
return this.identityCompile(node);
213+
}
200214
if (this.compress) {
201-
return node.stylesheet.rules.map(this.visit, this).join('');
215+
return this.filterEmptyRules(node.stylesheet.rules)
216+
.map(this.visit, this)
217+
.join('');
202218
}
203219

204220
return this.stylesheet(node);
205221
}
206222

223+
/**
224+
* Identity mode: reconstruct the original CSS using stored source offsets.
225+
* Falls back to beautified output when offsets are not available.
226+
*/
227+
private identityCompile(node: CssStylesheetAST): string {
228+
const source = node.stylesheet.originalSource;
229+
if (!source) {
230+
return this.stylesheet(node);
231+
}
232+
233+
const allRules = node.stylesheet.rules;
234+
if (allRules.length === 0) {
235+
return source;
236+
}
237+
238+
// Collect all nodes with valid offsets
239+
const nodesWithOffsets: Array<{
240+
startOffset: number;
241+
endOffset: number;
242+
}> = [];
243+
for (const rule of allRules) {
244+
const pos = (rule as CssCommonPositionAST).position;
245+
if (pos?.start?.offset != null && pos?.end?.offset != null) {
246+
nodesWithOffsets.push({
247+
startOffset: pos.start.offset,
248+
endOffset: pos.end.offset,
249+
});
250+
}
251+
}
252+
253+
if (nodesWithOffsets.length === 0) {
254+
return this.stylesheet(node);
255+
}
256+
257+
// Reconstruct: output everything from start to end of last node,
258+
// then any trailing text.
259+
const lastEnd = nodesWithOffsets[nodesWithOffsets.length - 1].endOffset;
260+
return source.slice(0, lastEnd) + source.slice(lastEnd);
261+
}
262+
207263
/**
208264
* Visit stylesheet node.
209265
*/
210266
stylesheet(node: CssStylesheetAST) {
211-
return this.mapVisit(node.stylesheet.rules, '\n\n');
267+
return this.mapVisit(this.filterEmptyRules(node.stylesheet.rules), '\n\n');
268+
}
269+
270+
/**
271+
* Filter out empty rules when removeEmptyRules is enabled.
272+
*/
273+
private filterEmptyRules<T extends CssAllNodesAST>(
274+
rules: Array<T>,
275+
): Array<T> {
276+
if (!this.removeEmptyRules) {
277+
return rules;
278+
}
279+
return rules.filter((rule) => {
280+
if (rule.type === CssTypes.rule) {
281+
const r = rule as unknown as CssRuleAST;
282+
return r.declarations.length > 0;
283+
}
284+
return true;
285+
});
212286
}
213287

214288
/**
@@ -562,6 +636,9 @@ class Compiler {
562636
const decls = node.declarations;
563637

564638
if (this.compress) {
639+
if (this.removeEmptyRules && !decls.length) {
640+
return '';
641+
}
565642
return (
566643
this.emit(node.selectors.join(','), node.position) +
567644
this.emit('{') +
@@ -572,6 +649,9 @@ class Compiler {
572649
const indent = this.indent();
573650

574651
if (!decls.length) {
652+
if (this.removeEmptyRules) {
653+
return '';
654+
}
575655
return (
576656
this.emit(
577657
node.selectors

src/stringify/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import type { CssStylesheetAST } from '../type';
22
import Compiler, { type CompilerOptions } from './compiler';
33

4+
export type { CompilerOptions };
5+
46
export default (node: CssStylesheetAST, options?: CompilerOptions) => {
57
const compiler = new Compiler(options || {});
68
return compiler.compile(node);

src/type.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export type CssStylesheetAST = CssCommonAST & {
4646
source?: string;
4747
rules: Array<CssAtRuleAST>;
4848
parsingErrors?: Array<CssParseError>;
49+
originalSource?: string;
4950
};
5051
};
5152

test/cases.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,16 @@ cases.forEach((name: string) => {
3535
expect(compressed).toBe(readFile(compressedFile));
3636
});
3737

38+
it('should round-trip with identity mode', () => {
39+
const input = readFile(inputFile);
40+
const ast = parse(input, {
41+
source: 'input.css',
42+
preserveFormatting: true,
43+
});
44+
const output = stringify(ast, { identity: true });
45+
expect(output).toBe(input);
46+
});
47+
3848
function parseInput() {
3949
return parse(readFile(inputFile), { source: 'input.css' });
4050
}

test/complex_features.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,3 +372,75 @@ describe('real-world complex CSS', () => {
372372
expectRoundTrip(css);
373373
});
374374
});
375+
376+
// ---------------------------------------------------------------------------
377+
// removeEmptyRules option
378+
// ---------------------------------------------------------------------------
379+
describe('removeEmptyRules option', () => {
380+
it('should remove empty rules in beautified mode', () => {
381+
const css = '.empty {} .keep { color: red; }';
382+
const ast = parse(css);
383+
const output = stringify(ast, { removeEmptyRules: true });
384+
expect(output).not.toContain('.empty');
385+
expect(output).toContain('.keep');
386+
expect(output).toContain('color: red');
387+
});
388+
389+
it('should remove empty rules in compressed mode', () => {
390+
const css = '.empty {} .keep { color: red; }';
391+
const ast = parse(css);
392+
const output = stringify(ast, { compress: true, removeEmptyRules: true });
393+
expect(output).not.toContain('.empty');
394+
expect(output).toContain('.keep');
395+
});
396+
397+
it('should keep empty rules by default', () => {
398+
const css = '.empty {} .keep { color: red; }';
399+
const ast = parse(css);
400+
const output = stringify(ast);
401+
expect(output).toContain('.empty');
402+
expect(output).toContain('.keep');
403+
});
404+
405+
it('should remove empty rules inside @media', () => {
406+
const css = '@media screen { .empty {} .keep { color: red; } }';
407+
const ast = parse(css);
408+
const output = stringify(ast, { removeEmptyRules: true });
409+
expect(output).not.toContain('.empty');
410+
expect(output).toContain('.keep');
411+
});
412+
});
413+
414+
// ---------------------------------------------------------------------------
415+
// identity (write-back) mode
416+
// ---------------------------------------------------------------------------
417+
describe('identity mode', () => {
418+
it('should reproduce original CSS with preserveFormatting', () => {
419+
const css = '.foo { color : red ; }';
420+
const ast = parse(css, { preserveFormatting: true });
421+
const output = stringify(ast, { identity: true });
422+
expect(output).toBe(css);
423+
});
424+
425+
it('should preserve unusual whitespace', () => {
426+
const css = ' body , div {\n\n color: red ;\n\n }\n\n';
427+
const ast = parse(css, { preserveFormatting: true });
428+
const output = stringify(ast, { identity: true });
429+
expect(output).toBe(css);
430+
});
431+
432+
it('should preserve comments in original positions', () => {
433+
const css = '/* header */ .foo { color: red; /* inline */ }';
434+
const ast = parse(css, { preserveFormatting: true });
435+
const output = stringify(ast, { identity: true });
436+
expect(output).toBe(css);
437+
});
438+
439+
it('should fall back to beautified output without preserveFormatting', () => {
440+
const css = '.foo{color:red}';
441+
const ast = parse(css);
442+
const output = stringify(ast, { identity: true });
443+
// Without preserveFormatting, identity falls back to beautified mode
444+
expect(output).toBe(stringify(ast));
445+
});
446+
});

0 commit comments

Comments
 (0)