fix: preserve CSS comments in the AST printer (#18637)

Adds a new `comments` array to the stylesheet node which CSS comments
are added to. Is subsequently used in `print` to see them in the output.

Alternative to #18475
pull/18628/head
Simon H 1 month ago committed by GitHub
parent 3ed9db4ba7
commit a1d5035d17
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: preserve CSS comments in the AST printer

@ -141,7 +141,8 @@ export function parseCss(source) {
type: 'StyleSheetFile',
start: 0,
end: source.length,
children
children,
comments: parser.css_comments
};
}

@ -50,6 +50,9 @@ export class Parser {
/** */
index = 0;
/** @type {AST.CSS.CSSComment[]} */
css_comments = [];
/**
* Creates a minimal parser instance for CSS-only parsing.
* Skips Svelte component parsing setup.
@ -61,6 +64,7 @@ export class Parser {
parser.template = source;
parser.index = 0;
parser.loose = false;
parser.css_comments = [];
return parser;
}

@ -24,6 +24,7 @@ const REGEX_HTML_COMMENT_CLOSE = /-->/;
*/
export default function read_style(parser, start, attributes) {
const content_start = parser.index;
parser.css_comments = [];
const children = read_body(parser, (p) => p.match('</style') || p.index >= p.template.length);
const content_end = parser.index;
@ -36,6 +37,7 @@ export default function read_style(parser, start, attributes) {
end: parser.index,
attributes,
children,
comments: parser.css_comments,
content: {
start: content_start,
end: content_end,
@ -229,18 +231,22 @@ function read_selector(parser, inside_pseudo_class = false) {
end: parser.index
});
} else if (parser.eat('::')) {
const name = read_identifier(parser);
/** @type {AST.CSS.SelectorList | null} */
let args = null;
if (parser.eat('(')) {
args = read_selector_list(parser, true);
parser.eat(')', true);
}
relative_selector.selectors.push({
type: 'PseudoElementSelector',
name: read_identifier(parser),
name,
start,
end: parser.index
end: parser.index,
...(args && { args })
});
// We read the inner selectors of a pseudo element to ensure it parses correctly,
// but we don't do anything with the result.
if (parser.eat('(')) {
read_selector_list(parser, true);
parser.eat(')', true);
}
} else if (parser.eat(':')) {
const name = read_identifier(parser);
@ -323,7 +329,7 @@ function read_selector(parser, inside_pseudo_class = false) {
}
const index = parser.index;
allow_comment_or_whitespace(parser);
allow_comment_or_whitespace(parser, false);
if (parser.match(',') || (inside_pseudo_class ? parser.match(')') : parser.match('{'))) {
// rewind, so we know whether to continue building the selector list
@ -449,7 +455,7 @@ function read_block_item(parser) {
// read ahead to understand whether we're dealing with a declaration or a nested rule.
// this involves some duplicated work, but avoids a try-catch that would disguise errors
const start = parser.index;
read_value(parser);
read_value(parser, false);
const char = parser.template[parser.index];
parser.index = start;
@ -492,10 +498,13 @@ function read_declaration(parser) {
/**
* @param {Parser} parser
* @param {boolean} [capture_comments]
* @returns {string}
*/
function read_value(parser) {
function read_value(parser, capture_comments = true) {
let value = '';
/** @type {AST.CSS.CSSComment[]} */
const value_comments = [];
let escaped = false;
let in_url = false;
@ -523,6 +532,13 @@ function read_value(parser) {
} else if (char === '(' && value.slice(-3) === 'url') {
in_url = true;
} else if ((char === ';' || char === '{' || char === '}') && !in_url && !quote_mark) {
const leading_whitespace = value.length - value.trimStart().length;
for (const comment of value_comments) {
comment.position = Math.max(
0,
/** @type {number} */ (comment.position) - leading_whitespace
);
}
return value.trim();
} else if (
char === '/' &&
@ -530,13 +546,11 @@ function read_value(parser) {
!quote_mark &&
parser.template[parser.index + 1] === '*'
) {
parser.index += 2;
while (parser.index < parser.template.length) {
if (parser.template[parser.index] === '*' && parser.template[parser.index + 1] === '/') {
parser.index += 2;
break;
}
parser.index++;
const comment = read_comment(parser);
if (capture_comments) {
comment.position = value.length;
parser.css_comments.push(comment);
value_comments.push(comment);
}
continue;
}
@ -624,13 +638,16 @@ function read_identifier(parser) {
return identifier;
}
/** @param {Parser} parser */
function allow_comment_or_whitespace(parser) {
/**
* @param {Parser} parser
* @param {boolean} [capture_comments]
*/
function allow_comment_or_whitespace(parser, capture_comments = true) {
parser.allow_whitespace();
while (parser.match('/*') || parser.match('<!--')) {
if (parser.eat('/*')) {
parser.read_until(REGEX_COMMENT_CLOSE);
parser.eat('*/', true);
if (parser.match('/*')) {
const comment = read_comment(parser);
if (capture_comments) parser.css_comments.push(comment);
}
if (parser.eat('<!--')) {
@ -642,6 +659,25 @@ function allow_comment_or_whitespace(parser) {
}
}
/**
* @param {Parser} parser
* @returns {AST.CSS.CSSComment}
*/
function read_comment(parser) {
const start = parser.index;
parser.eat('/*', true);
const value = parser.read_until(REGEX_COMMENT_CLOSE);
parser.eat('*/', true);
const end = parser.index;
return {
type: 'CSSComment',
value,
start,
end
};
}
/**
* Parse standalone CSS content (not wrapped in `<style>`).
* @param {Parser} parser

@ -312,6 +312,9 @@ const css_visitors = {
}
context.next();
},
PseudoElementSelector() {
// Don't analyse these
}
};

@ -23,6 +23,9 @@ const visitors = {
context.next();
}
},
PseudoElementSelector() {
// Don't analyse these
},
ComplexSelector(node, context) {
if (
!node.metadata.used &&

@ -374,6 +374,9 @@ const visitors = {
if (node.name === 'is' || node.name === 'where' || node.name === 'has' || node.name === 'not') {
context.next();
}
},
PseudoElementSelector() {
// Functional pseudo-element arguments are not scoped as selectors.
}
};

@ -19,6 +19,9 @@ const LINE_BREAK_THRESHOLD = 50;
*/
export function print(ast, options = undefined) {
const comments = (ast.type === 'Root' && ast.comments) || [];
const css_comments =
(ast.type === 'Root' ? ast.css?.comments : ast.type === 'StyleSheet' ? ast.comments : null) ||
[];
return esrap.print(
ast,
@ -29,7 +32,7 @@ export function print(ast, options = undefined) {
getTrailingComments: options?.getTrailingComments
}),
...svelte_visitors(comments),
...css_visitors
...css_visitors(css_comments, comments)
}),
{
indent: options?.indent
@ -177,161 +180,263 @@ function base_element(node, context, comments) {
context.append(child_context);
}
/** @type {Visitors<AST.SvelteNode>} */
const css_visitors = {
Atrule(node, context) {
context.write(`@${node.name}`);
if (node.prelude) context.write(` ${node.prelude}`);
if (node.block) {
context.write(' ');
context.visit(node.block);
} else {
context.write(';');
}
},
/**
* @param {AST.CSS.CSSComment[]} comments
* @param {AST.JSComment[]} js_comments
* @returns {Visitors<AST.SvelteNode>}
*/
function css_visitors(comments, js_comments) {
let comment_index = 0;
/** @param {number} end */
const has_comment_before = (end) => comments[comment_index]?.start < end;
/**
* @param {Context} context
* @param {AST.CSS.CSSComment} comment
*/
function write_comment(context, comment) {
context.write(`/*${comment.value}*/`);
}
AttributeSelector(node, context) {
context.write(`[${node.name}`);
if (node.matcher) {
context.write(node.matcher);
context.write(`"${node.value}"`);
if (node.flags) {
context.write(` ${node.flags}`);
}
/**
* @param {Context} context
* @param {number} end
*/
function write_inline_comments(context, end) {
let written = false;
while (has_comment_before(end)) {
if (written) context.write(' ');
write_comment(context, comments[comment_index++]);
written = true;
}
context.write(']');
},
Block(node, context) {
context.write('{');
return written;
}
if (node.children.length > 0) {
context.indent();
context.newline();
/**
* @param {Context} context
* @param {string} value
* @param {number} end
*/
function write_value(context, value, end) {
let offset = 0;
while (has_comment_before(end)) {
const comment = comments[comment_index++];
const position = Math.max(offset, Math.min(comment.position ?? 0, value.length));
context.write(value.slice(offset, position));
write_comment(context, comment);
offset = position;
}
let started = false;
context.write(value.slice(offset));
}
for (const child of node.children) {
if (started) {
context.newline();
}
/**
* @param {Context} context
* @param {Array<AST.CSS.Rule | AST.CSS.Atrule | AST.CSS.Declaration>} children
* @param {number} end
* @param {boolean} margins
*/
function print_children(context, children, end, margins) {
let started = false;
context.visit(child);
const separate = () => {
if (!started) return;
if (margins) context.margin();
context.newline();
};
for (const child of children) {
while (has_comment_before(child.start)) {
separate();
write_comment(context, comments[comment_index++]);
started = true;
}
context.dedent();
context.newline();
separate();
context.visit(child);
started = true;
}
context.write('}');
},
while (has_comment_before(end)) {
separate();
write_comment(context, comments[comment_index++]);
started = true;
}
}
ClassSelector(node, context) {
context.write(`.${node.name}`);
},
/**
* @param {AST.CSS.SelectorList} node
* @param {Context} context
* @param {boolean} multiline
*/
function print_selector_list(node, context, multiline) {
let needs_separator = false;
let remaining_selectors = node.children.length;
ComplexSelector(node, context) {
for (const selector of node.children) {
while (has_comment_before(selector.start)) {
if (needs_separator) context.write(' ');
write_comment(context, comments[comment_index++]);
needs_separator = true;
}
if (needs_separator) {
if (multiline) context.newline();
else context.write(' ');
}
context.visit(selector);
needs_separator = true;
remaining_selectors -= 1;
if (remaining_selectors > 0) context.write(',');
}
},
}
Declaration(node, context) {
context.write(`${node.property}: ${node.value};`);
},
return {
Atrule(node, context) {
context.write(`@${node.name}`);
IdSelector(node, context) {
context.write(`#${node.name}`);
},
const prelude_end = node.block?.start ?? node.end;
if (node.prelude || has_comment_before(prelude_end)) {
context.write(' ');
write_value(context, node.prelude, prelude_end);
}
NestingSelector(node, context) {
context.write('&');
},
if (node.block) {
context.write(' ');
context.visit(node.block);
} else {
context.write(';');
}
},
AttributeSelector(node, context) {
context.write(`[${node.name}`);
if (node.matcher) {
context.write(node.matcher);
context.write(`"${node.value}"`);
if (node.flags) context.write(` ${node.flags}`);
}
context.write(']');
},
Nth(node, context) {
context.write(node.value);
},
Block(node, context) {
context.write('{');
Percentage(node, context) {
context.write(node.value);
},
if (node.children.length > 0 || has_comment_before(node.end)) {
context.indent();
context.newline();
print_children(context, node.children, node.end, false);
context.dedent();
context.newline();
}
PseudoClassSelector(node, context) {
context.write(`:${node.name}`);
context.write('}');
},
if (node.args) {
context.write('(');
ClassSelector(node, context) {
context.write(`.${node.name}`);
},
let started = false;
ComplexSelector(node, context) {
for (const selector of node.children) context.visit(selector);
},
for (const arg of node.args.children) {
if (started) {
context.write(', ');
}
Declaration(node, context) {
context.write(`${node.property}: `);
write_value(context, node.value, node.end);
context.write(';');
},
context.visit(arg);
IdSelector(node, context) {
context.write(`#${node.name}`);
},
started = true;
}
NestingSelector(node, context) {
context.write('&');
},
context.write(')');
}
},
Nth(node, context) {
context.write(node.value);
},
PseudoElementSelector(node, context) {
context.write(`::${node.name}`);
},
Percentage(node, context) {
context.write(node.value);
},
RelativeSelector(node, context) {
if (node.combinator) {
if (node.combinator.name === ' ') {
context.write(' ');
} else {
context.write(` ${node.combinator.name} `);
PseudoClassSelector(node, context) {
context.write(`:${node.name}`);
if (node.args) {
context.write('(');
context.visit(node.args);
if (has_comment_before(node.end)) {
context.write(' ');
write_inline_comments(context, node.end);
}
context.write(')');
}
}
},
PseudoElementSelector(node, context) {
context.write(`::${node.name}`);
if (node.args) {
context.write('(');
context.visit(node.args);
if (has_comment_before(node.end)) {
context.write(' ');
write_inline_comments(context, node.end);
}
context.write(')');
}
},
for (const selector of node.selectors) {
context.visit(selector);
}
},
RelativeSelector(node, context) {
if (node.combinator) {
if (node.combinator.name === ' ') context.write(' ');
else context.write(` ${node.combinator.name} `);
}
Rule(node, context) {
let started = false;
for (const selector of node.selectors) context.visit(selector);
},
for (const selector of node.prelude.children) {
if (started) {
context.write(',');
context.newline();
}
Rule(node, context) {
print_selector_list(node.prelude, context, true);
context.write(' ');
if (write_inline_comments(context, node.block.start)) context.write(' ');
context.visit(node.block);
},
context.visit(selector);
started = true;
}
SelectorList(node, context) {
print_selector_list(node, context, false);
},
context.write(' ');
context.visit(node.block);
},
StyleSheet(node, context) {
context.write('<style');
attributes(node, node.attributes, context, js_comments);
context.write('>');
SelectorList(node, context) {
let started = false;
for (const selector of node.children) {
if (started) {
context.write(', ');
if (node.children.length > 0 || node.comments.length > 0) {
context.indent();
context.newline();
print_children(context, node.children, node.content.end, true);
context.dedent();
context.newline();
}
context.visit(selector);
started = true;
}
},
context.write('</style>');
},
TypeSelector(node, context) {
context.write(node.name);
}
};
TypeSelector(node, context) {
context.write(node.name);
}
};
}
/**
* @param {AST.JSComment[]} comments
@ -845,34 +950,6 @@ const svelte_visitors = (comments) => ({
}
},
StyleSheet(node, context) {
context.write('<style');
attributes(node, node.attributes, context, comments);
context.write('>');
if (node.children.length > 0) {
context.indent();
context.newline();
let started = false;
for (const child of node.children) {
if (started) {
context.margin();
context.newline();
}
context.visit(child);
started = true;
}
context.dedent();
context.newline();
}
context.write('</style>');
},
SvelteBody(node, context) {
base_element(node, context, comments);
},

@ -8,6 +8,15 @@ export namespace _CSS {
export interface StyleSheetBase extends BaseNode {
children: Array<Atrule | Rule>;
/** CSS comments in source order */
comments: CSSComment[];
}
export interface CSSComment extends BaseNode {
type: 'CSSComment';
value: string;
/** Character offset in a containing declaration value or at-rule prelude */
position?: number;
}
export interface StyleSheetFile extends StyleSheetBase {
@ -135,6 +144,7 @@ export namespace _CSS {
export interface PseudoElementSelector extends BaseNode {
type: 'PseudoElementSelector';
name: string;
args?: SelectorList;
}
export interface PseudoClassSelector extends BaseNode {

@ -69,9 +69,21 @@ describe('parseCss', () => {
});
it('parses comments', () => {
const ast = parseCss('/* comment */ div { color: red; }');
const ast = parseCss(
'/* top */ div, /* selector */ span { /* block */ color: /* value */ red; }'
);
assert.equal(ast.children.length, 1);
assert.equal(ast.children[0].type, 'Rule');
assert.deepEqual(
ast.comments.map((comment) => comment.value),
[' top ', ' selector ', ' block ', ' value ']
);
assert.deepEqual(ast.comments[0], {
type: 'CSSComment',
value: ' top ',
start: 0,
end: 9
});
});
it('parses complex selectors', () => {

@ -33,6 +33,7 @@
"type": "Style",
"start": 16,
"end": 56,
"comments": [],
"attributes": [],
"children": [
{

@ -33,6 +33,7 @@
"type": "Style",
"start": 16,
"end": 66,
"comments": [],
"attributes": [],
"children": [
{

@ -1071,6 +1071,14 @@
"end": 797
}
],
"comments": [
{
"type": "CSSComment",
"value": " test that all these are parsed correctly ",
"start": 12,
"end": 58
}
],
"content": {
"start": 7,
"end": 798,
@ -1125,5 +1133,6 @@
}
]
},
"options": null
"options": null,
"comments": []
}

@ -25,7 +25,35 @@
"type": "PseudoElementSelector",
"name": "view-transition-old",
"start": 60,
"end": 81
"end": 86,
"args": {
"type": "SelectorList",
"start": 82,
"end": 85,
"children": [
{
"type": "ComplexSelector",
"start": 82,
"end": 85,
"children": [
{
"type": "RelativeSelector",
"combinator": null,
"selectors": [
{
"type": "TypeSelector",
"name": "x-y",
"start": 82,
"end": 85
}
],
"start": 82,
"end": 85
}
]
}
]
}
}
],
"start": 60,
@ -89,7 +117,35 @@
"type": "PseudoElementSelector",
"name": "view-transition-old",
"start": 119,
"end": 140
"end": 145,
"args": {
"type": "SelectorList",
"start": 141,
"end": 144,
"children": [
{
"type": "ComplexSelector",
"start": 141,
"end": 144,
"children": [
{
"type": "RelativeSelector",
"combinator": null,
"selectors": [
{
"type": "TypeSelector",
"name": "x-y",
"start": 141,
"end": 144
}
],
"start": 141,
"end": 144
}
]
}
]
}
}
],
"start": 119,
@ -147,7 +203,35 @@
"type": "PseudoElementSelector",
"name": "highlight",
"start": 171,
"end": 182
"end": 199,
"args": {
"type": "SelectorList",
"start": 183,
"end": 198,
"children": [
{
"type": "ComplexSelector",
"start": 183,
"end": 198,
"children": [
{
"type": "RelativeSelector",
"combinator": null,
"selectors": [
{
"type": "TypeSelector",
"name": "rainbow-color-1",
"start": 183,
"end": 198
}
],
"start": 183,
"end": 198
}
]
}
]
}
}
],
"start": 171,
@ -200,7 +284,35 @@
"type": "PseudoElementSelector",
"name": "part",
"start": 234,
"end": 240
"end": 245,
"args": {
"type": "SelectorList",
"start": 241,
"end": 244,
"children": [
{
"type": "ComplexSelector",
"start": 241,
"end": 244,
"children": [
{
"type": "RelativeSelector",
"combinator": null,
"selectors": [
{
"type": "TypeSelector",
"name": "foo",
"start": 241,
"end": 244
}
],
"start": 241,
"end": 244
}
]
}
]
}
}
],
"start": 220,
@ -247,7 +359,35 @@
"type": "PseudoElementSelector",
"name": "slotted",
"start": 266,
"end": 275
"end": 285,
"args": {
"type": "SelectorList",
"start": 276,
"end": 284,
"children": [
{
"type": "ComplexSelector",
"start": 276,
"end": 284,
"children": [
{
"type": "RelativeSelector",
"combinator": null,
"selectors": [
{
"type": "ClassSelector",
"name": "content",
"start": 276,
"end": 284
}
],
"start": 276,
"end": 284
}
]
}
]
}
}
],
"start": 266,
@ -390,6 +530,26 @@
"end": 377
}
],
"comments": [
{
"type": "CSSComment",
"value": " test that all these are parsed correctly ",
"start": 12,
"end": 58
},
{
"type": "CSSComment",
"value": "button",
"start": 311,
"end": 321
},
{
"type": "CSSComment",
"value": "p after h1",
"start": 332,
"end": 346
}
],
"content": {
"start": 7,
"end": 378,
@ -405,5 +565,6 @@
"type": "Fragment",
"nodes": []
},
"options": null
"options": null,
"comments": []
}

@ -53,6 +53,7 @@
"end": 82
}
],
"comments": [],
"content": {
"start": 61,
"end": 83,
@ -77,6 +78,24 @@
]
},
"options": null,
"comments": [
{
"type": "Line",
"value": " script and style but no markup",
"start": 10,
"end": 43,
"loc": {
"start": {
"line": 2,
"column": 1
},
"end": {
"line": 2,
"column": 34
}
}
}
],
"instance": {
"type": "Script",
"start": 0,

@ -68,6 +68,7 @@
"end": 196
}
],
"comments": [],
"content": {
"start": 43,
"end": 197,
@ -122,5 +123,6 @@
}
]
},
"options": null
"options": null,
"comments": []
}

@ -0,0 +1,26 @@
<style>
/* first */
/* second */
.foo, /* between */
.bar /* trailing */ {
/* block */
color: r /* value */ ed;
}
@media screen/* media */and (width > 0) {
/* nested */
.baz {
display: block;
}
}
.empty {
/* only */
}
.part::part(/* argument */ label) {
display: block;
}
/* last */
</style>

@ -0,0 +1,28 @@
<style>
/* first */
/* second */
.foo, /* between */
.bar /* trailing */ {
/* block */
color: r /* value */ ed;
}
@media screen/* media */and (width > 0) {
/* nested */
.baz {
display: block;
}
}
.empty {
/* only */
}
.part::part(/* argument */ label) {
display: block;
}
/* last */
</style>

@ -1694,6 +1694,15 @@ declare module 'svelte/compiler' {
export interface StyleSheetBase extends BaseNode {
children: Array<Atrule | Rule>;
/** CSS comments in source order */
comments: CSSComment[];
}
export interface CSSComment extends BaseNode {
type: 'CSSComment';
value: string;
/** Character offset in a containing declaration value or at-rule prelude */
position?: number;
}
export interface StyleSheetFile extends StyleSheetBase {
@ -1788,6 +1797,7 @@ declare module 'svelte/compiler' {
export interface PseudoElementSelector extends BaseNode {
type: 'PseudoElementSelector';
name: string;
args?: SelectorList;
}
export interface PseudoClassSelector extends BaseNode {

Loading…
Cancel
Save