feat: export `parseCss` from `svelte/compiler` (#17496)

* feat: export  from

* changeset

* fix: Errors

* set_source, so state is correct if an error occurs

* simplify

---------

Co-authored-by: Rich Harris <rich.harris@vercel.com>
pull/17509/head
Elliott Johnson 8 months ago committed by GitHub
parent 5ce6186889
commit c9ebd6a885
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': minor
---
feat: export `parseCss` from `svelte/compiler`

@ -3,8 +3,9 @@
/** @import { AST } from './public.js' */ /** @import { AST } from './public.js' */
import { walk as zimmerframe_walk } from 'zimmerframe'; import { walk as zimmerframe_walk } from 'zimmerframe';
import { convert } from './legacy.js'; import { convert } from './legacy.js';
import { parse as _parse } from './phases/1-parse/index.js'; import { parse as _parse, Parser } from './phases/1-parse/index.js';
import { remove_typescript_nodes } from './phases/1-parse/remove_typescript_nodes.js'; import { remove_typescript_nodes } from './phases/1-parse/remove_typescript_nodes.js';
import { parse_stylesheet } from './phases/1-parse/read/style.js';
import { analyze_component, analyze_module } from './phases/2-analyze/index.js'; import { analyze_component, analyze_module } from './phases/2-analyze/index.js';
import { transform_component, transform_module } from './phases/3-transform/index.js'; import { transform_component, transform_module } from './phases/3-transform/index.js';
import { validate_component_options, validate_module_options } from './validate-options.js'; import { validate_component_options, validate_module_options } from './validate-options.js';
@ -118,6 +119,29 @@ export function parse(source, { modern, loose } = {}) {
return to_public_ast(source, ast, modern); return to_public_ast(source, ast, modern);
} }
/**
* The parseCss function parses a CSS stylesheet, returning its abstract syntax tree.
*
* @param {string} source The CSS source code
* @returns {Omit<AST.CSS.StyleSheet, 'attributes' | 'content'>}
*/
export function parseCss(source) {
source = remove_bom(source);
state.reset({ warning: () => false, filename: undefined });
state.set_source(source);
const parser = Parser.forCss(source);
const children = parse_stylesheet(parser);
return {
type: 'StyleSheet',
start: 0,
end: source.length,
children
};
}
/** /**
* @param {string} source * @param {string} source
* @param {AST.Root} ast * @param {AST.Root} ast

@ -34,6 +34,20 @@ export class Parser {
/** */ /** */
index = 0; index = 0;
/**
* Creates a minimal parser instance for CSS-only parsing.
* Skips Svelte component parsing setup.
* @param {string} source
* @returns {Parser}
*/
static forCss(source) {
const parser = Object.create(Parser.prototype);
parser.template = source;
parser.index = 0;
parser.loose = false;
return parser;
}
/** Whether we're parsing in TypeScript mode */ /** Whether we're parsing in TypeScript mode */
ts = false; ts = false;

