Skip to content

Commit 08d9f19

Browse files
authored
Merge pull request #10 from node-projects/copilot/add-css-formatting-functionality
Format newly added AST nodes in identity mode
2 parents 1ae38a1 + c5a0e31 commit 08d9f19

File tree

2 files changed

+377
-50
lines changed

2 files changed

+377
-50
lines changed

src/stringify/compiler.ts

Lines changed: 186 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -169,12 +169,14 @@ class Compiler {
169169
rawPrelude?: string,
170170
) {
171171
if (this.identity && rawPrelude) {
172-
return (
172+
this.indent(1);
173+
const result =
173174
this.emit(rawPrelude, position) +
174175
this.emit('{') +
175-
this.mapVisit(this.filterEmptyRules(rules)) +
176-
this.emit('}')
177-
);
176+
this.identityVisitBlock(this.filterEmptyRules(rules), 'rules') +
177+
this.emit('}');
178+
this.indent(-1);
179+
return result;
178180
}
179181
const filteredRules = this.filterEmptyRules(this.stripWhitespace(rules));
180182
if (this.compress) {
@@ -203,12 +205,14 @@ class Compiler {
203205
rawPrelude?: string,
204206
) {
205207
if (this.identity && rawPrelude) {
206-
return (
208+
this.indent(1);
209+
const result =
207210
this.emit(rawPrelude, position) +
208211
this.emit('{') +
209-
this.mapVisit(declarations) +
210-
this.emit('}')
211-
);
212+
this.identityVisitBlock(declarations, 'decls') +
213+
this.emit('}');
214+
this.indent(-1);
215+
return result;
212216
}
213217
const stripped = this.stripWhitespace(declarations);
214218
if (this.compress) {
@@ -253,7 +257,7 @@ class Compiler {
253257
// Fallback to beautified when preserveFormatting was not used
254258
return this.stylesheet(node);
255259
}
256-
return this.filterEmptyRules(rules).map(this.visit, this).join('');
260+
return this.identityVisitBlock(rules as CssAllNodesAST[], 'stylesheet');
257261
}
258262

259263
/**
@@ -293,6 +297,111 @@ class Compiler {
293297
});
294298
}
295299

300+
/**
301+
* Check whether a node was newly created (not parsed from source).
302+
* New nodes lack the raw formatting properties set by the parser.
303+
*/
304+
private isNewNode(node: CssAllNodesAST): boolean {
305+
switch (node.type) {
306+
case CssTypes.whitespace:
307+
return false;
308+
case CssTypes.comment:
309+
return !(node as CssCommonPositionAST).position;
310+
case CssTypes.declaration:
311+
return (node as CssDeclarationAST).rawBetween == null;
312+
case CssTypes.rule:
313+
return (node as CssRuleAST).rawPrelude == null;
314+
default:
315+
if ('rawPrelude' in node)
316+
return !(node as { rawPrelude?: string }).rawPrelude;
317+
if ('rawSource' in node)
318+
return !(node as { rawSource?: string }).rawSource;
319+
return !(node as CssCommonPositionAST).position;
320+
}
321+
}
322+
323+
/**
324+
* Visit block children in identity mode, formatting newly added nodes
325+
* with beautified output while preserving original formatting for
326+
* existing nodes.
327+
*
328+
* @param nodes - The child nodes to visit
329+
* @param context - 'decls' for declaration blocks, 'rules' for nested
330+
* rule blocks (e.g. @media), 'stylesheet' for top-level
331+
*/
332+
private identityVisitBlock(
333+
nodes: Array<CssAllNodesAST>,
334+
context: 'decls' | 'rules' | 'stylesheet',
335+
): string {
336+
const filtered = this.filterEmptyRules(nodes);
337+
338+
// Fast path: no new nodes – visit normally
339+
if (!filtered.some((n) => this.isNewNode(n))) {
340+
return this.mapVisit(filtered);
341+
}
342+
343+
let buf = '';
344+
const needsDoubleNewline = context === 'rules' || context === 'stylesheet';
345+
346+
for (let i = 0; i < filtered.length; i++) {
347+
const node = filtered[i];
348+
349+
if (!this.isNewNode(node)) {
350+
buf += this.visit(node);
351+
continue;
352+
}
353+
354+
// ── New node: add proper separator before it ──
355+
356+
// Check whether the buffer already ends with \n (possibly followed
357+
// by spaces that were originally the closing-brace indent). If so,
358+
// trim those trailing spaces – the beautified fallback of the new
359+
// node will supply the correct indentation.
360+
const lastNl = buf.lastIndexOf('\n');
361+
const hasTrailingNewline =
362+
lastNl >= 0 && buf.slice(lastNl + 1).trim() === '';
363+
364+
if (hasTrailingNewline) {
365+
buf = buf.slice(0, lastNl + 1);
366+
} else if (buf.length === 0 && context !== 'stylesheet') {
367+
// First child in a block – add newline after opening brace
368+
buf += '\n';
369+
} else if (buf.length > 0) {
370+
buf += '\n';
371+
}
372+
373+
if (needsDoubleNewline && buf.length > 1 && !buf.endsWith('\n\n')) {
374+
buf += '\n';
375+
}
376+
377+
// Visit the node (uses beautified fallback since raw props are absent)
378+
const str = this.visit(node);
379+
if (str) buf += str;
380+
}
381+
382+
// Add trailing newline + closing-brace indent when the last meaningful
383+
// node is new (blocks only, not the top-level stylesheet)
384+
if (context !== 'stylesheet') {
385+
const lastMeaningful = [...filtered]
386+
.reverse()
387+
.find((n) => n.type !== CssTypes.whitespace);
388+
if (
389+
lastMeaningful &&
390+
this.isNewNode(lastMeaningful) &&
391+
buf.length > 0 &&
392+
!buf.endsWith('\n')
393+
) {
394+
buf += '\n';
395+
// Indent for the closing brace (one level less than current)
396+
this.indent(-1);
397+
buf += this.indent();
398+
this.indent(1);
399+
}
400+
}
401+
402+
return buf;
403+
}
404+
296405
/**
297406
* Visit whitespace node.
298407
*/
@@ -311,7 +420,8 @@ class Compiler {
311420
if (this.compress) {
312421
return this.emit('', node.position);
313422
}
314-
return this.emit(`${this.indent()}/*${node.comment}*/`, node.position);
423+
const indent = this.identity && node.position ? '' : this.indent();
424+
return this.emit(`${indent}/*${node.comment}*/`, node.position);
315425
}
316426

317427
/**
@@ -331,12 +441,17 @@ class Compiler {
331441
*/
332442
layer(node: CssLayerAST) {
333443
if (this.identity && node.rawPrelude && node.rules) {
334-
return (
444+
this.indent(1);
445+
const result =
335446
this.emit(node.rawPrelude, node.position) +
336447
this.emit('{') +
337-
this.mapVisit(this.filterEmptyRules(<CssAllNodesAST[]>node.rules)) +
338-
this.emit('}')
339-
);
448+
this.identityVisitBlock(
449+
this.filterEmptyRules(<CssAllNodesAST[]>node.rules),
450+
'rules',
451+
) +
452+
this.emit('}');
453+
this.indent(-1);
454+
return result;
340455
}
341456
if (this.identity && !node.rules && node.rawSource) {
342457
return this.emit(node.rawSource, node.position);
@@ -388,12 +503,14 @@ class Compiler {
388503
document(node: CssDocumentAST) {
389504
const doc = `@${node.vendor || ''}document ${node.document}`;
390505
if (this.identity && node.rawPrelude) {
391-
return (
506+
this.indent(1);
507+
const result =
392508
this.emit(node.rawPrelude, node.position) +
393509
this.emit('{') +
394-
this.mapVisit(this.filterEmptyRules(node.rules)) +
395-
this.emit('}')
396-
);
510+
this.identityVisitBlock(this.filterEmptyRules(node.rules), 'rules') +
511+
this.emit('}');
512+
this.indent(-1);
513+
return result;
397514
}
398515
const rules = this.stripWhitespace(node.rules);
399516
if (this.compress) {
@@ -461,12 +578,14 @@ class Compiler {
461578
*/
462579
keyframes(node: CssKeyframesAST) {
463580
if (this.identity && node.rawPrelude) {
464-
return (
581+
this.indent(1);
582+
const result =
465583
this.emit(node.rawPrelude, node.position) +
466584
this.emit('{') +
467-
this.mapVisit(node.keyframes) +
468-
this.emit('}')
469-
);
585+
this.identityVisitBlock(node.keyframes, 'rules') +
586+
this.emit('}');
587+
this.indent(-1);
588+
return result;
470589
}
471590
const frames = this.stripWhitespace(node.keyframes);
472591
if (this.compress) {
@@ -494,12 +613,14 @@ class Compiler {
494613
keyframe(node: CssKeyframeAST) {
495614
const decls = node.declarations;
496615
if (this.identity && node.rawPrelude) {
497-
return (
616+
this.indent(1);
617+
const result =
498618
this.emit(node.rawPrelude, node.position) +
499619
this.emit('{') +
500-
this.mapVisit(decls) +
501-
this.emit('}')
502-
);
620+
this.identityVisitBlock(decls, 'decls') +
621+
this.emit('}');
622+
this.indent(-1);
623+
return result;
503624
}
504625
const stripped = this.stripWhitespace(decls);
505626
if (this.compress) {
@@ -525,12 +646,14 @@ class Compiler {
525646
*/
526647
page(node: CssPageAST) {
527648
if (this.identity && node.rawPrelude) {
528-
return (
649+
this.indent(1);
650+
const result =
529651
this.emit(node.rawPrelude, node.position) +
530652
this.emit('{') +
531-
this.mapVisit(node.declarations) +
532-
this.emit('}')
533-
);
653+
this.identityVisitBlock(node.declarations, 'decls') +
654+
this.emit('}');
655+
this.indent(-1);
656+
return result;
534657
}
535658
const decls = this.stripWhitespace(node.declarations);
536659
if (this.compress) {
@@ -560,12 +683,14 @@ class Compiler {
560683
*/
561684
pageMarginBox(node: CssPageMarginBoxAST) {
562685
if (this.identity && node.rawPrelude) {
563-
return (
686+
this.indent(1);
687+
const result =
564688
this.emit(node.rawPrelude, node.position) +
565689
this.emit('{') +
566-
this.mapVisit(node.declarations) +
567-
this.emit('}')
568-
);
690+
this.identityVisitBlock(node.declarations, 'decls') +
691+
this.emit('}');
692+
this.indent(-1);
693+
return result;
569694
}
570695
const decls = this.stripWhitespace(node.declarations);
571696
if (this.compress) {
@@ -603,12 +728,14 @@ class Compiler {
603728
*/
604729
host(node: CssHostAST) {
605730
if (this.identity && node.rawPrelude) {
606-
return (
731+
this.indent(1);
732+
const result =
607733
this.emit(node.rawPrelude, node.position) +
608734
this.emit('{') +
609-
this.mapVisit(this.filterEmptyRules(node.rules)) +
610-
this.emit('}')
611-
);
735+
this.identityVisitBlock(this.filterEmptyRules(node.rules), 'rules') +
736+
this.emit('}');
737+
this.indent(-1);
738+
return result;
612739
}
613740
const rules = this.stripWhitespace(node.rules);
614741
if (this.compress) {
@@ -681,12 +808,14 @@ class Compiler {
681808
*/
682809
scope(node: CssScopeAST) {
683810
if (this.identity && node.rawPrelude) {
684-
return (
811+
this.indent(1);
812+
const result =
685813
this.emit(node.rawPrelude, node.position) +
686814
this.emit('{') +
687-
this.mapVisit(this.filterEmptyRules(node.rules)) +
688-
this.emit('}')
689-
);
815+
this.identityVisitBlock(this.filterEmptyRules(node.rules), 'rules') +
816+
this.emit('}');
817+
this.indent(-1);
818+
return result;
690819
}
691820
const prelude = node.scope ? ` ${node.scope}` : '';
692821
return this.rulesBlock(
@@ -725,12 +854,17 @@ class Compiler {
725854
*/
726855
genericAtRule(node: CssGenericAtRuleAST) {
727856
if (this.identity && node.rawPrelude && node.rules) {
728-
return (
857+
this.indent(1);
858+
const result =
729859
this.emit(node.rawPrelude, node.position) +
730860
this.emit('{') +
731-
this.mapVisit(this.filterEmptyRules(<CssAllNodesAST[]>node.rules)) +
732-
this.emit('}')
733-
);
861+
this.identityVisitBlock(
862+
this.filterEmptyRules(<CssAllNodesAST[]>node.rules),
863+
'rules',
864+
) +
865+
this.emit('}');
866+
this.indent(-1);
867+
return result;
734868
}
735869
const prelude = node.prelude ? ` ${node.prelude}` : '';
736870
const rules = node.rules
@@ -772,12 +906,14 @@ class Compiler {
772906
const decls = node.declarations;
773907

774908
if (this.identity && node.rawPrelude) {
775-
return (
909+
this.indent(1);
910+
const result =
776911
this.emit(node.rawPrelude, node.position) +
777912
this.emit('{') +
778-
this.mapVisit(decls) +
779-
this.emit('}')
780-
);
913+
this.identityVisitBlock(decls, 'decls') +
914+
this.emit('}');
915+
this.indent(-1);
916+
return result;
781917
}
782918

783919
const stripped = this.stripWhitespace(decls);

0 commit comments

Comments
 (0)