@ -24,10 +24,11 @@ const REGEX_HTML_COMMENT_CLOSE = /-->/;
*/ */
export default function read_style(parser, start, attributes) { export default function read_style(parser, start, attributes) {
const content_start = parser.index; const content_start = parser.index;
const children = read_body(parser, '</style'); const children = read_body(parser, (p) => p.match('</style') || p.index >= p.template.length);
const content_end = parser.index; const content_end = parser.index;
parser.read(/^<\/style\s*>/); parser.eat('</style', true);
parser.read(/^\s*>/);
return { return {
type: 'StyleSheet', type: 'StyleSheet',
@ -46,20 +47,14 @@ export default function read_style(parser, start, attributes) {
/** /**
* @param {Parser} parser * @param {Parser} parser
* @param {string} close * @param {(parser: Parser) => boolean} finished
* @returns {any[]} * @returns {Array<AST.CSS.Rule | AST.CSS.Atrule>}
*/ */
function read_body(parser, close) { function read_body(parser, finished) {
/** @type {Array<AST.CSS.Rule | AST.CSS.Atrule>} */ /** @type {Array<AST.CSS.Rule | AST.CSS.Atrule>} */
const children = []; const children = [];
while (parser.index < parser.template.length) { while ((allow_comment_or_whitespace(parser), !finished(parser))) {
allow_comment_or_whitespace(parser);
if (parser.match(close)) {
return children;
}
if (parser.match('@')) { if (parser.match('@')) {
children.push(read_at_rule(parser)); children.push(read_at_rule(parser));
} else { } else {
@ -67,7 +62,7 @@ function read_body(parser, close) {
} }
} }
e.expected_token(parser.template.length, close); return children;
} }
/** /**
@ -627,3 +622,12 @@ function allow_comment_or_whitespace(parser) {
parser.allow_whitespace(); parser.allow_whitespace();
} }
} }
/**
* Parse standalone CSS content (not wrapped in `<style>`).
* @param {Parser} parser
* @returns {Array<AST.CSS.Rule | AST.CSS.Atrule>}
*/
export function parse_stylesheet(parser) {
return read_body(parser, (p) => p.index >= p.template.length);
}

@ -0,0 +1,138 @@
import { assert, describe, it } from 'vitest';
import { parseCss } from 'svelte/compiler';
describe('parseCss', () => {
it('parses a simple rule', () => {
const ast = parseCss('div { color: red; }');
assert.equal(ast.type, 'StyleSheet');
assert.equal(ast.children.length, 1);
assert.equal(ast.children[0].type, 'Rule');
});
it('parses at-rules', () => {
const ast = parseCss('@media (min-width: 800px) { div { color: red; } }');
assert.equal(ast.children.length, 1);
assert.equal(ast.children[0].type, 'Atrule');
if (ast.children[0].type === 'Atrule') {
assert.equal(ast.children[0].name, 'media');
}
});
it('parses @import', () => {
const ast = parseCss("@import 'foo.css';");
assert.equal(ast.children.length, 1);
assert.equal(ast.children[0].type, 'Atrule');
if (ast.children[0].type === 'Atrule') {
assert.equal(ast.children[0].name, 'import');
assert.equal(ast.children[0].block, null);
}
});
it('parses multiple rules', () => {
const ast = parseCss('div { color: red; } span { color: blue; }');
assert.equal(ast.children.length, 2);
});
it('has correct start/end positions', () => {
const ast = parseCss('div { color: red; }');
assert.equal(ast.start, 0);
assert.equal(ast.end, 19);
});
it('strips BOM', () => {
const ast = parseCss('\uFEFFdiv { color: red; }');
assert.equal(ast.start, 0);
assert.equal(ast.end, 19);
});
it('parses nested rules', () => {
const ast = parseCss('div { color: red; span { color: blue; } }');
assert.equal(ast.children.length, 1);
const rule = ast.children[0];
assert.equal(rule.type, 'Rule');
if (rule.type === 'Rule') {
assert.equal(rule.block.children.length, 2); // declaration + nested rule
}
});
it('parses empty stylesheet', () => {
const ast = parseCss('');
assert.equal(ast.type, 'StyleSheet');
assert.equal(ast.children.length, 0);
assert.equal(ast.start, 0);
assert.equal(ast.end, 0);
});
it('parses whitespace-only stylesheet', () => {
const ast = parseCss(' \n\t ');
assert.equal(ast.children.length, 0);
});
it('parses comments', () => {
const ast = parseCss('/* comment */ div { color: red; }');
assert.equal(ast.children.length, 1);
assert.equal(ast.children[0].type, 'Rule');
});
it('parses complex selectors', () => {
const ast = parseCss('div > span + p ~ a { color: red; }');
assert.equal(ast.children.length, 1);
const rule = ast.children[0];
if (rule.type === 'Rule') {
assert.equal(rule.prelude.type, 'SelectorList');
assert.equal(rule.prelude.children.length, 1);
// div > span + p ~ a has 4 relative selectors
assert.equal(rule.prelude.children[0].children.length, 4);
}
});
it('parses pseudo-classes and pseudo-elements', () => {
const ast = parseCss('div:hover::before { color: red; }');
assert.equal(ast.children.length, 1);
const rule = ast.children[0];
if (rule.type === 'Rule') {
const selectors = rule.prelude.children[0].children[0].selectors;
assert.equal(selectors.length, 3); // div, :hover, ::before
assert.equal(selectors[0].type, 'TypeSelector');
assert.equal(selectors[1].type, 'PseudoClassSelector');
assert.equal(selectors[2].type, 'PseudoElementSelector');
}
});
it('parses @keyframes', () => {
const ast = parseCss('@keyframes fade { from { opacity: 0; } to { opacity: 1; } }');
assert.equal(ast.children.length, 1);
assert.equal(ast.children[0].type, 'Atrule');
if (ast.children[0].type === 'Atrule') {
assert.equal(ast.children[0].name, 'keyframes');
assert.notEqual(ast.children[0].block, null);
}
});
it('parses class and id selectors', () => {
const ast = parseCss('.foo#bar { color: red; }');
assert.equal(ast.children.length, 1);
const rule = ast.children[0];
if (rule.type === 'Rule') {
const selectors = rule.prelude.children[0].children[0].selectors;
assert.equal(selectors.length, 2);
assert.equal(selectors[0].type, 'ClassSelector');
assert.equal(selectors[1].type, 'IdSelector');
}
});
it('parses attribute selectors', () => {
const ast = parseCss('[data-foo="bar"] { color: red; }');
assert.equal(ast.children.length, 1);
const rule = ast.children[0];
if (rule.type === 'Rule') {
const selectors = rule.prelude.children[0].children[0].selectors;
assert.equal(selectors.length, 1);
assert.equal(selectors[0].type, 'AttributeSelector');
if (selectors[0].type === 'AttributeSelector') {
assert.equal(selectors[0].name, 'data-foo');
assert.equal(selectors[0].value, 'bar');
}
}
});
});

@ -884,6 +884,12 @@ declare module 'svelte/compiler' {
modern?: false; modern?: false;
loose?: boolean; loose?: boolean;
} | undefined): Record<string, any>; } | undefined): Record<string, any>;
/**
* The parseCss function parses a CSS stylesheet, returning its abstract syntax tree.
*
* @param source The CSS source code
* */
export function parseCss(source: string): Omit<AST.CSS.StyleSheet, "attributes" | "content">;
/** /**
* @deprecated Replace this with `import { walk } from 'estree-walker'` * @deprecated Replace this with `import { walk } from 'estree-walker'`
* */ * */

Loading…
Cancel
Save